• Non ci sono risultati.

Algoritmi di sintes

Nel documento Introduzione a SuperCollider (pagine 88-96)

Introduzione a SuperCollider

3. conversione digitale-analogica: per essere ascoltabile, la sequenza numeri ca che compone il segnale viene riconvertita in segnale analogico attraverso

4.3 Algoritmi di sintes

Un algoritmo per la sintesi del suono è una procedura formalizzata che ha come scopo la generazione della rappresentazione numerica di un segnale audio.

Il linguaggio SC (sclang) permette di sperimentare algoritmi di sintesi del segnale in tempo differito senza scomodare –per ora– il server audio (scsynth). Non è certo un modo usuale di utilizzare SuperCollider, e tra l’altro sclang è pensato come un linguaggio di alto livello (“lontano dalla macchina e vicino al programmatore”), intrinsecamente non ottimizzato per operazioni numerica- mente massive (quello che in gergo si chiama “number crunching”). Tuttavia, a livello didattico, sia sul lato della sintesi dei segnali che su quello dell’appro- fondimento linguistico, può essere utile ragionare su alcuni semplici algoritmi di sintesi.

Poiché il segnale previsto per i CD audio è campionato a 44.100 Hz (attual- mente lo standard audio più diffuso), se si vuole generare un segnale mono della durata di 1 secondo a qualità CD, è necessario costruire un array di 44.100 posti: il processo di sintesi del segnale consiste allora nel definire ed implemen- tare un algoritmo che permetta di “riempire” ognuno di questi posti (letteral- mente numerati) con un valore. Così, se si vuole generare un segnale sinusoi-

dale puro, il metodo più semplice consiste nel calcolare l’ampiezza𝑦 per ogni

valore𝑦 all’indice 𝑥 dell’array 𝐴, che rappresenta 𝑆. Una funzione periodica si definisce come segue:

𝑦 = 𝑓(2𝜋 × 𝑥) Un segnale sinusoidale è descritto dalla formula:

𝑦 = 𝑎 × 𝑠𝑖𝑛(2𝜋 × 𝑘 × 𝑥)

L’effetto dei parametri𝑎 e 𝑘 è rappresentato in Figura 4.6, dove è disegnato

(in forma continua) un segnale (discreto) composto da100 campioni.

0 10 20 30 40 50 60 70 80 90 100 -1 -0.8 -0.6 -0.4 -0.2 0 0.2 0.4 0.6 0.8 1 0 10 20 30 40 50 60 70 80 90 100 -1 -0.8 -0.6 -0.4 -0.2 0 0.2 0.4 0.6 0.8 1 𝑎 = 1, 𝑘 = 1/1000 𝑎 = 1, 𝑘 = 2/1000 0 10 20 30 40 50 60 70 80 90 100 -1 -0.8 -0.6 -0.4 -0.2 0 0.2 0.4 0.6 0.8 1 0 10 20 30 40 50 60 70 80 90 100 -1 -0.8 -0.6 -0.4 -0.2 0 0.2 0.4 0.6 0.8 1 𝑎 = 0.5, 𝑘 = 1/1000 𝑎 = 0.5, 𝑘 = 2/1000

Fig. 4.6 Sinusoide e variazione dei parametri𝑎 e 𝑘.

L’algoritmo di sintesi per un segnale sinusoidale, scritto in pseudo-codice (ov- vero in un linguaggio inesistente ma che permette di illustrare in forma astratta la programmazione), è dunque il seguente:

Per ogni x in A: y = a*sin(k*x) A[x] = y

k che controllano l’ampiezza e la frequenza della sinusoide, mentre la seconda riga assegna all’indice x di A il valore y. SC permette agevolmente di imple- mentare un simile algoritmo. Ad esempio, il primo segnale di Figura 4.6 è stato ottenuto con il codice seguente:

1 var sig, amp = 1, freq = 1, val ; 2 sig = Array.newClear(100) ; 3 sig.size.do({ arg x ;

4 val = amp*sin(2pi*freq*(x/sig.size)) ; 5 sig[x]= val ;

6 }) ;

7 sig.plot(minval:-1, maxval:1, discrete:true) ;

Nel codice, la riga 1 definisce le variabili che conterranno rispettivamen- te il segnale, l’ampiezza, la frequenza e il valore incrementale dei campioni. Viene quindi creato un array di 100 elementi. Le righe 3-6 sono occupate da un ciclo: sig.size restituisce la dimensione dell’array (100). Per sig.size (100) volte viene valutata la funzione nel ciclo do: poiché x rappresenta l’incremen-

to lungo l’array (che rappresenta il tempo, ovvero0, 1, 2…98, 99), è in funzione

