Scheduler e post window

In informatica attivare automaticamente determinati processi o eventi in una sequenza temporale è un compito gestito da una parte del sistema operativo chiamata scheduler.

Attraverso il processo di scheduling possiamo temporizzare una lista di eventi o far eseguire al software alcune operazioni prima di altre ad un tempo estremamente rapido.

La velocità di computazione non temporizzata dipende dalla quantità di operazioni che il computer deve effettuare e dalle sue caratteristiche tecnico costruttive.

Facciamo attenzione a non confondere il tempo di scheduling con la sample rate (il tempo della computazione dei segnali audio digitali). In SuperCollider il primo è proprio dell’Interprete (sclang) e la sua velocità può variare a seconda di diversi fattori non sempre predicibili tra cui ad esempio il modello di computer utilizzato, mentre la seconda è propria del Server (scsynth) e nel momento in cui è stabilita è uguale su tutti i computers.

Per meglio comprendere lo scheduling in SuperCollider, calcoliamo le seguenti conversioni temporali eseguendo il codice una riga alla volta oppure selezionando l’intero blocco incluso tra le parentesi tonde.

(
82 / 60;    // conversione bpm --> bps: bpm/60.
1.3 * 60;   // conversione bps --> bpm: bps*60.
82 / 60000; // conversione bpm --> ms:  60000./bpm
82 / 60;    // conversione bpm --> s:   60./bpm
1/2;
1/3;
1/4;
)

Possiamo notare come ad ogni esecuzione il risultato viene stampato nella Post window.

Se procediamo una riga alla volta possiamo scegliere l’ordine andando su e giù tra le righe col cursore, mentre se eseguiamo l’intero blocco di codice vedremo postati i risultati nello stesso ordine delle operazioni scritte nell’Interprete, riga dopo riga, dall’alto verso il basso.

Quando eseguiamo il codice, i risultati delle operazioni sono scritti nella Post window, infatti questo è lo spazio dove possiamo leggere o scrivere dati e informazioni utili riguardo ciò che stà accadendo in SuperCollider. Il monitoraggio dello scheduling ne è un tipico esempio.

La Post window diventa inoltre uno strumento indispensabile anche quando dobbiamo effettuare un debugging, ovvero cercare e correggere eventuali errori presenti nel codice. Per verificare eseguiamo le seguenti righe:

(
var freq;
    freq = rrand(300,600);
      {SinOsc.ar(freq,0,0.2)}.play;
)

Se abbiamo già fatto il Boot dell’audio (cmd+b), vedremo postato qualcosa riguardo a un Synth (un’insieme di informazioni che potrebbero tornare utili).

Synth(”temp_0” : 1000)

Se invece le eseguiamo senza aver fatto il Boot (se già acceso fare prima ”Quit Server”), comparirà il seguente messaggio di errore:

RESULT = 0
WARNING: server ’localhost’ not running.

nil

Quando SuperCollider incontra un errore nella compilazione del codice, oltre a non funzionare (nel migliore dei casi) stampa nella Post window un messaggio di errore e indica con una freccia o un pallino il punto esatto dove si è interrotta la compilazione. Attenzione, non dove si è verificato l’errore, ma dove si è fermato. Sapendo che l’esecuzione avviene dall’alto al basso, riga dopo riga, quasi sempre da sinistra a destra, probabilmente l’errore è poco prima e a noi non resta che compiere le verifiche del caso per trovarlo. Un altro esempio:

play({SinOsc.ar(LFNoise0.kr(12,mul:600,add:1000)0.1)});  //...manca una virgola...  

La maggior parte delle volte si tratta di errori di battitura (manca una virgola, non abbiamo chiuso una parentesi, abbiamo usato una lettera maiuscola dove non potevamo, etc.) ma altre volte è possibile imbattersi in situazioni più complesse da risolvere. Per questi e altri casi, possiamo consultare i messaggi di errore più comuni sul sito di Daniel Nouri.

Controllo del tempo

Ora che abbiamo compreso il processo di scheduling, prima di cominciare a pensare quali possono essere le possibilità di controllare il tempo in SuperCollider vediamo quali sono i parametri che utilizzeremo per misurare e controllare il tempo e adottiamo a-criticamente la seguente regola (non necessariamente valida ma che al momento può aiutarci a comprendere meglio):

una riga di codice = un singolo evento (onset e durata) nel tempo

(sia esso una nota, una pausa, un trigger, il play di audio file, un grano di una sintesi granulare, etc.)

Seguendo questo assunto il codice seguente descrive cinque eventi in successione:

(
"riga uno     -> primo evento".postln;
"riga due     -> secondo evento".postln;
"riga tre     -> terzo evento".postln;
"riga quattro -> quarto evento".postln;
"riga cinque  -> quinto evento".postln;
)

Al momento dell’esecuzione la computazione sarà ordinata (sequenziata) ma molto veloce, quasi contemporanea all’azione delle dita sulla tastiera del computer. Non abbiamo alcun tipo di controllo sul tempo e come abbiamo detto in precedenza, la velocità di esecuzione dipenderà dalle caratteristiche dell’hardware e dalla mole di dati che devono essere computati. Una possibile trascrizione musicale è illustrata nella figura sottostante, dove i righi del pentagramma non indicano le altezze musicali, ma le righe dell’Interprete.

image not found

In musica abbiamo però la necessità di poter controllare sequenze di eventi nel tempo in diversi modi, con diversi parametri e unità di misura, non basta inanellarli in un ordine più o meno consequenziale. Dobbiamo avere ad esempio la possibilità di modificare i Tempi Delta che intercorrono tra una riga di codice (evento) e quella successiva o di controllare (idealmente) la durate delle operazioni effettuate nella computazione interna alle righe di codice, come illustrato nella figura seguente.

image not found

Per farlo dobbiamo utilizzare due Classi dedicate di SuperCollider: Routine e Task.

Routine

Secondo l'assunto appena esposto, il codice seguente specifica una sequenza di cinque eventi in rapidissima successione dall’alto al basso, riga dopo riga, da sinistra a destra.

image not found

(
"riga uno -> primo evento".postln;
"riga due -> secondo evento".postln;
"riga tre -> terzo evento".postln;
"riga quattro -> quarto evento".postln;
"riga cinque -> quinto evento".postln;
)

Immaginiamo ora di volere stoppare automaticamente la computazione alla fine di ogni riga, come se ci fosse una fermata musicale (corona o punto coronato) e di dover compiere una nuova azione per riprendere la valutazione del codice da dove si è fermata:

image not found

Questo è quello che fanno le Routine. Per verificarlo eseguiamo l’esempio nel box seguente. Prima le righe da 1 a 9 e poi la riga 10 oppure la 11, più volte.

(
r = Routine.new({        // Assegno la Routine alla variabile globale 'r'
                1.yield; // yield = fermata
                2.wait;  // wait  = sinonimo di yield
                3.yield; 
                4.yield; 
                5.yield; 
               })
)
r.value; // valuta riga per riga
r.next;  // sinoinimo di .value
r.reset  // riporta all'inizio

Risulta evidente che lo schema sintattico delle Routine è simile a quello della maggior parte delle Classi che hanno una funzione come primo argomento:

Routine.new( {func} );.

Analizziamo brevemente ma nel dettaglio i metodi utilizzati nel codice precedente.

Invece che richiamare il contenuto di una Routine un passo alla volta con il metodo .value, possiamo automatizzare l’esecuzione dell’intera sequenza (sequencing) invocando sulla Routine il metodo .play.

