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:

image not found

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:

image not found

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.

image not found

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.

image not found

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.

image not found

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)
)

image not found

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:

Per assecondare le diverse peculiarietà di queste tecniche nella maggior parte dei software sono disponibili tre tipi di oscillatori virtuali: