Minim es una de las librerías de sonido más potentes que hay, y encontramos sus inicios en 2007, cuando el desarrollador Damien Di Fede’s la incorporó a Processing. Está basada en JavaSound e, inicialmente, ya soportaba la reproducción de ficheros MP3, generadores de ruido y osciladores. En 2009 se incluyó, junto con Anderson Mills, un modelo Ugen, que permite encadenar diferentes efectos y generadores que tratan la información en tiempo real. El modelo Ugen es un modelo que se implementa en la generación de música sintética desde los años 50 y cuya primera implementación la realizó el equipo Max Mathews en Bell Labs.
A pesar de que hay bastante documentación de cómo utilizar los diferentes componentes de Minim, no hay muchos ejemplos de cómo es posible ampliar sus funcionalidades para adaptarlo a nuestros proyectos. Es necesario un nivel avanzado de Java para analizar el código y saber cómo realizar las adaptaciones de las clases existentes. En este artículo vamos a explicar cómo funcionan internamente los Ugen y cómo podemos ampliarlos para generar nuestros propios efectos o conectores que nos permitan, por ejemplo, realizar visualizaciones o disparar eventos al detectar un nivel o un rango de frecuencias. Será necesario tener unos conocimientos básicos de Processing para poder seguir el artículo, ya que se dan por supuesto varios conceptos de cómo funciona Processing a la hora de visualizar información en pantalla, inicializar variables, etc. En la revista MOSAIC se pueden localizar diversos artículos para familiarizarse con el lenguaje, como ‘Introducción a Processing‘.
El modelo Ugen
Este modelo se basa en diferentes módulos independientes que generan o reciben una señal y la sirven hacia otro elemento. Cada vez que la señal pasa por un elemento, esta se modifica hasta llegar a la salida final que es por norma la tarjeta de sonido. La velocidad a la que se solicita la información a la cadena de Ugens viene determinada por la frecuencia configurada en el elemento AudioOutput, es decir, la salida hacia la tarjeta de sonido. Por lo tanto, si tenemos configurada la frecuencia a 8Khz, se realizarán 8000 peticiones en un segundo, por lo que una de las premisas fundamentales es que los diferentes módulos deben ser rápidos en procesar la señal de forma que no agreguen retardos indeseados al sonido final.
Explicado de otro modo, tomemos como ejemplo el juego del teléfono descompuesto, en el cual una persona le va diciendo una frase al siguiente jugador y, al final, la frase queda alterada con respecto a la frase original porque cada persona le ha ido añadiendo un matiz diferente. Si lo extrapolamos, cada persona sería un módulo Ugen: la primera persona que inicia el juego sería un Ugen generador de sonido (oscil, por ejemplo), las siguientes personas serian módulos Ugen que modifican la frase (Ugen del tipo amplitude, pan, moogfilter, etc), y la persona que indica la frase final sería un AudioOutput.
El ejemplo más simple de una configuración de muestra seria un elemento Oscil con una frecuencia y amplitud configuradas a 1Khz u 0.5 de amplitud conectado a la salida con AudioOutput.
Todos los módulos Ugen descienden de una clase Ugen abstracta que implementa algunos buffers y funciones comunes a cualquier módulo de este tipo. Así, por ejemplo, todos los módulos que extiendan la clase tendrán disponible la función patch que permite ir enlazando los diferentes módulos. La clase, además, obliga a implementar la función uGenerate, que es la función principal de manipulación, cada vez que el sistema necesita datos, hará una llamada al método uGenerate de su módulo continuo, este a su vez pedirá datos a su módulo continuo, etc. Por ejemplo, supongamos que tenemos un oscil, un moogfilter y el audioouput conectados entre si, cuando el audioOput necesite datos, le pedirá al moogfilter una muestra a reproducir, este a su vez le solicitará datos al Oscil, el cual, como es un generador, servirá una muestra de datos. El moogfilter modificará los datos obtenidos de Oscil y los servirá al AudioOuput que los enviará a la tarjeta de sonido.
Creando nuestro propio Ugen
Para entender los diferentes conceptos explicados hasta ahora, vamos a implementar un módulo Ugen que permita visualizar el sonido que pasa a través de él. De este modo podremos visualizar el sonido antes de pasar por un filtro y después de pasar por el filtro.
Las características que tendrá nuestro visualizador serán las siguientes:
- Visualizar espectrograma
- Visualizar en forma de onda canal izquierdo y derecho
- Obtener el buffer con el que se han dibujado las muestras
- Volcado de buffer manual
Para poder dibujar la forma de onda, el módulo al que llamaremos Visualizador deberá ir almacenando en un buffer cada vez que el sistema le solicite datos, cuando el buffer se llene, se volcará el contenido en un buffer intermedio de dibujado, lo que permitirá tener información estable en el momento de dibujar la onda. El buffer donde se almacenan los datos, será un buffer circular, este tipo de buffer es ideal para almacenar datos temporales, cuando llega al final del buffer vuelve a rellenar información desde el principio por lo que siempre se dispondrá de las últimas n muestras del sonido.
Para crear una nueva clase, crearemos un fichero Visualizador.pde, en el cual deberemos importar las librerías minim e indicar el nombre de la clase, que debe coincidir con el del archivo.
import ddf.minim.*;
import ddf.minim.analysis.*;
import ddf.minim.ugens.*;
public class Visualizador extends UGen {
// declarar variables
// implementar funciones
}
El primer paso será declarar todas las variables que hacen falta para realizar las operaciones en el módulo, por lo tanto, hay que declarar un conector hacia otros módulos Ugen, los buffers de almacenaje temporal y de dibujado.
public UGenInput audio; // Conector al que està conectado
private int bufferSize = 1024; // Tamaño por defecto del buffer
private float[][] buffer; // Buffer circular
public SignalBuffer left; // Buffer para el canal izquierdo
public SignalBuffer right; // Buffer para el canal derecho
public SignalBuffer mix; // Buffer con la mezcla de los dos canales
private int index = 0; // Índice a la posición del buffer
private FFT fft = null; // Analisis de la transformada de Fourier
El constructor de la clase será el encargado de inicializar las variables, asignar espacio de memoria a los buffers, etc.
/**
Constructor del conector UGen indicando el tamaño del buffer
@param size - Indica el tamaño del buffer
*/
public Visualizador(int size) {
// Iniciamos el objecte interno al que se enlaza
audio = new UGenInput( InputType.AUDIO );
// Asignamos el tamaño del buffer
bufferSize = size;
// Iniciamos el buffer con dos canales, la implementación de momento es sólo STEREO
buffer = new float[2][bufferSize];
// Iniciamos los tres buffers
left = new SignalBuffer(bufferSize);
right = new SignalBuffer(bufferSize);
mix = new SignalBuffer(bufferSize);
}
Para poder iniciar el tamaño del analizador FFT, tenemos que esperar a que se asigne el tamaño del sampleRate en el sistema, por lo que no es posible inicializar la variable en el constructor. Para ello Minim incluye una función que es llamada cada vez que se cambia el sampleRate de salida, de forma que todos los módulos uGen conectados puedan reconfigurarse.
/*
Función llamada por Mínim cuando se cambia el sampleRate del controlador principal
*/
protected void sampleRateChanged() {
fft = new FFT(bufferSize, sampleRate());
fft.linAverages(30);
}
La función uGenerate, será la que irá obteniendo muestras y las irá guardando en el buffer, por lo tanto, devolverá la información sin manipular
/*
Método que devuelve los valores de cada canal al conector al que pertenece, en
nuestro caso, el visualizador UGen es transparente y no modifica los datos
por lo tanto coge los valores con la función getLastValues, y los asigna directamente
a la variable channels, posteriormente almacena una copia en el buffer interno
*/
@Override
protected void uGenerate(float[] channels) {
for (int i = 0; i < channels.length; ++i) {
// Copiamos los datos tal y como vienen de la fuente donde estamos conectados, en el caso
// de implementar otro tipo de módulo, sería en este punto donde deberíamos alterar los
// datos, por ejemplo un módulo gain, multiplicará el dato por una constante
channels[i] = audio.getLastValues()[i];
// Llenamos el buffer interno con los datos recuperados, protegemos que la fuente
// no tenga más de 2 canales, de momento la implementación está pensada para STEREO
if (i < buffer.length) {
buffer[i][index] = audio.getLastValues()[i];
// Si sólo tiene un canal, llenaremos el segundo canal con el mismo valor
if (channels.length == 1) {
buffer[1][index] = audio.getLastValues()[i];
}
}
}
// Incrementamos el índice del buffer circular
index++;
// Controlamos que no sobrepase el tamaño del buffer
index %= bufferSize;
// Si es zero indica que ha dado la vuelta y está lleno, por lo que haremos
// un volcado si está activo el autovolcado
if (autofill && index == 0) {
// Para no interferir y crear esperas, se llamda desde un hilo independiente
new Thread() {
public void run() {
forward();
}
}.start();
}
}
La clase incluye, además, varias funciones que permiten trabajar con el buffer obtenido o forzar el volcado manual.
// Función que hace un volcado del buffer circular a los diferentes buffers
// de los canales de audio, se puede llamar explicitamente o automáticamente, la
// propia clase hace un volcado cada vez que se llena el buffer circular
public float[] forward() {
int n = index;
// Índice a la siguiente posición del final del buffer, se hace un llenado desde
// el final hacia el principio, es decir desde bufferSize hasta 0
for (int i=bufferSize-1; i >= 0; i--) {
if (--n < 0) n = bufferSize-1;
left.set(i, buffer[0][n]); // Canal izquierdo
right.set(i, buffer[1][n]); // Canal derecho
mix.set(i, (buffer[0][n]+buffer[1][n])/2); // Mezcla de los 2 canales
}
return mix.toArray();
}
Para realizar el dibujado de la onda, se suministran dos funciones que, básicamente, pintan los valores del buffer en la pantalla, tal y como haríamos con la salida del AudioOput con mix, left o right
/*
Funciónn que pinta la forma de onda del buffer mix
@param x - Coordenada horizontal
@param y - Coordenada vertical
@param a - Multiplicador nivel de señal
*/
public void onda(float x, float y, float a) {
onda(x,y,a, mix);
}
/*
Función que pinta la forma de onda del buffer indicado
@param x - Coordenada horizontal
@param y - Coordenada vertical
@param a - Multiplicador nivel de señal
*/
public void onda(float x, float y, float a, AudioBuffer out) {
for (int i=0; i < bufferSize-1; i++) {
line( x+i, y - out.get(i)*a, x+i+1, y - out.get(i+1)*a );
}
}
Funciones similares se implementan para el pintado del espectro
/*
Función que pinta el espectro del buffer out utilizando un componente FFT
@param x - Coordenada horizontal
@param y - Coordenada vertical
@param w - Ancho de las barras
@param eje - Indica si pinta la leyenda de frecuencias
*/
public void espectro(AudioBuffer out, float x, float y, float w, boolean eje) {
// Pintamos el espectro de la mezcla de los canales izquierdo y derecho
fft.forward( out ); // Analizar el buffer de la salida de audio
for (int i = 0; i < (fft.avgSize()); i++) {
// Pintamos un rectangle del ancho calculado y altura indicada por la media de la banda,
// multiplicamos por 3 para mejorar la visualización
rect((x+(i*w)), y, 9, -fft.getAvg(i)*3);
// Pintem los valores de las frecuencias
if (eje) {
text(nf(fft.getAverageCenterFrequency(i)/1000, 0, 1), x+i*w, y+10);
}
}
// Pintamos el texto de la leyenda de las frecuencias
if (eje) text("Khz", x+fft.avgSize()*w, y+10);
}
Usando el componente
Se puede ver el uso del componente con el siguiente ejemplo, para el cual se utiliza un oscilador y un filtro paso bajo. La frecuencia del oscilador se puede variar moviendo el ratón por la pantalla. Se visualizará la señal antes y después de actuar el filtro.
El filtro se configura a una frecuencia de corte de 1.8Khz, y el oscilador variará la frecuencia entre 200 Hz y 5Khz. En la pantalla se podrá observar cómo se atenúa la señal conforme la frecuencia aumenta.
import ddf.minim.*;
import ddf.minim.analysis.*;
import ddf.minim.effects.*;
import ddf.minim.signals.*;
import ddf.minim.spi.*;
import ddf.minim.ugens.*;
// Objecto de sonido Minim
Minim minim;
AudioOutput out; // Salida de sonido hacia la tarjeta de audio
Oscil oscil; // Objeto oscilador
MoogFilter filtro; // Filtro de señal
Visualizador visualizador; // Connector UGen para dibujar antes del filtro
void setup() {
// Uso del render P2D más eficiente
size(700, 400, P2D);
// Se establece un frameRate de 30 fps
frameRate(30);
// Inicializar la libreria de sonido MINIM
minim = new Minim(this);
// Trabajaremos en mono
out = minim.getLineOut(Minim.MONO);
// Iniciamos un conector UGen Visualizador, y le asignamos el mismo tamaño que el OUT
visualizador = new Visualizador(out.bufferSize());
// Creamos el filtro PASO-BAJO
filtro = new MoogFilter(1800f, 0, MoogFilter.Type.LP);
// Creamos un oscilador SINUISOIDAL de 350Hz
oscil = new Oscil(350, 0.5f);
// Asociamos los diferentes UGen
// OSCILADOR -> VISUALIZADOR -> FILTRO -> SALIDA
oscil.patch(visualizador).patch(filtro).patch(out);
}
void mouseMoved() {
float frec = map( mouseX, 0, width-1, 200, 5000 );
// Cambiamos la frecuencia en el oscilador
oscil.setFrequency(frec);
}
void draw() {
// Pintamos una rejilla
noStroke();
background(0);
strokeWeight(0.5); // Cambiamos el grosor de las lineas
// Intervalos de 10
for (int x=0; x <= width; x+=10) {
// Verde oscuro para las lineas, las multiplos de 50 les indicamos con color más claro
stroke(0, (x%50 == 0 ? 80 : 60), 0);
line(x, 0, x, height); // Lineas verticales cambiando la coordenada X
}
for (int y=0; y <= height; y+=10) {
// Verde oscuro para las lineas, las multiplos de 50 les indicamos con color más claro
stroke(0, (y%50 == 0 ? 80 : 60), 0);
line(0, y, width, y); // Lineas horizontales cambiando la coordenada Y
}
// Pintar la forma de onda de las dos señales
stroke(255, 255, 255);
fill(255, 255, 255);
text("Antes del filtro", 10, 50);
text(String.format("Freq: %.0f Hz",oscil.frequency.getLastValue()), 100,50);
visualizador.onda(0, 100, 50); // Pintar la forma de onda de la señal original
text("Después del filtro", 10, 250);
visualizador.onda(0, 300, 50, out.mix); // Pintar la forma de onda de la señal a la salida
}
Descarga en este link el código fuente completo del artículo.
Enlaces relacionados
Documentación
http://courses.cs.washington.edu/courses/cse490s/11au/Readings/SynthesisChapt3.pdf
http://mosaic.uoc.edu/2012/10/01/audio-en-processing/
https://www.researchgate.net/profile/Ge_Wang9/publication/265164952_A_History_of_Programming_and_Music/links/540213e70cf2c48563af8672/A-History-of-Programming-and-Music.pdf