(
r = Routine({            
             0.5.yield;       // tempo delta in secondi (fermata)
             "trig_1".postln; // evento
             2.yield;
             "trig_2".postln;
             0.1.yield;
             "trig_3".postln;
           });
)

r.reset.play; // Esegue la Routine 

In questo caso se scriviamo numeri int o float come oggetti sui quali invocare .yield o .wait all’interno della Routine, questi vengono interpretati da SuperCollider come tempi delta di attesa prima di passare oltre (le fermate o corone musicali di cui sopra). Per il momento i valori numerici specificati all’interno della Routine equivalgono a secondi.

Se vogliamo fermare lo scheduling di una Routine possiamo farlo utilizzando due comandi differenti:

Abbreviazioni

Per scrivere una Routine possiamo utilizzare anche un’abbreviazione sintattica invocando il metodo .fork() su di una funzione.

(
{
"trig_1".postln; // azione (evento o nota)
1.wait;          // aspetta 1 secondo
"trig_2".postln; 
1.wait;        
"trig_3".postln;     
}.fork           // equivale a '.play'
)

In SuperCollider così come in altri software a causa della compresenza di diversi linguaggi informatici alcune operazioni e istruzioni possono essere scritte in molti modi differenti. Le Routine tradizionali e l’abbreviazione sintattica (Syntax Shortcut) {}.fork(t) ne sono un esempio così come la possibilità di scrivere il codice in functional notation o receiver notation. A questo link possiamo trovarne un elenco esaustivo.

Nell’usare questa abbreviazione sintattica dobbiamo però tenere conto di alcuni particolari e usare alcuni accorgimenti. Se ad esempio vogliamo usare il metodo .stop per interrompere la computazione è necessario invocare il metodo .fork() contestualmente all’assegnazione della funzione a una variabile, altrimenti non si fermerà in quanto SuperCollider interpreta tutto ciò che è incluso tra parentesi graffe come una funzione, non come una Routine.

Se eseguiamo le righe da 1 a 11 del codice seguente, SuperCollider prima riporta la scritta a Routine e poi comincia a computare la sequenza. Se prima della fine eseguiamo la riga 12 si interrompe.

(
r = {"trigger_1".postln; 1.wait;
	 "trigger_2".postln; 1.wait;
	 "trigger_3".postln; 1.wait;
	 "trigger_4".postln; 1.wait;
	 "trigger_5".postln; 1.wait;
	 "trigger_6".postln;
}.fork
)

r.stop; 

Se invece eseguiamo le righe da 1 a 11 del codice seguente SuperCollider restituisce a Function e non a Routine e se volessimo interrompere la sequenza dal codice dovremmo adottare altre strategie.

(
r = {"trigger_1".postln; 1.wait;
	 "trigger_2".postln; 1.wait;
	 "trigger_3".postln; 1.wait;
	 "trigger_4".postln; 1.wait;
	 "trigger_5".postln; 1.wait;
	 "trigger_6".postln;
}
)

r.fork;     // parte ma...
r.stop;     // non si stoppa, 

La Routine parte eseguendo la riga 12 perchè il metodo .fork() quando invocato "trasforma" una funzione in una Routine, ma nel momento in cui invochiamo .stop sulla variabile r, SuperCollider la interpreta come funzione e non come Routine, non eseguendo ciò che gli stiamo chiedendo.

Task

Un Task all’apparenza è esattamente come una Routine. Accetta gli stessi metodi e segue la stessa sintassi. La principale differenza tra queste due Classi sta nel fatto che i primi possono essere messi in pausa e riprendere la computazione dal punto in cui li abbiamo fermati, mentre le Routine come abbiamo visto se stoppate possono solo essere resettate e ripartire dall’inizio.

(
r = Task({"trigger_1".postln; 1.wait;
          "trigger_2".postln; 1.wait;
          "trigger_3".postln; 1.wait;
          "trigger_4".postln; 1.wait;
          "trigger_5".postln; 1.wait;
          "trigger_6".postln; 1.wait;
          "trigger_7".postln; 1.wait;
          "trigger_8".postln; 1.wait;
          "trigger_9".postln;
})
)

r.play;
r.pause;  // pausa
r.resume; // riprende
r.stop;   // come pause
r.reset;  // resetta all'inizio la prossima volta che viene eseguito
r.start;  // riparte da capo

I metodi .play e .resume sono sinonimi così come .stop e .pause. I primi servono per far partire la computazione dal punto in cui si trova, i secondi per stopparla o metterla in pausa. Il metodo .start invece fa partire la computazione di un Task sempre dall’inizio, come se avessimo usato .reset.play().

Facciamo attenzione a non confondere la differenza tra Routine e Task appena esposta con la spiegazione data all’inizio del paragrafo sulle prime. Riassumendo le Routine possono solo essere eseguite da capo a fondo anche se interrotte prima del loro termine mentre i Task possono essere messi in pausa e riprendere dal punto in cui li abbiamo interrotti.

I Task sono dunque musicamente più funzionali delle Routine ma questa maggiore duttilità (come spesso avviene in informatica) ha un prezzo da pagare ovvero ci sono alcune limitazioni:

Per chi volesse approfondire queste e altre problemetiche riguardanti i Task può consultare il loro Help file dove troverà anche diversi esempi che illustrano queste limitazioni.

Clocks

Abbiamo visto come di default i valori temporali espressi nelle Routine e nei Task siano in secondi. In termini musicali musica però una sequenza di eventi può essere definita e misurata sia in Tempo assoluto (secondi e millisecondi) che in Tempo relativo (beat e sue suddivisioni). In SuperCollider possiamo adottare una o l'altra unità di misura specificando il tipo di Clock da utilizzare. Ne esistono tre, ognuno da utilizzare in una specifica situazione:

Possiamo pensare ai Clock come a metronomi che forniscono la griglia temporale all'interno della quale si svolgono gli eventi.

I Clocks possono essere specificati come argomento dei metodi .play(Clock) e .fork(Clock) e vanno a modificare l'unità di misura degli eventi temporali presenti all'interno delle Routines sulle quali sono invocati:

(
d = SystemClock;       // secondi
b = 120;               // bpm
t = TempoClock(b/60);  // bpm --> bps
a = AppClock;          // secondi (per le GUI)

r = Routine({         
             0.5.wait; // sinonimo di .yield     
             "trig_1".postln;
             2.wait;
             "trig_2".postln;
             0.1.wait;
             "trig_3".postln;
            });
)
r.reset.play(d); // Esegue la Routine a SystemClock (tempi in secondi 1 = 1 secondo)
r.reset.play(t); // Esegue la Routine a TempoClock  (tempi in beats   1 = 1 beat metronomico)
r.reset.play(a); // Esegue la Routine a AppClock    (tempi in secondi 1 = 1 secondo)

Vediamo i dettagli.

SystemClock

SystemClock è un oggetto di SuperCollider. Osserviamo nel dettaglio le sue caratteristiche richiamando il suo Help file. Gli Help files generalmente sono divisi in due o tre parti, una chiamata Description, un’altra Class Methods e la terza, quando presente Instance Methods.

image not found

