Creazione di un oggetto con Max SDK


Realizzazione di un filtro per Cycling Max, attraverso Max sdk in linguaggio C.

Leggi!
25/05/2019 -  La Software Development Kit di Cycling è una libreria che permette, una volta aggiunta tra i packages di Max, di programmare un nuovo oggetto per Max stesso. Ho letto la documentazione ufficiale ed ho fatto qualche prova; questo articolo è il riassunto di quanto ho concluso.

L'idea è quella di realizzare, in linguaggio C, un oggetto che permetta di filtrare un rumore bianco (o anche un rumore alternativo) attorno ad una specifica nota, ottenendo un suono il più intonato possibile.
Partendo dall modello matematico di un filtro passa basso, ho definito la formulazione più semplice che permettesse di isolare una banda di frequenze abbastanza stretta e con un taglio abbastanza netto: si tratta di un filtro passa basso con un'importante resonance sulla frequenza di taglio, che otterrò tramite la seguente formula:
dove s, a1 e a2 sono coefficienti da calcolarsi di volta in volta, in base anche alla frequenza di taglio richiesta.

L'oggetto si chiamerà notefilter~, e il consiglio per iniziare a scriverne il comportamento in C è di clonare la cartella di uno degli esempi di oggetto audio (Max/Packages/max-sdk/source/audio) e svuotarne il file .c, rinominando poi appropriatamente tutti i file che contiene. In questo modo si ottiene velocemente un progetto già strutturato con le librerie al loro posto. Personalmente ho lavorato con Max 8, utilizzando come IDE Visual Studio nella sua versione gratuita. Se utilizzate (come è probabile che sia) Max a 64 bit, vi ricordo di selezionare tale modalità nel menù a tendina di Visual Studio!
Ecco come ho scritto il file notefilter.c, step by step.

Ho incluso le librerie necessarie al funzionamento di un oggetto audio, ed ho definito la classe dell'oggetto, chiamandola notefilter_class
#include "ext.h"
#include "ext_obex.h"
#include "z_dsp.h"
#include <math.h>

void *notefilter_class;
specificandone poi i parametri, il cui primo (t_pxobject l_obj) è indispensabile per la corretta definizione dell'oggetto
typedef struct _notefilter
{
t_pxobject l_obj;
double freq_taglio; // frequenza di taglio
double l_a1; // coefficiente 1
double l_a2; // coefficiente 2
double l_a1p; // coefficiente 1 precedente
double l_a2p; // coefficiente 2 precedente
double l_ym1; // campione di output precedente
double l_ym2; // campione di output precedente al precedente
double l_fqterm; // coefficiente in frequenza
double l_resterm; // coefficiente di resonance
double l_2pidsr; // 2 pi
short l_fcon; // segnale relativo all'ingresso di frequenza
} t_notefilter;
Infine ho dichiarato tutti gli header delle funzioni che andrò ad implementare, che esse siano di fatto metodi della classe oggetto o meno.
void notefilter_dsp64(t_notefilter *x, t_object *dsp64, short *count, double samplerate, long maxvectorsize, long flags);
void notefilter_perform64(t_notefilter *x, t_object *dsp64, double **ins, long numins, double **outs, long numouts, long sampleframes, long flags, void *userparam);
void notefilter_int(t_notefilter *x, int f);
void notefilter_calc(t_notefilter *x);
void *notefilter_new(double freq, double reso);
A questo punto inizia l'implementazione delle funzioni vere e proprie, la cui prima, chiamata ext_main, si occupa di creare fisicamente l'oggetto tramite class_new, specificandone il nome, i metodi di costruzione e distruzione e la dimensione (ed altri parametri che non vengono utilizzati perché non necessari o obsoleti). La stessa funzione si occupa anche di aggiungere di fatto i metodi alla classe: le funzioni che fino ad ora sono indipendenti divengono metodi tramite class_addmethod, che richiede inoltre di passare un nome con cui riconoscere quel metodo all'interno della funzione.
class_register permette infine di registrare la classe definita nella ClassBox di Max.
void ext_main(void *r)
{
t_class *c;
c = class_new("notefilter~",(method)notefilter_new, (method)dsp_free,
sizeof(t_notefilter), 0L, A_DEFFLOAT, A_DEFFLOAT, 0);
class_addmethod(c, (method)notefilter_dsp64, "dsp64", A_CANT, 0);
class_addmethod(c, (method)notefilter_int, "int", A_LONG, 0);
class_dspinit(c);
class_register(CLASS_BOX, c);
notefilter_class = c;
return 0;
}
La funzione successiva, notefilter_dsp64, permette di stoccare i coefficienti precedenti in apposite variabili, e di memorizzare il valore di input fornito in quell'istante; questo passaggio viene aggiunto alla dsp chain, la lista che specifica le funzioni di signal processing definite dall'oggetto per i suoi segnali.
void notefilter_dsp64(t_notefilter *x, t_object *dsp64, short *count, double samplerate, long maxvectorsize, long flags)
{
x->l_2pidsr = (2.0 * PI) / samplerate;
notefilter_calc(x);
x->l_a1p = x->l_a1;
x->l_a2p = x->l_a2;
x->l_fcon = count[1];

dsp_add64(dsp64, (t_object *)x, (t_perfroutine64)notefilter_perform64, 0, NULL);
}
Il cuore del programma risiede nella prossima funzione, notefilter_perform64, che richiede forse un'analisi leggermente più approfondita.
Dopo la definizione delle variabili temporanee che servono nell'esecuzione della funzione, vengono caricati i valori (proprio in questi spazi appena creati) dal set di parametri dell'oggetto, e viene definito, per comodità, un valore chiamato scale, dato dalla somma dei due coefficienti, incrementata di uno.
void notefilter_perform64(t_notefilter *x, t_object *dsp64, double **ins, long numins, double **outs, long numouts, long sampleframes, long flags, void *userparam) {
t_double *in = ins[0];
t_double *out = outs[0];
t_double freq = x->freq_taglio;
double a1 = x->l_a1;
double a2 = x->l_a2;
double ym1 = x->l_ym1;
double ym2 = x->l_ym2;
double val, scale, temp, resterm;

scale = 1.0 + a1 + a2;
Il software passa ora in rassegna tutti i campioni disponibili, processandone il valore quantizzato secondo la formulazione matematica che ho scelto. Per semplificare la comprensione di questo passaggio chiave, ho riassunto l'operazione in uno schema.

Per implementarne il funzionamento, ho sfruttato le variabili temporanee definite all'inizio della funzione, ed ho strutturato un ciclo while per scorrere i campioni.
while (sampleframes--) {
val = *in++;
temp = ym1;
ym1 = scale * val - a1 * ym1 - a2 * ym2;
ym2 = temp;
*out++ = ym1;
}
x->l_ym1 = ym1;
x->l_ym2 = ym2;
}
Negli oggetti di Max, come è noto, ci possono essere diversi input, che accettano dati di diverso tipo. In questo caso, il primo input sarà riservato al segnale da filtrare (il rumore, nel caso in cui si voglia usare notefilter come sintetizzatore), mentre il secondo riceverà un numero intero relativo alla nota midi attorno alla cui frequenza si vuole eseguire il filtraggio.
La funzione notefilter_int si occupa proprio di definire il comportamento dell'oggetto al ricevere di tale numero intero.

Viene calcolata la frequenza di taglio a partire dalla midi-note, seguendo la semplice formula

e ne viene memorizzato il dato nel parametro freq_taglio.
Una funzione di post (sintassi analoga alla comune funzione printf di C) permette di stampare nella console di max un messaggio contenente le informazioni di nota e di frequenza in questione. La funzione chiama infine notefilter_calc, metodo che esegue il calcolo dei coefficienti, e che tratterò tra poco.
void notefilter_int(t_notefilter *x, int f)
{
x->freq_taglio = (440.0 / 32.0) * pow(2.0,((f - 9.0) / 12.0));
post("nota n. %i - frequenza: %f",f, x->freq_taglio);
notefilter_calc(x);
}
I coefficienti a1 e a2 sono fondamentali per una corretta operazione di filtraggio: caratterizzano l'intensità della resonance e riportano la frequenza di taglio; il loro calcolo avviene nella funzione notefilter_calc secondo le seguenti espressioni:

e il codice con cui l'ho implementa è molto semplice
void notefilter_calc(t_notefilter *x)
{
double resterm;
resterm = exp(0.125) * 0.882497;
x->l_fqterm = cos(x->l_2pidsr * x->freq_taglio);
x->l_a1 = -2. * resterm * x->l_fqterm;
x->l_a2 = resterm * resterm;
x->l_resterm = resterm;
}
L'ultima funzione del software, che conclude la realizzazione dell'oggetto, è notefilter_new, quella che viene chiamata alla creazione dell'oggetto.
Vengono eseguite alcune operazioni di routine, atte ad allocare la memoria necessaria per il funzionamento dell'oggetto, e vengono definiti gli inlet (nel nostro caso 2) e gli outlet (nel nostro caso 1).
Alcuni valori parametrici (come 2pidsr) possono essere calcolati in questa sede, ammesso che il loro valore non debba poi essere ricalcolato in corso d'esecuzione: queste operazioni, infatti, vengono eseguite solo nel momento della creazione dell'oggetto.
void *notefilter_new(double val, double reso)
{
t_notefilter *x = object_alloc(notefilter_class);
dsp_setup((t_pxobject *)x,2);

x->freq_taglio = val;
x->l_2pidsr = (2. * PI) / sys_getsr();
notefilter_calc(x);

x->l_a1p = x->l_a1;
x->l_a2p = x->l_a2;

outlet_new((t_object *)x, "signal");
return (x);
}

Il software funziona, ed una volta compilato ha generato correttamente l'oggetto notefilter~, che compare ora tra gli oggetti di Max, completo di autocompilazione quando si inizia a digitarne il nome.
Per testarlo, ho realizzato una semplice patch in cui il rumore prodotto da un oggetto noise~ viene filtrato da notefilter~, che riceve da uno kslider la nota attorno alla quale filtrare il segnale. L'output viene visualizzato tramite spectroscope e riprodotto tramite la scheda audio attraverso l'oggetto ezdac.

Il risultato è un suono metallico e decisamente poco adatto ad un utilizzo reale, ma sono soddisfatto di quanto ho ottenuto perché, dopotutto, è ciò che mi aspettavo. Questo è quanto spectroscope mostra durante il funzionamento di notefilter


Il file notefilter.c completo è disponibile al seguente link:

download c file


Share this article:
Let's get in touch!