di x che viene calcolato il valore della funzione (𝑓[𝑥]). Il valore della frequenza del segnale desiderato indica il numero di cicli (2𝜋) al secondo. Nel caso, freq

= 1 indica che un ciclo della sinusoide (che va da0 a 1) verrà “distribuito” su

100 punti. Di qui, il significato di x/sig.size. Se la frequenza desiderata fosse 440 Hz (→ cicli al secondo, freq = 440) allora ci dovrebbero essere 440 × 2𝜋 cicli ogni secondo (2*pi*freq). Questo valore deve essere distribuito su tutti i posti dell’array (x/sig.size). Ottenuto il valore val, questo sarà compreso (per

definizione trigonometrica) nell’escursione[−1, 1] e dunque può essere scalato

per amp. La riga 5 assegna al posto x di sig il valore val. Il segnale viene quindi

disegnato nell’escursione d’ampiezza[−1, 1] in forma discreta (7).

Una classe utile per calcolare segnali secondo quanto visto finora è Signal, che è una sottoclasse di ArrayedCollection (la superclasse più generale degli oggetti array-like) specializzata per la generazione di segnali. La classe Signal è specializzata per contenere array di grandi dimensioni e omogenei per tipo di dato (come tipico per i segnali audio). L’esempio seguente è un’ovvia riscrittura

del precedente con Signal e un segnale di44100 campioni, pari a un secondo

1 var sig, amp = 1, freq = 440, val ; 2 sig = Signal.newClear(44100) ; 3 sig.size.do({ arg x ;

4 val = amp*sin(2pi*freq*(x/sig.size)) ; 5 sig[x]= val ;

6 }) ;

Il segnale ottenuto può essere salvato su hard disk in formato audio così da essere eseguito: Signal può così essere utilizzato per generare materiali audio utilizzabili in seguito, attraverso (e sempre dal lato client) la classe SoundFile.

1 (

2 var sig, amp = 1, freq = 440, val ;

3 var soundFile ;

5 sig = Signal.newClear(44100) ; 6 sig.size.do({ arg x ;

7 val = amp*sin(2pi*freq*(x/sig.size)) ; 8 sig[x]= val ;

9 }) ;

11 soundFile = SoundFile.new ;

12 soundFile.headerFormat_("AIFF").sampleFormat_("int16").numChannels_(1) ;

13 soundFile.openWrite("/Users/andrea/Desktop/signal.aiff") ;

14 soundFile.writeData(sig) ;

15 soundFile.close ;

16 )

Nell’esempio, SoundFile crea un file audio (11), di cui sono specificabili le proprietà (12): il tipo ("AIFF"), la quantizzazione (16 bit, "int16"), il numero di

canali (mono, 1)1. È importante specificare la quantizzazione perché SC inter-

namente (e per default) lavora a 32 bit, in formato float: un formato utile per la precisione interna ma piuttosto scomodo come formato di rilascio finale. Do- po aver create l’oggetto di tipo file è necessario specificare il percorso del file

1 Si noti il concatenamento dei messaggi: ognuno dei metodi restituisce infatti

ray sig (14), che è stato generato esattamente come nell’esempio precedente. Ad operazioni concluse, il file deve essere chiuso (15), altrimenti non sarà leggibile. Il file creato può così essere ascoltato.

Per evitare tutte le volte di scrivere su file i dati generati, è possibile utiliz- zare il metodo play che offre la possibilità di ascoltare il contenuto dell’oggetto Signal (come ciò avvenga nel dettaglio lo si vedrà poi).

1 var sig, amp = 1, freq = 441, val ; 2 sig = Signal.newClear(44100) ; 3 sig.size.do({ arg x ;

4 val = amp*sin(2pi*freq*(x/sig.size)) ; 5 sig[x]= val ;

6 }) ;

7 sig.play(true) ;

Ci si può chiedere a quale frequenza: fino ad ora la frequenza è stata infat- ti specificata soltanto in termini di frequenza relativa tra le componenti. Poiché

con play si passa al tempo reale2, vanno considerate altre variabili che verranno

discusse in seguito. SC per default genera un segnale con un tasso di campiona-

mento (sample rate, sr) pari a44.100 campioni al secondo. Il contenuto dell’array,

dopo essere stato messo in una locazione di memoria temporanea (un “buffer”)

viene letto perciò alla frequenza di44.100 campioni al secondo (un segnale di

44.100 campioni viene “consumato” in un secondo). In altri termini, SC pre-

leva un valore dal buffer ogni1/44.100 secondi. Con il metodo play(true) (il

valore di default) l’esecuzione è in loop: una volta arrivato alla fine, SC ripren-