Nella prima (Description è documentato lo stato dell’oggetto, ovvero a quale tipo di Data appartiene a quali messaggi può rispondere e quali sono le operazioni può compiere. Nella fattispecie dell’oggetto in trattazione:

Se vogliamo specificare il tempo in secondi possiamo evitare di definire un Clock in quanto SystemClock è il Clock di default ed è anche il più preciso, se invece vogliamo gestire il tempo musicale in tempo relativo dobbiamo specificarne un altro: TempoClock.

TempoClock

Anche TempoClock è uno scheduler ma rispetto a SystemClock presenta alcune differenze sostanziali che lo rendono musicalmente più versatile ma tecnicamente meno preciso. Osserviamo nel dettaglio quali sono le sue caratteristiche:

AppClock

Ai fini pratici AppClock. è uno scheduler del tutto simile a SystemClock in quanto:

Ci sono però due differenze. La prima è tecnica e consiste nel fatto che vive nello stesso thread dell’applicazione mentre SystemClock in uno separato. La seconda è più pratica: SystemClock è uno scheduler ad alta priorità mentre AppClock a bassa priorità e questa è la discriminante maggiore nel decidere di utilizzare uno o l’altro. Infatti ogni volta che vorremo visualizzare parametri dinamici (che cambiano nel tempo) su una GUI (Graphic User Interface) dovremo obbligatoriamente utilizzare AppClock che essendo a bassa priorità darà la preferenza alla computazione dell’audio e solo se il computer avrà ancora risorse disponibili computerà anche la parte visiva.

Sequencing

Da questo Paragrafo in poi per facilitare la comprensione degli esempi a livello percettivo useremo un sintetizzatore sinusoidale percussivo.

Sintetizzatore percussivo

// frequenze in Hz
// ampiezze tra 0.0 e 1.0
// durate in secondi

(
s = Server.local.boot;
SynthDef(\sperc, {|freq =440, amp=0.5, dur=0.5, atk=10, gate=1.0|
                  var durms = dur*1000,
                      env   = EnvGen.ar(Env.perc(0.01, dur-0.01),gate,doneAction: 2),
                      osc   = SinOsc.ar(freq, 0, amp);
                  Out.ar(0, env *osc ! 2)
                  }).add;
)

Dopo aver compiuto questa operazione torniamo agli esempi di codice illustrati nei paragrafi precedenti dove abbiamo definito sequenze di eventi nel tempo. Una delle azioni che abbiamo chiesto di compiere a SuperCollider è stata quella di stampare qualcosa nella post window all’onset di ogni evento.

(
b = 120;
t = TempoClock(b/60);
r = Routine({"trigger".postln; // stampa
             0.5.yield;        // aspetta
             "trigger".postln;
             0.5.yield;
             "trigger".postln;
             }).reset.play(t);
)

Ora che abbiamo a disposizione un Synth possiamo aggiungere una seconda azione: fargli suonare una nota. Anche in questo caso possiamo utilizzare la sintassi usata nelle righe 5, 8 e 11 del prossimo riquadro così come è, senza entrare nel merito, ci basti sapere che contiene le istruzioni necessarie per eseguire una singola nota con una altezza una durata e un’intensità predefinite (valori di default) da inviare al Synth definito in precedenza.

(
b = 120;
t = TempoClock(b/60);
r = Routine({"trigger".postln; // stampa
             Synth("sperc");   // suona
             0.5.wait;         // aspetta
             "trigger".postln;
             Synth("sperc");
             0.5.wait;
             "trigger".postln;
             Synth("sperc");
             }).reset.play(t)
)

Se non abbiamo la necessità di monitorare visivamente il trigger degli eventi o altre informazioni riguardanti la computazione, possiamo omettere le righe dove chiediamo a SuperCollider di stampare qualcosa nella post window.

(
b = 120;
t = TempoClock(b/60);
r = Routine({Synth("sperc"); // suona
             0.5.wait;       // aspetta
             Synth("sperc");
             0.5.wait;
             Synth("sperc");
             }).reset.play(t);
)

Usando la sintassi Synth("sperc") in questo punto del codice abbiamo uno strumento polifonico che virtualmente crea e distrugge un Synth a ogni nota, utilizzando una tecnica chiamata dynamic voice allocation che permette di ridurre notevolmente il consumo di cpu del computer. Non essendo però questa tematica propria della sezione corrente, per ora pensiamolo semplicemente come un pianoforte virtuale.

Loop con n.do({})

Nei Paragrafi precedenti dedicati alla programmazione di SuperCollider abbiamo visto come programmare sequenze di eventi specificando esclusivamente un solo valore per i tempi delta oppure scrivendo un solo evento per riga:

(
Routine.new({Synth("sperc");
	     1.00.wait;
	     Synth("sperc");
	     0.75.wait;
	     Synth("sperc");
	     0.25.wait;
	     Synth("sperc");
	     0.33.wait;
	     Synth("sperc");
	     0.33.wait;
	     Synth("sperc");
	     0.33.wait;
	     Synth("sperc");
	     0.5.wait;
	     Synth("sperc");
	     0.5.wait;
}).play;
)

Utilizzando questa sintassi abbiamo però il problema che per scrivere brani di una certa durata andiamo a generare patch della lunghezza pari a quella dei poemi epici.

Fortunatamente per ovviare a questo inconveniente ci sono degli oggetti che generano dei loop nel codice e che possono essere usati anche all’interno di una Routine. Il più importante per quello che riguarda questo Paragrafo segue la sintassi n.do({}).

Partiamo con la più semplice delle sequenze di eventi nel tempo: la successione di pulsazioni regolari. Possiamo definire questo tipo di sequenza con il ripetersi di due azioni che si susseguono uguali per un numero finito o infinito di volte nel tempo (in questo caso la durata dell’evento è sottintesa e coincide con il tempo delta).

(
b = 92;
t = TempoClock(b/60);        // beat
r = Routine({
//------------------------------------------- 1 ripetizione
             Synth("sperc"); // onset evento
             1.wait;         // tempo delta di attesa            
//------------------------------------------- 2 ripetizione
             Synth("sperc");
             1.wait;
//------------------------------------------- 3 ripetizione
             Synth("sperc");
             1.wait;
//------------------------------------------- etc.
             }).reset.play(t);
)

La sintassi n.do({}) permette di abbreviare il codice per operazioni di questo tipo (iterazioni), ed è traducibile in: ripeti n volte la valutazione delle righe contenute nella funzione specificata come argomento. Quando hai finito eventualmente prosegui oltre. Possiamo anche pensare questo tipo di loop come al corrispondente informatico di un ritornello musicale.

(
b = 92;
t = TempoClock(b/60);
r = Routine({
             3.do(                        // ripeti 3 volte
//------------------------------------------- (ritornello)
                   {Synth("sperc");
                    1.wait;}
//------------------------------------------- 
                  )                       // fine loop
           }).reset.play(t);              // fine Routine
)

Dovrebbe ora risultare chiaro il perchè in alcuni paragrafi precedenti per scopi puramente esemplificativi abbiamo sostenuto l’assunto: 1 riga di codice = 1 evento specificando al contempo che questa non è un’affermazione sempre valida.

Allontanandoci ora dalle specifiche esigenze di temporizzazione degli eventi musicali vediamo come questo oggetto possa essere utilizzato anche al di fuori delle Routine, in qualsiasi punto del codice. In questi casi l’esecuzione del loop avverrà a velocità massima di scheduling:

10.do({"ciao".postln}) // valuta 10 volte il contenuto della funzione

Eseguendo la riga precedente vedremo stampata dieci volte la parola "ciao" nella post window ad altissima velocità (una parola per riga), seguita dal numero 10 che riporta quante volte la funzione è stata valutata (anche se in questo caso lo abbiamo specificato noi).

L’esempio seguente invece illustra come le due situazioni (loop temporizzato all’interno di una Routine o a tempo di scheduling) possano convivere all’interno dello stesso patch e quali caratteristiche musicali differenti possono assumere. Ricordiamo che la computazione del codice di SuperCollider avviene in quasi tutti i casi dall'alto verso il basso e, una volta terminato un loop prosegue alla riga successiva.

(
b = 92;
t = TempoClock(b/60);
r = Routine({

            3.do({                       // ripeti questo 3 volte
                  Synth("sperc");
                  1.wait;
                });

            4.do({                       // poi ripeti questo 4 volte
                  0.5.wait;
                  5.do({Synth("sperc",[\freq,rrand(90,100)]); 
                        0.25.wait         // questo loop
                       });
                  0.125.wait
                  });

             2.wait;                     // aspetta 2 beats

             5.do({Synth("sperc",[\freq,rrand(20,110)])}) // accordo di 5 note...
                                         
           }).reset.play(t);             // fine Routine
)
r.stop;t.clear;                          // per stop prima del tempo

image not found

Notazione musicale con ritornelli (le altezze cambiano in modo pseudo-casuale a ogni esecuzione)

image not found

Notazione musicale estesa (le altezze cambiano in modo pseudo-casuale a ogni esecuzione)

La sintassi n.do({}) genera sequenze finite, che hanno una lunghezza pari al numero di ripetizioni che specifichiamo.

Se vogliamo invece generare sequenze infinite possiamo semplicemente sostituire il numero di ripetizioni con la parola riservata (keyword) inf. In questo caso il loop va avanti all’infinito fino a quando non stoppiamo la computazione in uno dei modi che conosciamo.

(
b = 120;
t = TempoClock(b/60);
r = Routine({ inf.do({Synth("sperc"); 0.5.yield}) }).reset.play(t);
)

r.stop;          // stoppa la Routine
r.reset.play(t); // rewind, play
t.clear;         // distrugge l'istanza di TempoClock            

In tutti i casi in cui l’unico argomento di un oggetto è una funzione (come nel caso di Routine({}), Task({}) e n.do({})), le parentesi tonde possono essere omesse.

5.do{"ciao".postln};

Routine{Synth("sperc"); 0.5.wait; Synth("sperc")}.play;

(
b = 120;
t = TempoClock(b/60);
r = Routine{ 6.do{Synth("sperc"); 0.5.yield} }.reset.play(t);
)

Tuttavia fino a quando non saremo più familiari con il linguaggio di SuperCollider è preferibile utilizzare la sintassi tradizionale. Un altro oggetto che possiamo usare per iterare all’infinito una funzione è loop{} (oppure {}.loop).

(
b = 120;
t = TempoClock(b/60);
r = Routine({
             loop{Synth("sperc"); 0.5.yield} 
             });
r.reset.play(t);
)

r.stop;

// Altra scrittura...
(
b = 120;
t = TempoClock(b/60);
r = Routine({
            {Synth("sperc"); 0.5.yield}.loop 
            });
r.reset.play(t);
)

r.stop;

Quando generiamo loop infiniti con una qualsiasi delle sintassi appena illustrate, dobbiamo obbligatoriamente specificare un tempo delta con i metodi .wait o .yield in quanto se omettiamo queste "pause" tra valutazioni successive della funzione, SuperCollider le esegue il più veloce possibile e all’infinito, andando in crash.

Tutto ciò che è stato appena esposto funziona nella realizzazione di sequenze di eventi che seguono pulsazioni regolari, ma se volessimo generare sequenze con ritmi diversi senza scrivere 1 evento per riga di codice dovremmo utilizzare delle collezioni di tempi delta differenti rappresentate sotto forma di liste o Array.

Array

Un Array è una sequenza di valori che può essere indicizzata con numeri interi, come in un piano cartesiano (xy). I tempi delta che descrivono una sequenza ritmica sono valori e possono dunque essere indicizzati:

image not found

In questo caso si parla di sequenza numerica bidimensionale (2D).

Valori = y = 1.00, 0.75, 0.25, 0.33, 0.33, 0.33, 0.50, 0.50
Indici = x = 0 ,   1 ,   2 ,   3 ,   4 ,   5 ,   6 ,   7

Questo tipo di sequenze in SuperCollider sono ottimizzate in una superClasse di oggetti chiamata SequenceableCollection.

Gli indici x sono sottintesi e partono sempre da 0 mentre i valori o gli oggetti che compongono la collezione (chiamati items) sono inclusi tra parentesi quadre e separati tra loro da una virgola. La super-Classe SequenceableCollection contiene numerose Classi. Le due più utilizzate sono:

Qalsiasi tipo di data può essere collezionato in un Array o in una List (int, float, Synth, Envelope, midi, etc.) così come illustrato nel riquadro seguente:

[60,63,78,98,12]                // sequenza di midi note
[0.5,0.25,0.25,1]               // sequenza di tempi delta
[12,67,89,127,45]               // sequenza di velocity
[440,569,890,987]               // insieme di frequenze
[0.2,0.4,0.8,0.2]               // insieme di ampiezze
[SinOsc.ar(440),SinOsc.ar(441)] // insieme di segnali
[rand(10),rand(0.1),rand(1000)] // insieme di funzioni

Attenzione, in realtà le prime cinque righe contengono solo numeri int e float compresi in ambiti che possono essere mappati su parametri corrispondenti alle unità di misura indicate (midi note, tempi delta, etc.).

Se includiamo una collezione di oggetti tra due parentesi quadre SuperColllider la interpreta automaticamente come un Array, mentre se vogliamo la interpreti come List dobbiamo specificarlo utilizzando una sintassi che apprenderemo più avanti.

Sugli elementi che sono all’interno di un Array (items) possiamo effettuare numerose operazioni, manipolazioni, sostituzioni di posizione, di data, e altre diavolerie, ma per gli scopi di questo Paragrafo ci interessa solo un particolare tipo di Array chiamato Literal array. La sua caratteristica è che non può essere modificato e che come tipo di data fa parte dei Literals già illustrati in precedenza.

E’ caratterizzato dal simbolo # prima della parentesi di apertura.

#[1,0.75,0.25,0.33,0.33,0.33, 0.5,0.5];

L’aver illustrato le caratteristiche principali di List, Array e Literal Array ci permette di osservare una problematica tipica dei software musicali (e non solo) ovvero il dover spesso scegiere tra più oggetti che apparentemente hanno le stesse funzioni (come in questo caso collezionare e indicizzare data). Solitamente dobbiamo effettuare questa scelta tenendo conto dell’assunto: versatilità vs costo computazionale o meglio scegliere in base alle nostre esigenze di programmazione quello che ha un minor costo computazionale. Prendiamo ad esempio le tre Classi di cui sopra.

Per collezioni di valori numerici la differenza di costo computazionale è solitamente irrisoria ma per collezioni di segnali audio, inviluppi o altri tipi di data può essere rilevante e dunque da tenere in considerazione.

Visualizzazione

Quando un Array contiene valori numerici, possiamo visualizzarli graficamente su un piano cartesiano utilizzando gli indici (sottintesi) come ascisse (x) e i valori (items) come ordinate (y). Per farlo abbiamo a disposizione due possibili sintassi: la prima utilizza il metodo .plot() invocato direttamente sull'Array mentre la seconda la Classe Plotter.

Plotting

Se vogliamo generare una visualizzazione grafica (Plotter) dei dati contenuti in un Array a ogni valutazione, possiamo farlo invocando su di esso il metodo .plot(). Apparirà una nuova finestra come quella illustrata nella Figura seguente e la scritta a Plotter sarà stampata nella Post window.

image not found

#[12,34,56,3,78,98,23,9].plot;  // valori determinati
Array.rand(100, -1.0,2.5).plot; // valori pseudo-casuali tra -1 e 2.5

Questo metodo come molti altri può essere invocato su diversi tipi di oggetti (polimorfismo) , tra i quali gli Array. Per sapere quali sono dobbiamo selezioniarlo e schiacciare cmd+d, come quando richiamiamo un Help file. Se però eseguiamo questa operazione sui metodi, nell’Help browser non compare un Help file ma un elenco di oggetti sui quali possono appunto essere invocati.

image not found

L’aspetto grafico di un Plotter è personalizzabile attraverso gli argomenti di .plot(). Facciamo attenzione che a seconda del tipo di data sul quale è invocato, gli argomenti di questo metodo cambiano. Nell’esempio seguente sono presenti quelli spendibili nella visualizzazione di Array numerici.

image not found

(
a = #[12,34,56,3,78,98,23,9];
a.plot(name: "mio plot", // nome 
       bounds:540@200,   // dimensioni x@y in pixels
       minval: -100,     // valore limite inferiore
       maxval: 100,      // valore limite superiore
       discrete:true)    // true = punti, false = linee
) 

Fra questi è sempre raccomandabile specificare minval e maxval perchè di default i limiti si "adattano" ai valori contenuti nell’Array e questo potrebbe generare errori di lettura e valutazione. Nell’esempio seguente la visualizzazione dei valori può trarre in inganno (nell’Array in alto sono compresi tra 0.1 e 1.0 mentre in quello in basso tra 1 e 10).

image not found

(
[#[0.1,0.5,0.6,0.1,0.9,0.3],
 #[1,5,2,4,8,10,5,6]].plot;
)

Specificando i limiti invece la visualizzazione sarà decisamente più corrispondente alla realtà e più leggibile:

image not found

(
[#[0.1,0.5,0.6,0.1,0.9,0.3],
 #[1,5,2,4,8,10,5,6]]..plot(minval:0,maxval:10);
)

Plotter

Se invece vogliamo creare una sola finestra, all’interno della quale visualizzare e cancellare a ogni esecuzione diversi valori possiamo utilizzare direttamente la Classe Plotter.

Plotter.new;
a = Plotter.new("segmenti",300@200); 
a.value = #[23,12,45,32,65]; 
a.value_(#[1,2,3]);  
a.value = #[10,2,33,41,50,23,45,56];          

Per farlo dobbiamo compiere due distinte operazioni.

  1. creare una finestra vuota o meglio un’istanza della Classe Plotter (riga 1 e 2 del codice precedente):

    image not found

    Ricordiamo che il metodo .new può essere sottinteso. Gli argomenti sono gli stessi che abbiamo visto per il metodo .plot().

  2. riempire la finestra specificando i valori y con il metodo che già conosciamo .value = [items] (riga 3 del codice precedente):

    image not found

    Questa seconda operazione possiamo effettuarla quante volte vogliamo. Il numero di items può variare a piacere e i limiti della finestra si adattano automaticamente ai nuovi valori. Notiamo la differenza rispetto all’utilizzo di questo metodo quando è assegnato a una funzione:

    // Funzioni
    a = {arg a,b; a+b};
    a.value(3,6);
    
    // Plotter
    a = Plotter("segmenti",300@200);
    a.value = #[23,12,45,32,65];
    a.value_( #[1,2,3] );      
    

    Come abbiamo già visto nel Paragrafo dedicato a TempoClock le sintassi usate nelle ultime due righe si equivalgono e possiamo utilizzarne una o l’altra a nostro piacimento.

I due modi appena esposti di visualizzare i valori di un Array si equivalgono in quanto entrambi generano un’istanza della Classe Plotter. Il primo chiede a SuperCollider di farlo per noi attraverso il metodo .plot(). Il secondo ci permette di farlo noi direttamente sottointendendo il metodo .new.

Una volta generata una finestra grafica contenente un Plotter possiamo modificare il suo aspetto grafico anche con il metodo .setProperties():

image not found

(
a = Plotter("segmenti",300@200);
a.value = #[23,12,45,32,65];
a.setProperties(
    \backgroundColor, Color.black,  // sfondo
    \plotColor, Color.yellow,       // data
    \fontColor, Color.magenta,      // numeri
    \gridColorX, Color.white,       // griglia x
    \gridColorY, Color.white,       // griglia Y
                );
)          

Una lista esaustiva di tutte le proprietà grafiche che possiamo modificare la troviamo richiamando l’Help file di questo metodo. Infine possiamo interagire con un Plotter attraverso alcune abbreviazioni da tastiera. Selezioniamo la finestra con un click.

Lettura in sequenza

Qualsiasi tipo di sequenza ritmica (e non solo) può essere rappresentata con un Array.

Questi però soto un punto di vista informatico sono collezioni di data e non hanno nulla a che fare con il tempo.

Leggere un Array che descrive una sequenza di valori musicali è come leggere una partitura tradizionale senza suonarla. Il parametro temporale entra in gioco solo nel momento dell’esecuzione, nota dopo nota. Anche per gli Array dobbiamo dunque avere la possibilità di richiamare a nostro piacere nel tempo ogni singolo item. Possiamo farlo grazie al fatto che sono collezioni indicizzate (id, item) e ottenere di volta in volta ogni singolo elemento (item) richiamandone l’indice sottinteso (id) attraverso diversi metodi.

[].at()

Il primo e il più utilizzato è [].at().

a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5]; // items
//    0  1     2     3     4     5     6    7     // indici sottointesi
a.at(0); // 1
a.at(1); // 0.75
a.at(2); // 0.25
a.at(3); // 0.33 etc....

Osserviamo come sia uso comune assegnare un Array a una variabile per poi richiamare i singoli items specificandone l’indice sottointeso come argomento del metodo [].at() in valutazioni successive. Il primo indice è sempre 0. Possiamo anche utilizzare un’abbreviazione sintattica che corrisponde alla notazione adottata nel linguaggio Java, anche se personalmente (tranne che in alcuni casi particolari) preferisco la notazione tradizionale.

a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5]; // items
//    0  1     2     3     4     5     6    7     // indici
a[0]; // 1
a[1]; // 0.75
a[2]; // 0.25
a[3]; // 0.33 etc....

Un’informazione che come vedremo può tornare utile in numerose occasioni è conoscere il numero di items contenuti in un Array e la possiamo ottenere invocando il metodo .size.

a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5];
a.size; // --> 8

Se in assenza di quest’ultima informazione e per qualsiasi motivo dovesse essere richiamato un indice maggiore della lunghezza dell’Array (size-1 in quanto il primo indice è 0) o minore di 0, SuperCollider riporta nil come dimostrato nell’esempio seguente.

(
var a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5], // items
//        0  1     2     3     4     5     6    7     // indici
    id   = rrand(-5,10),                   // valori tra -5 e 10
    item = a.at(id);                       // richiama
[id, item]                                 // stampa
)

Per ovviare a quanto appena esposto in questo caso possiamo utilizzare o il metodo [].size come argomento di rand(),

(
var a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5], 
    id   = rand(a.size), // valori tra 0 e 7 (size-1)
    item = a.at(id);                       
[id, item]                                
)

