Wavetable lookup
Quando parliamo di playback in un sistema audio digitale intendiamo sempre la lettura dinamica nel tempo dei valori di ampiezza istantanea di un segnale (campioni o samples) scritti in precedenza nella memoria di massa (HD, SD o altro) o temporanea (RAM) di un device. Questa operazione è alla base di quasi tutte le tecniche di sintesi ed elaborazione del segnale in ambito digitale e consiste nel leggere dinamicamente i valori di ampiezza istantanea (y) memorizzati in un Buffer richiamandone gli indici (x) con un segnale puntatore debitamente riscalato:
L'immagine precedente mostra come i valori in output (y) del segnale puntatore possono corrispondere ai valori degli indici (x) dei campioni memorizzati nel Buffer. Se la forma d'onda del segnale puntatore è un fasore (onda a dente di sega) e il tempo delta che intercorre tra i campioni (periodo di campionamento) è lo stesso di quello utilizzato per memorizzare i campioni nel Buffer otterremo una fedele riproduzione dell'audio presente nel Buffer (playback). Se invece la forma d'onda e/o la frequenza sono maggiori o minori o variano dinamicamente nel tempo otterremo un qualche tipo di elaborazione del segnale.
Fasori
Storicamente in informatica musicale esistono tre tipi di fasori che possiamo utilizzare come segnale puntatore in un wavetable lookup:
- Fasore unipolare che genera valori compresi tra 0 e 1 e si presta a un utilizzo generico.
p = 100; // Size del Buffer n = 1/p * -1; // Valore iniziale (offset di -0.01) a = p.collect({n = n+(1/p)}) // UNIPOLARE tra 0 e 1
- Fasore bipolare che genera valori compresi tra -1 e 1 e si presta a un utilizzo generico.
b = a * 2 - 1; // BIPOLARE tra -1 e +1
- Fasore incrementale che genera valori compresi tra un minimo ed un massimo e si presta a essere utilizzzato nella programmazione di oscillatori tabellari o in tecniche di tabling.
c = b * 0.3 + 0.1; // Tra Min e Max
Per poter impiegare i valori di ampiezza istantanea (y) del fasore nella rilettura degli indici (x) di un Buffer dobbiamo riscalarli in un ambito (range) compreso tra zero e il size-1 del Buffer da rileggere.
Nel primo caso (fasore unipolare) moltiplichiamo semplicemente i valori in uscita per il size:
a; // Unipolare d = a * p; // Tra 0 e size-1
(osserviamo i valori ottenuti nella post window).
Nel secondo caso (fasore bipolare) dobbiamo prima renderli unipolari e poi moltiplicarli per il size:
b = b * 0.5 + 0.5; // Da bipolare a unipolare d = b * p; // Tra 0 e size-1
(osserviamo i valori ottenuti nella post window).
Con i valori così riscalati un singolo periodo (ciclo) del fasore corrisponderà ad una singola rilettura del Buffer.
LFSaw.ar()
In SuperCollider possiamo utilizzare la UGen LFSaw.ar() che genera un segnale bipolare di questo tipo.
( {[ LFSaw.kr(2), // Bipolare LFSaw.kr(2, 1), // Fuori fase LFSaw.kr(2, 1) * 0.5 + 0.5 // Unipolare ]}.plot(1); )
Osserviamo come per far cominciare la la lettura dall'inizio del Buffer dobbiamo mettere l'oscillatore fuori fase.
Se vogliamo leggere l'intero buffer possiamo recuperare automaticamente il suo size in campioni attraverso due sintassi.
u = Buffer.read(s, "bach.wav".resolveRelative); u.numFrames; // Se vogliamo il risultato nell'Interprete {BufFrames.kr(u).poll(2)}.play; // Se in una SynthDef (Server)
Per poi riscalare i valori di LFSaw.ar() in un ambito compreso tra 0 ed il valore ottenuto invocando il metodo .range(min,max) direttamente sul segnale bipolare.
u = Buffer.read(s, "bach.wav".resolveRelative); {LFSaw.ar(2,1).range(0,BufFrames.kr(u))}.plot(1)
Questo metodo ci permette di rileggere anche porzioni di buffer specificando un punto d'inizio e uno di fine compresi nel size come valori di minimo e massimo.
u = Buffer.read(s, "bach.wav".resolveRelative); u.duration; // Recuperiamo la durata in secondi per scegliere in essa ~inizio = 0.3 * u.sampleRate; // Da secondi a campioni ~fine = 1.7 * u.sampleRate; {LFSaw.ar(2,1).range(~inizio,~fine)}.plot(1);
Per quanto riguarda la frequenza dobbiamo agire diversamente a seconda dellla funzione del fasore.
Se lo utilizziamo in tecniche di wavetable lookup synthsis ovvero nella lettura di una wavetables (un buffer su cui è memorizzato un singolo periodo di forma d'onda) la frequenza del fasore (in Hz) sarà la stessa del suono prodotto.
Se invece lo utilizziamo in tecniche di looping dobbiamo ricavare la frequenza in Hertz dalla durata in secondi della porzione di Buffer che vogliamo riprodurre calcolandone il reciproco.
~dur = 0.5; // Durata in secondi 1/~dur; // Periodo in Hertz ~dur.reciprocal; // Reciproco...
Phasor.ar()
La UGen Phasor.ar() è invece un fasore del terzo tipo (incrementale) ovvero che genera valori compresi tra un minimo e un massimo che aumentano a seconda del passo specificato (incremento). Il valore massimo non è mai raggiunto (wrap point).
~min = 200; ~max = 1000; ~passo = 10; {Phasor.ar(0,~passo,~min,~max)}.plot
Il codice precedente ad esempio genera 80 valori (200, 210, 220, 230, etc) ad ogni ciclo.
Se avessimo specificato un passo di 20 ne avrebbe generati solo 40 in quanto minimo e massimo rimangono invariati (200, 220, 240, 260, etc.).
( ~min = 200; ~max = 1000; ~passo1 = 10; ~passo2 = 20; { [Phasor.ar(0,~passo1,~min,~max), Phasor.ar(0,~passo2,~min,~max)] }.plot(bounds:600@600) )
Possiamo facilmente intuire che l'incremento (passo) è il parametro che influisce sulla frequenza e che è in stretta relazione con la sample rate. Anche in questo caso dobbiamo agire diversamente a seconda dellla funzione del fasore.
Se lo utilizziamo in tecniche di wavetable lookup synthsis ovvero nella lettura di una wavetables (un buffer su cui è memorizzato un singolo periodo di forma d'onda) è necessario ricavare il valore d'incremento dalla frequenza desiderata (in Hz).
u = Buffer.alloc(s, 1024); ~freq = 200; // Frequenza (Hz) ~max = u.numFrames; // Recuperiamo la durata in campioni ~passo = ~max * ~freq / s.sampleRate; // size del Buffer * freq / sample rate {Phasor.ar(0, ~passo, 0, ~max)}.plot(1);
Se invece lo utilizziamo in tecniche di looping l'incremento sarà sempre di un campione e possiamo specificare la porzione di buffer da leggere (o l'intero buffer) come punti d'inizio e fine.
u = Buffer.read(s, "bach.wav".resolveRelative); ~min = 0; // Punto d'inizio ~max = u.numFrames; // Fine {Phasor.ar(0, 1, ~min, ~max)}.plot(1);
In SuperCollider sia LFSaw.ar() che Phasor.ar() sono comunemente utilizzati in combinazione con BfRd.ar() sia nelle tecniche di looping che nella costruzione di oscillatori tabellari
Puntatori
I fasori lavorano secondo il concetto: vai da 'a' fino a 'b' in questo tempo oppure in 'n' passi. In alcune tecniche di elaborazione del suono (ma non per la wavetable synthesis) possiamo anche specificare singoli punti da raggiungere in un tempo dato. In questo caso il concetto precedente varia in: dal punto in cui ti trovi vai a quest'altro punto in questo tempo specificando sempre una coppia di valori: target time.
In SuperCollider possiamo utilizzare la UGen VarLag.ar() scegliendo ovviamente i valori target in un ambito compreso tra zero e il size del buffer che stiamo leggendo (nell'esempio sono tra -1 e +1 solo per poterli visualizzare nell'oscilloscopio).
( a = {arg punto=0,tempo=1; // target time VarLag.kr(punto,tempo) }.scope ) a.set(\punto,rand2(1.0),\tempo,0.5);
Per poter utilizzare questo segnale come puntatore per BufRd.ar()dobbiamo però convertirne la rata:
( a = {arg punto=0,tempo=1; // target time var krate,arate; krate = VarLag.kr(punto,tempo); // Control rate arate = K2A.ar(krate); // Audio rate }.scope ) a.set(\punto,rand2(1.0),\tempo,0.5);
Questo tipo di wavetable lookup si presta particolarmente a tecniche di scratching
Altri segnali
Nel wavetable lookup possiamo infine utilizzare qualsiasi tipo di segnale audio continuo o flusso numerico continuo (numeric stream) per "navigare" all'interno di un buffer semplicemente riscalandone i valori.
Se l'ambito del segnale è compreso tra -1 e +1 possiamo riscalarlo invocando il metodo .range().
u = Buffer.read(s, "bach.wav".resolveRelative); ~min = 0; ~max = u.numFrames; {LFNoise1.ar(1).range(~min,~max).poll}.play
Se invece l'ambito del segnale da riscalare è diverso possiamo invocare il metodo .linlin()
( ~minIn = 200; ~maxIn = 3000; ~minOut = 0; ~maxOut = 400; {var a,b; a = TRand.ar(~minIn,~maxIn,Impulse.ar(2)); // Segnale tra 200 e 3000 b = a.linlin(~minIn,~maxIn,~minOut,~maxOut).poll // Segnale riscalato }.play )
Tecniche
Le tecniche utilizzate per compiere questa operazione sono numerose e devono essere di volta in volta scelte in base alle esigenze musicali del caso. Possiamo raggrupparle in tre grandi categorie di strumenti virtuali:
Players (Riproduttori) che effettuano una lettura consecutiva di tutti i samples dal primo all'ultimo con la stessa rata di campionamento alla quale sono stati registrati senza alcun tipo di modifica o elaborazione dell'informazione se non di natura esclusivamente tecnica come ad esempio diversi tipi di interpolazione tra i campioni per migliorare la qualità della riproduzione.
Elaboratori che utilizzano diverse modalità di lettura dei campioni. Le tecniche principali per compiere questo tipo di operazioni possono essere ulteriormente raggruppate in quattro tipologie e svolgersi sia in tempo differito che in tempo reale.
- Slicing: leggere in un ordine non sequenziale porzioni di un Buffer.
- Enveloping: modificare l'inviluppo d'ampiezza del file originale o di sue porzioni.
- Looping: leggere ciclicamente una o più porzioni di un Buffer.
- Scratching: muoversi dinamicamente attraverso un puntatore nella lettura di un Buffer.
- Oscillatori tabellari che effettuano una lettura continua dal primo all'ultimo campione di uno o più cicli di forma d'onda memorizzati in una o più wavetables. Anch'essi in genere danno la possibilità di scegliere il tipo di interpolazione per definire la qualità del suono generato.
Per assecondare le diverse peculiarietà di queste tecniche nella maggior parte dei software sono disponibili tre tipi di oscillatori virtuali:
Sample player oscillator. Caratterizzati dalla possibilità di leggere un Buffer (o una porzione di esso) sequenzialmente specificando velocità di lettura ed eventualmente direzione (recto o verso). La lettura solitamente comincia (o ricomincia) quando l'oscillatore riceve un trigger. Questo oscillatore è più adatto alle tecniche di looping e slicing, nonchè per la sintesi granulare. In SuperCollider corrisponde alla UGen PlayBuf.ar().
Scarichiamo il soundfile utilizzato negli esempi.
( s.boot; s.scope; s.meter; s.plotTree; ) ( Buffer.freeAll; b = Buffer.read(s,"bach.wav".resolveRelative); SynthDef(\player_1, {arg buf=0,amp=1,t_rig=0; var sig; sig = PlayBuf.ar(2, buf,trigger: t_rig); Out.ar(0,sig * amp) }).add; ) a = Synth(\player_1,[\buf,b]); a.set(\t_rig,1); a.free;
Buffer reading oscillator. Caratterizzato dalla possibilità di "navigare" dinamicamente all'interno di un Buffer effettuandone la lettura attraverso segnali di controllo di svariato tipo. Si presta particolarmente a tecniche di slicing e scratching. In SuperCollider corrisponde alla UGen BufRd.ar().
( Buffer.freeAll; b = Buffer.read(s,"bach.wav".resolveRelative); SynthDef(\player_2, {arg buf=0, amp=0; var sig,punta; punta = LFNoise2.ar(1).range(0,BufFrames.kr(buf)); sig = BufRd.ar(2, buf, punta); Out.ar(0,sig * amp) }).add; ) a = Synth(\player_2, [\buf,b,\amp,1]) a.free;
Interpolating wavetable oscillator (Oscillatore tabellare). Caratterizzato dalla possibilità di leggere una wavetable contenente un singolo ciclo di forma d'onda opure una porzione di Sound file inviluppata. Si presta particolarmente alla costruzione di timbri con spettro fisso, a tecniche di sintesi vettoriale e tabling. In SuperCollider corrisponde alla UGen Osc.ar().
( Buffer.freeAll; b = Buffer.alloc(s,1024,1); b.sine1([1,0.3,0.5,0.7],true,true); SynthDef(\player_3,{arg buf=0,amp=0; var sig; sig = Osc.ar(buf, MouseX.kr(60,300)); // mouse x controlla l'altezza Out.ar(0,sig * amp) }).add; ) a = Synth(\player_3, [\buf,b,\amp,1]) a.free;