de da capo. Dunque, se𝑠𝑖𝑧𝑒 è la dimensione in punti dell’array, il periodo del

segnale (“quanto dura” in secondi) è𝑠𝑖𝑧𝑒/𝑠𝑟, e la frequenza è il suo inverso:

1/𝑠𝑖𝑧𝑒/𝑠𝑟 = 𝑠𝑟/𝑠𝑖𝑧𝑒. Se 𝑠𝑖𝑧𝑒 = 1000, allora 𝑓 = 44.100/1000 = 44.1𝐻𝑧. Vice-

versa, se si intende ottenere un segnale la cui fondamentale sia𝑓 , la dimensione

dell’array che contenga un singolo ciclo deve essere𝑠𝑖𝑧𝑒 = 𝑠𝑟/𝑓 . È un calcolo

soltanto approssimativo perché𝑠𝑖𝑧𝑒 deve essere necessariamente intero. Se la

dimensione dell’array è44.100 (come in molti esempi nel capitolo, ma non in

tutti) allora il segnale viene letto una volta al secondo. Poiché l’array contiene

un numero𝑓𝑟𝑒𝑞 di cicli, freq indica effettivamente la frequenza del segnale.

2 Perciò è necessario effettuare il boot del server audio, premendo la finestra “Ser-

Nel seguito, non verrà esplicitamente usato play: al lettore la possibilità di uti- lizzare il metodo e il compito di adeguare gli esempi.

Tornando ora alle questioni relative alla sintesi, come si è detto, un segna- le periodico è una somma di sinusoidi. Una implementazione “rudimentale” potrebbe essere la seguente:

1 var sig, sig1, sig2, sig3 ; 2 var amp = 1, freq = 1, val ; 3 sig = Signal.newClear(44100) ; 4 sig1 = Signal.newClear(44100) ; 5 sig2 = Signal.newClear(44100) ; 6 sig3 = Signal.newClear(44100) ; 8 sig1.size.do({ arg x ;

9 val = amp*sin(2pi*freq*(x/sig.size)) ; 10 sig1[x]= val ;

11 }) ;

12 sig2.size.do({ arg x ;

13 val = amp*sin(2pi*freq*2*(x/sig.size)) ; 14 sig2[x]= val ;

15 }) ;

16 sig3.size.do({ arg x ;

17 val = amp*sin(2pi*freq*3*(x/sig.size)) ; 18 sig3[x]= val ;

19 }) ;

20 sig = (sig1+sig2+sig3)/3 ; 21 sig.plot ;

Nell’esempio si vogliono calcolare la fondamentale e le prime due armo- niche. Allo scopo si generano quattro oggetti Signal della stessa dimensione (3-6). Quindi si calcolano i quattro segnali, ripetendo un codice strutturalmente identico che varia solo per la presenza di un moltiplicatore di freq (8-19). Infine, sig è utilizzato per contenere la somma degli array (implementata negli array come somma degli elementi per le posizioni successive). L’obiettivo è appunto una “somma” di sinusoidi. I valori nell’array sig vengono quindi cautelativa-

mente divisi per3. Infatti, se il segnale deve rimanere nell’escursione [−1, +1],

allora lo scenario peggiore possibile è quello dei tre picchi (positivi o negativi)

in fase, la cui somma sarebbe appunto3. Dividendo per 3, allora l’ampiezza

re sullo schermo è conveniente ridurre molto il numero dei punti e impostare 𝑓𝑟𝑒𝑞 = 1.

In programmazione, la ripetizioni di blocchi di codice sostanzialmente iden- tici è sempre sospetta. Infatti, indica che l’iterazione non è in carico al program- ma ma al programmatore. Inoltre, la ripetizione tipicamente porta errori. An- cora, il programma non è modulare perché è pensato non per il caso generale

di una somma di𝑛 sinusoidi, ma per il caso specifico di 3 sinusoidi. Infine, il

codice diventa inutilmente prolisso e difficile da leggere.

L’algoritmo seguente permette in maniera più elegante di calcolare un se-

gnale periodico che includa un numero𝑛 di armoniche, definito nella variabile

harm.

1 var sig, amp = 1, freq = 440, val ;

2 var sample, harm = 3 ;

3 sig = Signal.newClear(44100) ; 4 sig.size.do({ arg x ;

5 sample = x/sig.size ;

6 val = 0 ; 7 harm.do{ arg i ; 8 harm = i+1 ;

9 val = val + (amp*sin(2pi*freq*(i+1)*sample) ); 10 } ;

11 sig[x]= val/harm ;

12 }) ;

Il ciclo (4) calcola il valore di ogni campione e usa sample per tenere in me-

moria la posizione. Quindi per ogni campione reinizializza val a0. A questo