oppure altri tre metodi che sono parenti stretti di [].at().

[].clipAt()

Se per qualsiasi motivo il numero che richiama gli indici è superiore al size o inferiore a 0, riporta l’ultimo item sia oltre il size che per numeri negativi:

(
var a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5], // items
//        0  1     2     3     4     5     6    7     // indici
    id   = rrand(-5,10),
    item = a.clipAt(id);
[id, item]
)

[].wrapAt()

Se per qualsiasi motivo il numero che richiama gli indici è superiore al size torna all’indice 0 e continua a contare. Come in un loop. Nell’esempio seguente se rand(15) genera 10 riporta l’elemento che è all’indice 2 (10 - 8 che è il size), se invece esce 14 quello a indice 6 (14 - 8)e così via. Funziona anche con indici negativi.

(
var a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5], // items
//        0  1     2     3     4     5     6    7     // indici
    id   = rand(15),
    item = a.wrapAt(id);
[id, item]
)

[].foldAt()

Se per qualsiasi motivo il numero che richiama gli indici è superiore al size o inferiore a 0 continua la conta in modo speculare. Nell’esempio seguente se esce 13 riporta l’elemento all’indice 1, se invece esce 9 quello all’indice 5. Funziona anche con indici negativi.

(
var a = #[1, 0.75, 0.25, 0.33, 0.33, 0.33, 0.5, 0.5], // items
//        0  1     2     3     4     5     6    7     // indici
    id   = rrand(-5,10),
    item = a.foldAt(id);
[id, item]
)

