Ugens
Come in ogni modello di spazio-tempo, ogni punto dello spazio ha quattro coordinate (x, y, z, t), tre delle quali rappresentano un punto dello spazio, e la quarta un preciso momento temporale: intuitivamente, ciascun punto rappresenta quindi un evento, un fatto accaduto in un preciso luogo in un preciso istante. Il movimento di un oggetto puntiforme è quindi descritto da una curva, con coordinata temporale crescente, detto linea di universo.   P. Davies
Ora che sappiamo accendere il Server di SuperCollider e ricevere o inviare alle porte del computer segnali audio vediamo come generarli o elaborarli utilizzando un insieme di oggetti chiamati UGens o Unit Generator. Questi sono Classi che possono appunto generare, leggere o elaborare segnali audio, o meglio compire operazioni su flussi di numeri (numeric stream) che decrivono campioni. Cominciano sempre con una lettera maiuscola e di default sono colorate di blu, possono avere molti input ma un solo output (quelle che naturalmente avrebbereo bisogno di molti output come i panner in realtà riportano un array di UGens)
Server vs Interprete
Se volessimo azzardare un paragone con quanto illustrato nella prima Parte di questo scritto ovvero con un processo di scheduling è come se le UGens contenessero al loro interno il seguente codice:
( t = SystemClock; r = Routine({ 1000.do({var y; // idealmente 'inf' y = rrand(-1.0,1.0); y.round(0.01).postln; // stampa il valore di ogni sample (1/44100).wait // 44.100 volte al secondo }) }); r.play(t); )
L'esempio precedente è la realizzazione 'Client side' di un rumore bianco (white noise) ed è puramente dimostrativo. Da questo momento in avanti infatti dobbiamo prestare molta attenzione a non confondere i processi di scheduling che sono realizzati nell'Interprete (Client side) con le UGens che "vivono" nel Server (Server side) e che possono leggere, modificare o generare due diversi tipi di segnali:
Segnali audio (.ar o audio rate) che computano tanti numeri al secondo quanti la sample rate alla quale sta lavorando il server. Sono più costosi a livello computazionale e in genere sono i segnali che producono suono e che vengono scritti sugli output del computer.
Segnali di controllo (.kr o control rate) che invece computano tanti numeri al secondo seguendo la formula:
Sample Rate / Block size
e che di default è: 44100/64 = circa 689 numeri computati al secondo. Per questo motivo sono meno costosi a livello computazionale e in genere non producono suono ma controllano parametri di altri segnali audio come ad esempio un inviluppo d'ampiezza.
Eseguendo il codice seguente possiamo vedere una visualizzazione della differenza tra i due.
{[SinOsc.ar,SinOsc.kr]}.plot(1/50, bounds:1000@500).plotMode_(\levels).refresh;
Concludendo le UGens sono delle Classi ottimizzate per computare segnali audio, 'vivono' nel Server e possiamo crearle invocando i metodi .ar, .kr o .ir (che vedermo in seguito) sul loro nome.
Forme d'onda classiche
I tre metodi precedenti descrivono quanti valori vengono generati in un secondo mentre i nomi delle specifiche UGen indicano quale tipo di relazione intercorre fra i valori generati (forma d'onda o funzione d'onda). In seguito possiamo osservare la visualizzazione delle forme d'onda di alcuni segnali audio e di controllo classici:
( {[SinOsc.ar, // Oscillatore sinusoidale WhiteNoise.ar, // Generatore di rumore bianco Saw.ar, // Generatore di onde a dente di sega Pulse.ar, // Generatore di onde quadre Blip.ar, // Treno d'impulsi (PulseTrain) LFPar.kr, // Parabolica (Quasi-Sine) LFNoise0.kr, // Generatore di valori pseudo-casuali LFSaw.kr, LFPulse.kr, Impulse.kr] }.plot(1/10, bounds:1100@800).plotMode_(\plines).refresh; )
N.B. LF = Low Frequency
Nel proseguio della trattazione ci soffermeremo su queste ed altre UGens, per ora possiamo curiosare nel consueto modo ovvero richiamando l'Help file di ognuna, nel quale possiamo trovare anche degli esempi sonori oppure ottenere un elenco di tutte le UGens andando nella finestra degli Help files e cliccando prima su Browse e poi su UGens (495):
Argomenti
Tutte le UGen possiedono degli argomenti (variabili d'istanza) che specificano i valori di alcuni parametri del segnale da generare o modificare come ad esempio frequenza, ampiezza o altro. Nonostante siano argomenti dei metodi .ar(), .kr() o .ir(), sono differenti per ogni specifica UGen sulla quale sono invocati e per conoscere quali sono dobbiamo richiamare il suo Help file.
Entreremo nello specifico ogni volta che ne incontreremo una nuova. Come esempio osserviamo quelli di SinOsc che è un oscillatore sinusoidale:
SinOsc.ar(freq:440, phase:0, mul:1, add:0);
Il primo specifica la frequenza in Hertz
( {[SinOsc.ar(1320), SinOsc.ar(880), SinOsc.ar(440)] }.plot(minval:-1,maxval:1) )
Il secondo specifica la fase iniziale in radianti tra -1 e 1
( {[SinOsc.ar(440,0.0), SinOsc.ar(440,0.5), SinOsc.ar(440,1.0)] }.plot(minval:-1,maxval:1) )
Il terzo specifica un fattore di moltiplicazione dell'ampiezza dei singoli campioni
( {[SinOsc.ar(440,0,1.0), SinOsc.ar(440,0,0.5), SinOsc.ar(440,0,0.2)] }.plot(minval:-1,maxval:1) )
Il quarto specifica un valore da sommare all'ampiezza dei singoli campioni (DC offset)
( {[SinOsc.ar(440,0,1.0), SinOsc.ar(440,0,0.5), SinOsc.ar(440,0,0.2)] }.plot(minval:-1,maxval:1) )
Questi ultimi due (mul e add) sono comuni a quasi tutte le UGens e come risulta evidente dai codici precedenti possiamo specificare anche solo alcuni argomenti. Quelli non indicati assumeranno il valore di default che possiamo trovare nell'help file. Inoltre come per gli argomenti delle Classi possiamo utilizzare due sintassi differenti:
La prima sottointende le keywords e non possiamo saltare posizioni da sx a dx:
{SinOsc.ar(440,0,0.3)}.plot;
In questo caso l'indicazione della fase sarebbe superflua in quanto assume lo stesso valore di default (0), ma si rende necessario specificarla in quanto il valore dell'argomento seguente (mul) è diverso da quello di default.
La seconda utilizza le keywords e possiamo sia saltare posizioni che cambiarne l'ordine
{SinOsc.ar(mul:0.7, freq:500)}.plot;
Monitors
In SuperCollider ci sono oggetti grafici che ci restituiscono informazioni riguardanti l'attività computazionale e i segnali audio attivi in entrata o in uscita dal Server in uso. Possiamo monitorare allo stesso modo le singole UGen, invocando su di esse alcuni dei metodi già incontrati. Per quanto riguarda i segnali audio ci sono tre possibili tipi di monitoraggio:
Uditivo: ascoltando il risultato sonoro.
Grafico: visualizzando la forma d'onda (oscillogramma) piuttosto che lo spettro (spettrogramma) piuttosto che l'ampiezza del segnale (meters).
Numerico: visualizzando i valori dei singoli campioni o meglio di campioni estratti dallo stream numerico in istanti definiti (snapshots)
Per ognuna di queste modalità di monitoring esiste un metodo che possiamo invocare sull'output dei segnali:
{}.play
Un modo veloce per far suonare e monitorare attraverso l'ascolto una o più UGens consiste nell'includerla tra parentesi graffe (ovvero all'interno di una funzione) e invocare su di essa il metodo .play. Questa è un'abbreviazione sintattica che possiamo utilizzare per prototipare velocemente algoritmi di sintesi ma come vedremo in seguito non è il modo migliore per programmare patch informaticamente corretti e performanti.
{SinOsc.ar}.play; {SinOsc.ar+WhiteNoise.ar}.play;
{}.scope
Per avere un monitoraggio visivo della forma d'onda (oscillogramma) del segnale in uscita di una specifica UGen (o da un network di UGens) possiamo invocare il metodo .scope():
sia su una funzione che la (le) contiene:
{Saw.ar}.scope; {SinOsc.ar(LFNoise0.kr(15,1000,2000))}.scope;
e in questo caso oltre a visualizzare la forma d'onda agisce anche come .play,
sia direttamente su di una UGen all'interno di una funzione o come vedremo più avanti all'interno di una SynthDef.
{SinOsc.ar(2000+(1000*LFNoise0.kr(5).scope(\pitch))).scope(\sine)}.play;
Due considerazioni. La prima consiste nel fatto che l'argomento di .scope() è il nome che vogliamo dare alla finestra grafica. La seconda riguarda il diverso colore che differenzia lo scoping dei segnali a audio rate (gialli) da quelli a control rate (verdi). Infine ricordiamoci che gli oscilloscopi delle singole UGen possono coesistere con quello del Server.
( {SinOsc.ar(2000+(1000*LFNoise0.kr(5).scope(\pitch))).scope(\sine)}.play; s.scope; )
{}.plot
Possiamo anche visualizzare in un plot i valori dell'output di una UGen invocando l'omonimo metodo su una funzione che include una UGen o un algoritmo di sintesi. In questo caso gli argomenti sono differenti rispetto al plotting di un Array. Nell'esempio seguente sono illustrati i principali, per tutti gli altri richiamare l'Help file di .plot e scegliere Function.
{Pulse.ar}.plot; {Pulse.ar+Saw.ar}.plot(duration: 0.01 ); // Durata della finestra in secondi {Pulse.ar*Saw.ar}.plot(bounds: Rect(0,0,500,100)); // Dimensioni grafiche finestra {Pulse.ar*PinkNoise.ar}.plot(bounds: 500@100); // Abbreviazione {Pulse.ar+WhiteNoise.kr}.plot(minval: -2, maxval: 2); // Minimo e massimo {Pulse.ar*SinOsc.ar}.plot(bounds: 500@100); // Abbreviazione
Possiamo notare che la visualizzazione non è per nulla precisa, soprattutto all'inizio quando SuperCollider deve effettuare numerose operazioni "nascoste", ma può tornarci utile ugualmente per farci un'idea della forma d'onda di un segnale o nella ricerca di eventuali errori.
{UGen.ar.poll()}.play
Infine per un monitoraggio numerico dei valori dei singoli samples possiamo utilizzare il metodo .poll invocato direttamente su un UGen. Questo metodo stampa semplicemente nella Post window i valori in uscita. L'argomento è il numero di campioni (snapshots) per secondo (in Hertz).
{SinOsc.ar.poll(1)}.play; {SinOsc.ar(MouseX.kr(40,10000,1).poll(10), 0, 0.1) }.play;
Teniamo presente che il metodo .poll serve solo per monitorare, non possiamo assegnare i singoli valori ad una variabile per poi riutilizzarli all'interno del codice. Per fare questo dovremo utilizzare un'altra tecnica che vedremo in un'altra sezione di questo scritto.
Synth e segnali
Abbiamo visto nel paragrafo precedente che per ottenere segnali (sia audio che di controllo) in output da una UGen una delle possibili sintassi a nostra disposizione consiste nell'includerla tra parentesi graffe come se fosse il contenuto di una funzione. Eseguendo la riga seguente potremo leggere nella post window la scritta a Function.
{SinOsc.ar};
La valutazione della riga precedente genera dunque una funzione, non un segnale. Per generare un segnale dobbiamo invocare su di essa uno dei metodi che abbiamo già incontrato.
{SinOsc.ar}.play; {SinOsc.ar}.scope; {SinOsc.ar}.plot;
Questa forma infatti è un'abbreviazione sintattica: ogni volta che eseguiamo un codice di questo tipo, SuperCollider compie alcune operazioni per noi, creando temporaneamente un nuovo Synth (una nuova "istanza" di Synth), derivato dalla Classe della specifica UGen e impostata sui valori di default.
Indici e Nodi
Eseguiamo più volte la riga seguente:
{SinOsc.ar}.play;
Ad ogni valutazione vedremo comparire nella Post window alcune informazioni e sentiremo l'ampiezza del suono aumentare:
Synth("temp__1" : 1000) Synth("temp__2" : 1001) Synth("temp__3" : 1002) ...
Queste ci dicono che a ogni valutazione SuperCollider ha creato una nuova istanza di Synth rappresentata dall'oggetto Synth(). Come possiamo notare ogni Synth() ha un argomento che indica l'etichetta (o indirizzo o nome o indice) che gli è stato assegnato automaticamente ed è diviso in due parti:
- la prima ("temp__1") è un valore che si incrementa fino a quando non usciamo dall'applicazione, e che indica la creazione di un Synth temporaneo
- la seconda (: 1000) è il numero di istanza creata nel Server o Node che è stata assegnata a quel Synth.
In pratica ogni nuova istanza è un nuovo Synth che esiste nel Server fino a quando non spegnamo l'audio con 'cmd+.'. Ogni istanza si chiama Node ed è indicizzata sul Server. Possiamo visualizzare in una finestra grafica tutti i nodi presenti su un Server invocando il metodo .plotTree.
s.plotTree; {SinOsc.ar(rrand(400,1900))*SinOsc.ar}.play;
Se volessimo eliminare tutti i Synth (Nodes) da un Server:
s.freeAll;
Notiamo che all'interno di un singolo Synth possiamo avere sia una sola UGen (un segnale audio) che più UGens (più segnali audio) collegate tra loro in diversi modi attraverso le più disoarate tecniche di sintesi e/o elaborazione del segnale espresse sotto forma di algoritmi. In questo secondo caso il segnale in ouput sarà il risultato dell'algoritmo specifico. Infine in termini musicali possiamo pensare a ogni singolo Synth (o Nodo) come a una una voce monofonica.
SynthDef e Synth
Per "far suonare" SuperCollider fino a questo punto abbiamo utilizzato l'abbreviazione sintattica: {UGen.ar}.play ma il paradigma per una corretta liuteria virtuale da realizzarsi per mezzo di qualsiasi software passato o presente (e probabilmente futuro) consiste nel seguire alcuni passi derivati dalle prassi del fare musica con strumenti acustici che sono:
Progettare e creare uno o più strumenti virtuali (cosa che in SuperCollider possiamo realizzare con SynthDef e Synth). Nel caso specifico di SuperCollider la struttura interna più completa di una SynthDef dovrebbe essere:
- Nome dello strumento (indirizzo al quale inviare i parametri dall'esterno)
- Argomenti (nomi dei parametri di controllo da ricevere dall'esterno)
- Variabili locali (eventuali)
- Bus In (il bus dal quale leggere eventuali segnali in entrata)
- Algoritmo di sintesi o elaborazione del suono
- Bus Out (il bus sul quale scrivere il segnale in uscita)
Connetterli tra loro in diverse configurazioni attraverso i Bus o una matrice (che corrisponde alla definizione di un organico acustico e all'orchestrazione un brano).
Controllarli inviando valori dinamicamente agli argomenti (eseguire una partitura musicale).
In SuperCollider è possibile memorizzare sul Server un modello di strumento in una SynthDef (Synth Definition) o meglio inviare al Server le istruzioni necessarie alla costruzione di un Synth. Dopo aver compiuto questa operazione da questo modello possiamo derivare uno o più strumenti virtuali (Synth) costruiti seguendo le istruzioni memorizzate in precedenza. Sfruttiamo dunque un paradigma simile al rapporto che c'è tra Classe e Istanza nei linguaggi di programmazione OOP ma che tecnicamente non è la stessa cosa. Vediamo come passare gradualmente da come abbiamo operato finora (abbreviazione sintattica {}.play) alla programmazione di Synth attraverso le SynthDef.
Costruiamo uno strumento monofonico utilizzando l'abbreviazione sintattica che conosciamo
{SinOsc.ar}.play;
Quando invochiamo il metodo .play su una funzione, SuperCollider crea automaticamente sul Server una SynthDef e un'istanza di Synth i cui parametri se non specificati sono quelli di default delle UGens contenute al suo interno e l'Out è sul canale sinistro (Bus 0)
Costruiamo allo stesso modo uno strumento monofonico i cui parametri sono variati dinamicamente da segnali di controllo i cui valori sono memorizzati in variabili locali. Ricordiamoci che anche i segnali di controllo esistono nel Server e dunque una volta creato il Synth non c'è alcuna comunicazione tra Interprete e Server.
( {var freq, amp; freq = MouseY.kr(800,500); // Segnali di controllo interni al Server amp = MouseX.kr(0.0,0.5); SinOsc.ar(freq,mul:amp); // Algoritmo di sintesi }.play )
Costruiamo allo stesso modo uno strumento monofonico i cui parametri possono essere variati dinamicamente dall'Interprete, assegnandolo ad una variabile globale per fornirgli un indirizzo al quale poter inviare messaggi attraverso il metodo .set(\nome, valore).
( a = {arg fmin = 200, fmax = 400, amin = 0.0, amax = 0.0; // Argomenti (con o senza valori di default) var freq, amp; // Variabili locali freq = MouseY.kr(fmax,fmin); // Segnali di controllo interni al Server amp = MouseX.kr(amin,amax); SinOsc.ar(freq!2)*amp; // Algoritmo di sintesi }.play ) a.set(\fmin,1000,\fmax,2500,\amin,0.1,\amax, 0.3); // Invia nuovi valori dall'Interprete al Server a.set(\fmin,400, \fmax,500, \amin,0.7,\amax,0.9); a.free; // Distrugge il Synth temporaneo e libera la variabile 'a'
Attenzione attraverso il metodo .set(\nome,valore) possiamo inviare solo valori, non segnali in quanto come abbiamo visto è una comunicazione tra Interprete e Server e come ben sappiamo i segnali audio e di controllo possono esistere solo nel Server.
Definiamo una SynthDef. La sintassi è la stessa che abbiamo usato per l'abbreviazione sintattica, dobbiamo solo sostituirci a SuperCollider e creare noi prima la SynthDef ovvero le istruzioni e poi derivare da questa tutte le istanze di Synth che vogliamo. A livello sintattico le due differenze principali sono:
dobbiamo specificare noi un nome sotto forma di symbol (\nome) o stringa ("nome") che sostituirà la scritta temp_0 nella Post window e che utilizzeremo come etichetta per creare le istanze,
dobbiamo specificare il numero di canale (bus) sul quale scrivere il segnale in output (0 = canale sinistro) ed eventualmente quello dal quale leggere segnali in input.
( SynthDef("strum_0", // Nome per accesso (come le variabil globali) {arg freq = 800, amp = 0.5; // Argomenti (con o senza valori di default) var sig = SinOsc.ar(freq, mul:amp); // Variabile locale Out.ar(0,sig); // Bus Out (0 = Sinistra) }).add; // '.add' memorizza le istruzioni sul Server // fino a quando non usciamo da SuperCollider SynthDef("strum_1", // Un'altra SynthDef con un altro nome e un altro {arg fmin = 200, fmax = 400, amin = 0.0, amax = 1.0; var freq,amp,sig; freq = MouseY.kr(fmax,fmin); amp = MouseX.kr(amin,amax); sig = SinOsc.ar(freq, mul:amp); Out.ar(1,sig); // Bus Out (1 = Destra) }).add )
Creiamo quanti Synth vogliamo utilizzando le istruzioni memorizzate nella SynthDef.
s.plotTree; // Apre l'albero dei Nodi Synth("strum_0"); // Crea un Synth dalla SynthDef strum_0 con i parametri di default Synth("strum_1"); // (se li abbiamo specificati come argomenti nella SynthDef) s.freeAll; // Distrugge tutti i Nodi (Synth) presenti sul Server
I parametri dei due Synth appena creati hanno assunto i valori di default che avevamo specificato nella SynthDef, se invece volessimo creare un nuovo Synth con valori diversi da quelli di default la sintassi è la seguente:
Synth("strum_0", [\freq,987,\amp,0.3]) // Crea un nuovo Synth con i parametri specificati Synth("strum_0", [\freq,1234,\amp,0.2]) // Crea un nuovo Synth con i parametri specificati Synth("strum_1", [\fmin,1000,\fmax,2500,\amin,0.1,\amax, 0.3]); // Crea un nuovo Synth con i parametri specificati Synth("strum_1", [\fmin,400, \fmax,500, \amin,0.7,\amax,0.9]); // Crea un nuovo Synth con i parametri specificati
Eseguendo le quattro righe precedenti abbiamo creato quattro Synths virtuali monofonici che suonano contemporaneamente. Se volessimo cambiare dinamicamente dall'Interprete i valori degli argomenti di uno specifico Synth dovremmo poterlo identificare assegnandolo a una variabile e poi inviare a quest'ultima i valori con il metodo .set(\nome, valore) esattamente come abbiamo fatto poc'anzi con l'abbreviazione sintattica.
a = Synth("strum_0",[\freq,500,\amp,0.2]); // Crea il Synth e lo assegna ad 'a' b = Synth("strum_1",[\fmin,200, \fmax,300, \amin,0.2,\amax,0.6]); // Crea il Synth e lo assegna a 'b' a.set(\freq, 1200) b.set(\fmin, 250, \amax, 0.3); a.run(false); // ferma la computazione audio senza distruggere il Synth a.run(true); // fa ripartire la computazione audio b.run(false); a.free; // distrugge il Synth e libera la variabile 'a' b.free; // distrugge il Synth e libera la variabile 'b'
Il metodo .run(false) accetta un boolean che ferma o fa ripartire la computazione audio senza distruggere il Synth mentre il metodo .free distrugge il Synth assegnato alla variabile richiamata ma non la SynthDef dalla quale deriva.
A questo punto credo sia importante sottolineare alcune piccole differenze che potranno tornarci utili in seguito:
- Possiamo modificare dinamicamente i parametri di un Synth:
alla sua creazione generando di fatto un nuovo Synth a ogni nota:
Synth("strum_0", [\freq, rrand(900,1500),\amp,rand(0.4)])
assegnando un Synth a una variabile per poi utilizzare il metodo .set(\nome,valore) generando di fatto un Synth monofonico
a = Synth("strum_0"); a.set(\freq, rrand(900,1500),\amp,rand(0.4))
Queste due possibilità saranno alla base di due tecniche differenti per l'allocazione dinamica delle voci che vedremo più avanti
- Abbiamo a disposizione due modi differenti di controllare dinamicamente i parametri di un Synth:
inviando valori attraverso messaggi inviati dall'Interprete al Server (Client side). Utilizzeremo questo metodo con tecniche di sequencing oppure per far arrivare al Server valori provenienti da interfacce esterne che non supportano il protocollo OSC ma altri come il MIDI.
utilizzando segnali di controllo interni al Server (Server side) come avviene nei sintetizzatori analogici passati, presenti e probabilmente futuri.
Possiamo infine visualizzare in un browser grafico tutte le SynthDef presenti su un Server in un determinato momento:
SynthDescLib.global.read.browse;
Come possiamo notare sono presenti sia quelle di default che quelle aggiunte da noi (custom).