punto per ogni campione effettua un numero𝑛 di calcoli, cioè semplicemente

calcola il valore della funzione seno per la frequenza fondamentale e le prime 𝑛 − 1 armoniche, come fossero 𝑛 segnali diversi. A ogni calcolo, aggiunge il valore ottenuto a quello calcolato per lo stesso campione dalle altre funzioni (la “somma” di sinusoidi). Infine, val viene memorizzato nell’elemento di sig su cui si sta operando, dopo averlo diviso per il numero delle armoniche (si ricordi l’argomento di cautela precedente). Rispetto all’esempio precedente, il codice definisce univocamente la funzione richiesta e la parametrizza come desiderato (si provi a variare harm aumentando le armoniche), è più semplice da corregge- re, è generale, è più compatto. Vale la pena introdurre un altro punto, anche se non avrà conseguenze pratiche. L’esempio iterativo è un esempio di approccio

non in tempo reale. Prima si calcolano i tre segnali, quindi si sommano (mixano, si potrebbe dire). In tempo reale, come si vedrà, il segnale viene generato con- tinuamente, dunque non è pensabile effettuare una sintesi di un segnale la cui durata è a rigore indeterminata (“qualcosa sta continuando a suonare”). Il se- condo esempio è invece potenzialmente implementabile in tempo reale: infatti, l’algoritmo calcola il valore finale di un campione alla volta. Nel caso discusso, questo viene scritto in una posizione incrementale dell’array sig, ma potrebbe essere invece inviato alla scheda audio per la conversione.

A partire dall’ultimo esempio diventa agevole possibile generare altri se- gnali periodici già ricordati. Così, per definizione, un onda a dente di sega è un

segnale periodico che ha teoricamente infinite armoniche di frequenza𝑓 × 𝑛,

dove𝑓 è la frequenza fondamentale e 𝑛 = 2, 3, 4, …, e di ampiezza rispettiva-

mente pari a1/2, 3, 4… (ovvero ognuna inversamente proporzionale al numero

della armonica relativa). L’esempio seguente introduce una piccola modifica nell’algoritmo precedente.

1 var sig, amp = 1, freq = 440, val ;

2 var ampl ; // ampl vale per ogni componente

3 var sample, harm = 10 ;

4 sig = Signal.newClear(44100) ; 5 sig.size.do({ arg x ;

6 sample = x/sig.size ;

7 val = 0 ; 8 harm.do{ arg i ; 9 harm = i+1 ; 10 ampl = amp/harm ;

11 val = val + (ampl*sin(2pi*freq*(i+1)*sample) ); 12 } ;

13 sig[x]= val/harm ;

14 }) ;

L’unica differenza è l’introduzione della variabile ampl, per ogni campione, per ogni armonica (10) viene calcolata l’ampiezza relativa dividendo l’ampiez- za di riferimento amp per il numero d’armonica. Se si prova a incrementare harm si nota come l’onda a dente di sega venga progressivamente approssimata con maggiore esattezza.

sega, ma aggiungendo soltanto le armoniche dispari (𝑛 = 1, 3, 5…). In altri ter- mini, un’onda quadra è un’onda a dente di sega in cui le armoniche pari hanno ampiezza nulla. Il codice è riportato nell’esempio seguente.

1 var sig, amp = 1, freq = 440, val ;

2 var ampl ; // ampl vale per ogni componente

3 var sample, harm = 20 ;

4 sig = Signal.newClear(44100) ; 5 sig.size.do({ arg x ;

6 sample = x/sig.size ;

7 val = 0 ; 8 harm.do{ arg i ; 9 harm = i+1 ;

10 if(harm.odd){ // e’ dispari? 11 ampl = amp/harm ;

12 val = val + (ampl*sin(2pi*freq*(i+1)*sample) ); 13 }

14 } ;

15 sig[x]= val/harm ;

16 }) ;

Come si vede, il valore di ampl questa volta è sottoposto ad una valutazione condizionale (10), in cui harm.odd restituisce vero o falso a seconda se la varia- bile (il numero di armonica) è dispari o pari. Se dispari, il valore viene calcolato come nel caso dell’onda a dente di sega, se è pari, semplicemente val non è calcolato per la componente che non viene incrementata.

Alcune variazioni del numero di armoniche (3,5,40) per i tre casi discussi (sinusoidi a ampiezza costante, onda a dente di sega e onda quadra) sono raffi- gurate in Figura 4.7. In particolare, nell’onda a dente di sega e in quella quadra il contributo delle armoniche è visibile nel numero delle “gobbe” che il segnale presenta.

Nel documento Introduzione a SuperCollider (pagine 88-96)