Questi metodi non hanno esclusivamente una valenza informatica ma anche musicale. Infatti come vedremo in seguito potremo utilizzarli per generare e controllare alcuni tipi di variazioni procedurali.

Così come abbiamo visto parlando di scheduling, possiamo richiamare gli items di un Array "a mano" valutando di volta in volta le singole righe di codice, oppure automatizzare il processo sfruttando una caratteristica non ancora illustrata del metodo n.do({}).

Sappiamo che questo metodo implica la presenza di una funzione e che all’interno di questa possiamo specificare degli argomenti.

Quando una funzione è argomento di un metodo come in questo caso gli argomenti non sono utilizzati come indirizzi ai quali inviare valori dall’esterno ma per recuperare automaticamente informazioni istantanee sullo stato dell'oggetto.

Nello specifico di n.do({}) se scriviamo un solo argomento, indipendentemente dal nome che gli diamo, assume valenza di contatore riportando a ogni valutazione della funzione interna il numero di volte che è stata già valutata, partendo da 0.

Come ultimo valore riporta come sappiamo già il numero totale di valutazioni effettuate;

(
10.do{
      arg ciao;   // contatore    
      ciao.postln // stampa
      }
)

Osserviamo la Post window dopo aver valutato il codice: vedremo stampato un numero per riga da 0 a 9 (tutti i passi del loop) e infine 10 che corrisponde al numero totale di valutazioni. Se sostituiamo ora il numero di iterazioni (in questo esempio 10) con un Array, gli argomenti che possiamo specificare sono due e corrispondono a due distinte informazioni:

(
#["a","b","c"].do{arg ciao, miao;
	             [ciao, miao].postln}
)

L’ultima informazione in questo caso non è il numero totale di valutazioni ma l’Array sul quale abbiamo invocato il metodo. Sebbene l’ordine nella dichiarazione degli argomenti sia prefissato e invariabile (item, contatore), le due informazioni sono indipendenti e legate al nome che gli diamo. Se infatti volessimo postare prima il contatore e dopo l’item potremmo invertire i nomi delle loro etichette nel comando di stampa.

(
#["a","b","c"].do{arg ciao, miao;
	             [miao, ciao].postln} // nomi invertiti
)

Possiamo anche sostituire i due nomi con la parola riservata ...args che abbiamo già incontrato parlando di TempoClock, ma in questo caso l’ordine non è modificabile.

(
a = #["a","b","c"];
a.do{arg ...args;       // argomenti: item, id
            args.postln // stampa
    }
)

Se invece specifichiamo un solo argomento, otterremo semplicemente un iterazione degli items.

(
a = #["a","b","c"];
a.do{arg biro;       // 1 argomento: item
         biro.postln
    }
)

Le sintassi appena esposte sono funzionali alla lettura completa da sinistra a destra di un Array. Se però volessimo leggerlo in modo parziale o randomico oppure utilizzare i metodi [].clipAt(), [].wrapAt() o [].foldAt() non potremmo farlo in quanto una volta terminata l’iterazione il loop si ferma. Per questo motivo torna molto utile l’argomento che riporta numero di valutazioni della funzione (contatore), che possiamo utilizzare come argomento di [].at() o dei suoi simili:

(
a = #["a","b","c"];    // Array da iterare
n = 10;                // numero di iterazioni (da 0 a 9)
n.do{arg biro;         // riporta il numero di iterazione a ogni passo
     a.at(biro).postln // richiama gli items dagli indici
    }
)

Valutando il codice precedente dopo i tre items contenuti nell’Array vedremo stampati nella Post window sette nil e infine il numero 10. In questo modo possiamo utilizzare anche gli altri metodi:

(
a = #["a","b","c","d","e"];
n = 10;
3.do{arg biro;
     a.at(biro).postln};
"-----------".postln;
n.do{arg biro;
     a.clipAt(biro).postln};
"-----------".postln;
n.do{arg biro;
     a.wrapAt(biro).postln};
"-----------".postln;
n.do{arg biro;
     a.foldAt(biro).postln};
)

Per realizzare un sequencing possiamo temporizzare i processi di lettura di un Array includendoli in Routine e Task. Di seguito alcuni esempi:

// Richiamando direttamente gli items.
(
var bpm = 100,                                   // bpm
    t   = TempoClock(bpm/60),                    // metronomo
    dt  = #[1,0.75,0.25,0.33,0.33,0.33,0.5,0.5], // tempi delta
    r   = Routine({
                   dt.do({arg delta;             // items                        
                          Synth("sperc");        // suona
                          delta.postln;          // stampa
                          delta.wait})           // aspetta
                  });
r.play(t)
)

// Richiamando gli indici con '[].wrapAt(id)' e simili.
(
var bpm = 120,
    n   = 20,                                     // numero di eventi
    t   = TempoClock(bpm/60),
    dt  = #[1,0.75,0.25,0.33,0.33,0.33,0.5,0.5],
    r   = Routine({
                   n.do({arg id;                   // indici
                         var delta = dt.wrapAt(id);// itera gli items                        
                         Synth("sperc");           // suona
                         [id,delta].postln;        // stampa
                         delta.wait})              // aspetta
                  });
r.play(t)
)

Come possiamo osservare valutando questo secondo esempio l’argomento di n.do({}) non riporta gli indici dell’Array ma il numero di valutazioni della funzione. Non confondiamoci.

Con questa tecnica possiamo infine sincronizzare tra loro diversi parametri, ognuno memorizzato in un Array differente:

(
var bpm,t,tsec,note,vels,durs,dt,r;
bpm  = 150;
t    = TempoClock(bpm/60);
tsec = 60/bpm;                
note = [90,      100,  98,   97,    96, 93].midicps;
vels = [60,       90, 120,   60,    40, 10]/127;
durs = [0.125, 0.125, 0.5, 0.25, 0.125,  1]*tsec;
dt   = [ 0.25,  0.25,   1,    1,     1,  1];

r = Routine({dt.do{arg delta, id;

                   Synth("sperc",[\freq, note.at(id), // frequenze richiamate come indici
                                  \amp,  vels.at(id), // ampiezze richiamate come indici
                                  \dur,  durs.at(id)  // durate richiamate come indici
                                 ]
                        );
                   delta.wait}                        // tempi delta richiamati come items
             }).reset.play(t);
)

Determinismo

In questo paragrafo possiamo leggere cosa è una procedura deterministica e come possiamo impiegarla per la composizione di suoni nel tempo Qui invece vedremo come realizzare sequenze di eventi musicali nel tempo in SuperCollider.

Teniamo presente un concetto che ripeterò più volte: organizzeremo sequenze di quei parametri del suono che più sono legati alla tradizione musicale occidentale: altezze, intensità, tempi delta e durate ma le stesse tecniche possono essere applicate a qualsiasi altro parametro di qualsiasi tipo di sintesi o elaborazione del suono come vedremo nelle prossime sezioni.

Infine sottolineo che in questo Paragrafo scopriremo solo come programmare sequenze nel tempo di valori assoluti senza entrare nel merito di regole, linguaggi e sintassi musicali o sonore che saranno anch'esse affrontate nelle prossime Sezioni.

Tutti gli esempi e lo sviluppo degli esercizi saranno eseguiti dal Synth \sperc già utilizzato in precedenza.

Alla fine del Paragrafo precedente abbiamo visto come realizzare una sequenza musicale qualsiasi in libero arbitrio semplicemente aggiungendo nuovi Array, uno per ogni parametro desiderato:

image not found

Per comodità abbiamo specificato i valori in unità di misura facilmente interpretabili sia da altri esseri umani che da altri software o interfacce esterne a SuperCollider: frequenze in midinote, ampiezze in velocity (entrambe in valori compresi tra 0 e 127), durate e tempi delta in tempo relativo (frazioni di beat). A questo punto si pone un problema. Infatti come abbiamo appurato qualche riga sopra il Synth "sperc" accetta valori espressi unità di misura differenti da queste. Si rendono necessarie alcune conversioni.

Conversioni di tempo

Come possiamo notare, gli items dell'Array in cui sono specificati i tempi delta sono richiamati uno alla volta all'interno della Routine che genera la sequenza di eventi e che "lavora" al tempo relativo espresso in bpm con TempoClock. Di fatto sono già frazioni del beat. Gli items dell'Array in cui sono specificate le durate invece sono "inviati" al Synth che accetta valori espressi in tempo assoluto (secondi). Si rende dunque necessaria una conversione:

In questo modo al Synth arriveranno i valori in tempo assoluto calcolati di volta in volta al tempo relativo che esprimeremo in bpm.

Conversioni di altezze o frequenze

Come sappamo le altezze possono essere espresse attraverso differenti unità di misura:

Vediamo i principali metodi di SuperCollider che possiamo utilizzare per le conversioni di frequenza:

60.midicps;               // midi --> Hz
440.cpsmidi;              // Hz   --> midi

2.degreeToKey([0,2,5,7]); // il ricevente è l'indice (degree) della scala che spefichiamo come argomento
6.keyToDegree([0,2,3,6]); // il ricevente è il grado relativo tra gli indici

2.midiratio;              // midi --> fattore di moltiplicazione
1.6.ratiomidi;            // fatt.--> midi

440 * 1.midiratio;

Conversioni di intensità o ampiezze

Compiamo le stesse operazioni per quanto riguarda le intensità:

Vediamo i principali metodi di SuperCollider che possiamo utilizzare per le conversioni delle ampiezze:

64/127;       // velocity --> lineare
0.5*127;      // lineare  --> velocity
0.5.pow(4);   // lineare  --> quartica
0.0001.ampdb; // lineare  --> decibel
-80.dbamp;    // decibel  --> lineare

Pause

Fino a questo punto abbiamo realizzato sequenze di eventi nel tempo ma nessuno di questi era una pausa. In generale, indipendentemente dal software musicale che usiamo ci sono due modi differenti per ottenere pause all'interno di una sequenza di suoni:

Sequenze e insiemi

Nella pratica musicale un Array o una qualunqe collezione di dati può essere pensata e utilizzata in due modi differenti:

Questa differenza, nel linguaggio musicale se applicata alle altezze invece che alle durate può coincidere con quella che esiste in generale tra pensiero contrappuntistico e pensiero armonico, orizzontalità vs verticalità:

Accordi

Secondo quanto appena enunciato una sequenza di valori numerici può rappresentare:

Alea

In questo paragrafo possiamo leggere cosa è una procedura non deterministica e come possiamo impiegarla per la composizione di suoni nel tempo. Qui invece vedremo quali sono gli oggetti di SuperCollider che restituiscono valori pseudocasuali seguendo le diverse modalità già illustrate nel Paragrafo sopracitato. Tutti gli oggetti seguenti operano secondo una distribuzione linerare delle probabilità, le tecniche che seguono procedure stocastiche saranno affrontate in un altro Paragrafo.

Infine soffermiamoci su una possibile esigenza musicale comune a tutti gli algoritmi di scelta pseudocasuale appena illustrati: il voler richiamare tutti i valori in un ambito o appartenenti ad un insieme una volta sola, senza ripetizioni come ad esempio la generazione pseudo-casuale delle altezze di una serie dodecafonica. In questo caso siamo obbligati ad utilizzare l'ultima tecnica illustrata in quanto dobbiamo:

Se invece della scelta pseudocasuale tra due limiti volessimo compiere la stessa operazione tra i valori di un insieme dovremmo compiere le stesse operazioni non sull'Array degli items ma su un Array degli indici sottointesi:

(
var items, idx, bpm;
items   = [98,100,76,73,89,103];          // altezze
idx     = [0, 1,  2, 3, 4, 5  ].scramble; // indici
bpm = 120;

t = TempoClock.new(bpm/60);
r = Routine.new({
                 idx.do({arg id;
                         Synth(\sperc,[freq:items.at(id).midicps,
                                        amp: 0.5]);
                         0.25.wait
                         })
}).play(t)
)

Interpolazioni

Vai da a a b in tot

Oltre che variare dinamicamente dall'Interprete il singolo valore, nei controlli di alcuni parametri musicali avremo bisogno di generare delle interpolazioni di diverso tipo per risolvere esigenze tecniche o musicali che rispondano al seguente concetto:

parti da 'a' e vai a 'b' in 'n' passi piccoli o grandi

In musica nella descrizione dei parametri di alto livello il numero di passi è generalmente sostituito dal tempo, anche se come vedremo è un parametro di cui tener conto nelle operazioni matemetiche da effettuare.

parti da 'a' e vai a 'b' in tot secondi con passi piccoli o grandi

image not found

Per quanto riguarda le frequenze ad esempio si tratta di un concetto che esprime un glissato o un qualche tipo di scala che suddivide un intervallo, per le ampiezze un crescendo o diminuendo mentre per il panning i movimenti della sorgente, etc. Possiamo realizzare questo concetto sia con segnali generati da UGens dedicate all'interno del Server come abbiamo fatto per esempio per il portamento (.lag() e suoi simili) sia nell'Interprete temporizzando e automatizzando alcune operazioni matematiche. Per ora occupiamoci di quest'ultimo caso passo dopo passo.

  1. definiamo i parametri di alto livello dichiarandoli come argomeni di una funzione:

    parti da 200 e vai a 800 in 3 secondi seguendo una curva lineare con passi piccoli (indice di definizione uguale a 0.01)

    (
    ~rampa = {arg a     = 200,  // inizio
                  b     = 800,  // fine
                  dur   = 3,    // durata 
                  defId = 0.01; // indice di definizione tra 0.1 (10 passi), 0.01 (100 passi) e 0.001 (1000 passi)
              }
    )
    
  2. calcoliamo l'intervallo assoluto tra a e b:

    (
    ~rampa = {arg a=200,b=800,dur=3,defId=0.01;
              var range;
                  range  = abs(a-b);
              }
    )
    
    ~rampa.value; // risultato...
    
  3. calcoliamo il numero di passi utilizzando come valori limite 0 e 1 perchè quando dovremo generare curve non linerari questo è l'unico intervallo che mantiene il valore di arrivo invariato:

    (
    ~rampa = {arg a=200,b=800,dur=3,defId=0.01;
              var range,nPassi;
                  range  = abs(a-b);
                  nPassi = 1/defId;
               // nPassi = defId.reciprocal; // alternativa...
              }
    )
    
    ~rampa.value; // risultato...
    

    Volendo possiamo definire direttamente in modo assoluto il numero di passi invece che un indice di definizione relativo, ma questa rimane una scelta personale.

  4. creiamo un Array i cui items corrispondono ai valori di tutti i passi compresi tra il valore di partenza e quello di arrivo sotto forma di onsets:

    • invochiamo sull'Array il metodo [].interpolation() che realizza in n passi un'interpolazione lineare tra due valori (in questo caso tra 0 e 1 per la stessa ragione illustrata nel punto precedente).
    • riscaliamo i valori nel range compreso tra a e
  5. (
    ~rampa = {arg a=200,b=800,dur=3,defId=0.01;
              var range,nPassi,aPasso;
                  range  = abs(a-b);
                  nPassi = 1/defId;
                  aPasso = Array.interpolation(nPassi,0,1) // array tra 0 e 1
                                .normalize(a,b);           // riscala
              }
    )
    
    ~rampa.value; // risultato...
    
  6. calcoliamo il tempo delta tra i passi in secondi dividendo la durata del passaggio per il numero di passi:

    (
    ~rampa = {arg a=200,b=800,dur=3,defId=0.01;
              var range,nPassi,aPasso,dPasso;
                  range  = abs(a-b);
                  nPassi = 1/defId;
                  aPasso = Array.interpolation(nPassi,0,1) 
                                .normalize(a,b);           
                  dPasso = dur/nPassi;
              }
    )
    
    ~rampa.value; // risultato...
    
  7. richiamiamo i singoli valori dell'Array nel tempo con il metodo n.do() inserito in una Routine:

    (
    ~rampa = {arg a=200,b=800,dur=3,defId=0.01;
              var range,nPassi,aPasso,dPasso;
                  range  = abs(a-b);
                  nPassi = 1/defId;
                  aPasso = Array.interpolation(nPassi,0,1) 
                                .normalize(a,b);           
                  dPasso = dur/nPassi;
    
    Routine.new({
                 aPasso.do({arg item;
                            var val;
                            val = item;
                            val.postln;  // lo stampa
                            dPasso.wait  // tempo delta tra i passi
                            })
                 }).play
    }
    )
    
    ~rampa.value(200,800,3,0.01);   // incremento
    ~rampa.value(450,100,1,0.01);   // decremento      
    (      
    ~rampa.value(rand(1000).postln, // random...
    	         rand(1000).postln,
    	         rrand(0.1,3),0.01); 
    )
    

Vai a b in tot

Una variante del pensiero (e del codice) precedente è rappresentata dalla seguente situazione:

dal valore che hai vai a 'b' in tot secondi con passi piccoli o grandi

Rispetto al codice precedente basterà eliminare l'argomento a (il valore iniziale) e dichiararlo come variabile al di fuori della funzione, assegnandogli un valore iniziale per permettere di calcolare il range della prima valutazione ed aggiornare il suo contenuto con l'ultimo item letto dal loop nella Routine:

(
var a=0;     // dichiarato al di fuori dela funzione

~rampa = {arg b=100,dur=1,defId=0.01;
          var range,nPassi,aPasso,dPasso;
              range  = abs(a-b);
              nPassi = 1/defId;
              aPasso = Array.interpolation(nPassi,0,1) 
                            .normalize(a,b);           
              dPasso = dur/nPassi;

Routine.new({
             aPasso.do({arg item;
                        a = item;    // aggiorna il valore iniziale
                        a.postln;  
                        dPasso.wait  
                        })
             }).play
}
)

~rampa.value(800,3,0.01);                          // incrementa
~rampa.value(100,1,0.1);                           // decrementa
~rampa.value(rand(1000).postln,rrand(0.1,3),0.1);  // random

Curve non lineari

Negli esempi precedenti le interpolazioni erano di tipo lineari, ovvero il delta tra i passi rimaneva costante per tutta la rampa. Con una piccola aggiunta nel codice illustrato possiamo generare anche interpolzaioni con curve diverse:

image not found

(
var n,lin,scale;
    n    = 100;
    lin  = Array.interpolation(n,0,1);
    scale = [lin,
             lin.squared,
             lin.cubed,
             lin.sqrt,
             lin.pow(2),
             lin.pow(4),
             lin.pow(6),
             lin.pow(0.3)
            ].plot("interpola",600@600,true)
             .plotMode_(\plines)
)

Dobbiamo dunque:

(
var a=0;     // dichiarato al di fuori dela funzione

~rampa = {arg b=100,dur=1,defId=0.01;
          var range,nPassi,aPasso,dPasso;
              range  = abs(a-b);
              nPassi = 1/defId;
              aPasso = Array.interpolation(nPassi,0,1) // Array tra 0 e 1
                            .squared                   // curva
                            .normalize(a,b);           // riscala      
              dPasso = dur/nPassi;

Routine.new({
             aPasso.do({arg item;
                        a = item;    // aggiorna il valore iniziale
                        a.postln;  
                        dPasso.wait  
                        })
             }).play
}
)

~rampa.value(800,3,0.01);                          // incrementa
~rampa.value(100,1,0.1);                           // decrementa
~rampa.value(rand(1000).postln,rrand(0.1,3),0.1);  // random

Integriamo ora le rampe nel codice che abbiamo utilizzato per l'invio del singolo valore, se non vogliamo alcuna rampa basta fissare il tempo a 0.

(
var inizio=0;                  // operazioni di init

// ------------------------------ SynthDef e Synth

SynthDef(\ksig,
              {arg freq=400,smooth=0.02;
               var sig, port;
                   port = freq.lag(smooth); // abbreviazione sintattica
                   sig  = SinOsc.ar(port);
               Out.ar(0,sig)
               }
          ).add;

{~synth = Synth(\ksig)}.defer(0.1);

// ------------------------------ Operazioni di basso livello

~ksynth = {arg fine=100,dur=1,defId=0.01,smth=0.02;          // aggiunto smth
           var range,nPassi,aPasso,dPasso;
               range  = abs(inizio-fine);
               nPassi = 1/defId;
               aPasso = Array.interpolation(nPassi,0,1).pow(4).normalize(inizio,fine);       
               dPasso = dur/nPassi;

Routine.new({
             aPasso.do({arg item;
                        ~synth.set(\freq,item,\smooth,smth); // al Synth
                         inizio = item;                      // aggiorna l'inizio
                        dPasso.wait  
                        })
             }).play;
           };
)

// ------------------------------ Controllo esterno dei parametri

~ksynth.value(800,3,0.01);                          // incrementa
~ksynth.value(100,1,0.1);                           // decrementa
~ksynth.value(rrand(300,1000).postln,rrand(0.1,3),[0.1,0.01,0.001].choose);  // random
~ksynth.value(800,0,0.01);                          // senza rampa