El siguiente esquema resume las principales modificaciones que llevo hechas hasta el momento sobre Minim. Los módulos doblemente recuadrados son clases nuevas, y las conexiones entre los módulos representan métodos, la mayoría de ellos, nuevos, y otros que existían previamente pero sufrieron modificaciones. Los módulos representados en verde pertenecen a la clase AudioSignal, los azules a Oscillator, y los rojos a AudioEffect. “Adder” es una especie de clase “comodín”, ya veremos por qué. He de decir que no me preocupé mucho por la “coherencia abstracta” del modelo, sino que se parece más bien a una especie de “emparchamiento”, al mejor estilo Windows.
Antes de entrar en el análisis en detalle de cada módulo, escuchemos un ejemplo de sintetizador hecho con esta versión de Minim. Ejemplo #0
ADSR es lo que se conoce en los sintetizadores como generador de envolvente, es decir un módulo que genera una señal de control secuencial de cuatro “fases” (attack, decay, sustain y release), aplicable a distintos parámetros, pero fundamentalmente a la amplitud de una señal. (Nota: la clase “ADSR” ya existía, aunque no estaba disponible; como mi código era bastante distinto, resolví no modificar la clase existente sino crear una nueva, a la que llamé inicialmente ADSR2; luego invertí los nombres y la clase original pasó a llamarse ADSR2).
Al instanciar un objeto de la clase ADSR es necesario pasarle un objeto de la clase Gate como parámetro. Gate es una clase muy simple, que consta de dos métodos: start() y stop(), para indicarle a ADSR cuándo debe empezar el attack y cuándo el release (respectivamente). La razón para tener Gate separada de ADSR, es contemplar la posibilidad de que varias envolventes sean disparadas simultáneamente por una misma señal de control, como ocurre habitualmente en los sintetizadores.
Veamos un par de ejemplos del uso de Gate y ADSR:
Ejemplo #1 – Sinusoide con Envolvente
int sr = 44100; int bd = 16; // sr = frecuencia de muestreo; bd = resolución minim = new Minim (this); // objeto Minim out = minim.getLineOut (Minim.MONO, 1024, sr, bd); // salida de audio out1 = new Adder(); // objeto Adder cuya frecuencia es modificada según la nota out.addSignal (out1); // agregamos el Adder a la salida gate = new Gate(); // objeto Gate que es disparado con el mouse click ADSR env = new ADSR (50, 50, .7, 50, sr, gate); // nueva envolvente; attack=50ms, decay=50ms, sustain=70%, release=50ms, controlada por 'gate' SineWave sine = new SineWave (261.63, 1, sr); // nueva sinusoide; frecuencia=261.63Hz, amplitud=1 sine.setEnvelope (env); // agregamos la envolvente a la sinusoide out1.addSignal (sine, Oscillator.RELATIVE); // agregamos la sinusoide al Adder, de modo que su frecuencia sea modificable por éste
Ejemplo #2 – Dos señales con distinta envolvente, mismo Gate
// mismas 6 líneas que el ejemplo anterior (y todos los que siguen) ADSR env1 = new ADSR (5, 600, .5, 10, sr, gate); // nueva envolvente; attack=5ms, decay=600ms, sustain=50%, release=10ms, controlada por 'gate' TriangleWave tri = new TriangleWave (261.63, .9, sr); // nueva onda triangular; frecuencia=261.63Hz, amplitud=0.9 tri.setEnvelope (env1); // agregamos la envolvente 1 a la onda triangular out1.addSignal (tri, Oscillator.RELATIVE); // agregamos la onda triangular al Adder, de modo que su frecuencia sea modificable por éste ADSR env2 = new ADSR (1200, 1200, .25, 1200, sr, gate); // nueva envolvente; attack=1200ms, decay=1200ms, sustain=25%, release=1200ms, controlada por 'gate' SawWave saw = new SawWave (392.45, .1, sr); // nueva 'dientes de sierra'; frecuencia=392.45Hz, amplitud=0.1 saw.setEnvelope (env2); // agregamos la envolvente 2 a la 'dientes de sierra' out1.addSignal (saw, Oscillator.RELATIVE); // agregamos la 'dientes de sierra' al Adder, de modo que su frecuencia sea modificable por éste
ADSR también dispone del método setAmp para cambiar el nivel de pico de toda la envolvente, que por defecto es igual a 1.
La clase Oscillator ya incluía un esbozo de los métodos setAmplitudeModulator y setFrequencyModulator, pero no eran funcionales. Hubo que modificar estos dos métodos para hacerlos utilizables, y agregar dos métodos más: setEnvelope y setPWM.
Todos estos métodos aceptan como parámetro cualquier objeto de una clase que implemente AudioSignal, es decir, cualquier generador de señal, ya sea sinusoide, diente de sierra, ruido blanco, etc., hasta la propia ADSR. (En una versión previa, setFrequencyModulator sólo aceptaba objetos de clases que extendieran Oscillator o Adder, enseguida veremos por qué.)
setFrequencyModulator posee dos modos de operación: ABSOLUTE y RELATIVE, pensados en principio con la idea de generar vibrato y síntesis FM, respectivamente. En el modo ABSOLUTE, la señal moduladora tiene su propia frecuencia y esta sólo puede modificarse con su propio método setFreq. En el modo RELATIVE, en cambio, al producirse un cambio en la frecuencia de la portadora, se actualiza automáticamente la frecuencia de la moduladora, de manera que la relación -el cociente- entre ambas se mantenga constante. Esto explica por qué setFrequencyModulator sólo aceptaba, inicialmente, objetos de la clase Oscillator o Adder: porque tenían que ser señales que tuvieran “frecuencia”. Posteriormente, para superar esta limitación, introduje el siguiente cambio: todos las clases que implementen AudioSignal tendrán que tener todos los métodos de Oscillator (setFreq, setAmp, frequency(), etc., incluidos los cuatro que estamos viendo) tengan éstos o no algún efecto sobre la señal generada. Gracias a este cambio, es posible usar cualquiera de los generadores de señal para controlar cualquier parámetro de otro.
La modulación de frecuencia está implementada como modulación de fase, de modo que el índice de modulación (I) está compensado con la frecuencia moduladora: cuanto más alta es esta última, más alto es I. Concretamente, la fórmula que se utiliza para calcular la desviación de frecuencia es, por ejemplo, para el caso de dos sinusoides:
y = Ac * sin (2*PI*fc*t/sr + Am * fm/1000 * sin (2*PI*fm*t/sr)) [c = portadora; m = moduladora; sr = frec. de muestreo]
Talvez alguien se pregunte: ¿por qué /1000? Bien, se trata de un valor arbitrario, tomado simplemente para que la profundidad de modulación quede en niveles “esperables”, para niveles “esperables” de señal moduladora; algo totalmente subjetivo y sin importancia.
setAmplitudeModulator y setEnvelope tienen básicamente el mismo efecto: multiplicar portadora y moduladora. La sutil diferencia entre ambos, es que setAmplitudeModulator agrega un 50% de nivel de continua (DC bias) a la señal moduladora, como una forma sencilla y rápida de producir trémolo. Además, gracias a la existencia de estos dos métodos, es muy fácil producir trémolo y envolvente de amplitud simultáneamente.
setPWM produce una modulación del ancho del pulso, únicamente en los osciladores del tipo PulseWave (y debo reconocer que la forma de implementarlo fue bastante “chancha”, pero bueh… funciona).
Veamos ahora algunos ejemplos de todo esto:
Ejemplo #3 – Sinusoide con Vibrato
ADSR env = new ADSR (50, 50, .7, 50, sr, gate); // nueva envolvente; attack=50ms, decay=50ms, sustain=70%, release=50ms, controlada por 'gate' SineWave sine = new SineWave (261.63, 1.3, sr); // nueva sinusoide; frecuencia=261.63Hz, amplitud=1.3 sine.setEnvelope (env); // agregamos la envolvente a la onda cuadrada SineWave lfo = new SineWave (5, .3, sr); // nueva sinusoide; frecuencia=5.0Hz, amplitud=0.3 sine.setAmplitudeModulator (lfo); // agregamos el lfo a la sinusoide como modulador de amplitud out1.addSignal (sine, Oscillator.RELATIVE); // agregamos la sinusoide al Adder, de modo que su frecuencia sea modificable por éste
Ejemplo #4 – Sinusoide con Trémolo
ADSR env = new ADSR (50, 50, .7, 50, sr, gate); // nueva envolvente; attack=50ms, decay=50ms, sustain=70%, release=50ms, controlada por 'gate' SineWave sine = new SineWave (261.63, 1.3, sr); // nueva sinusoide; frecuencia=261.63Hz, amplitud=1.3 sine.setEnvelope (env); // agregamos la envolvente a la onda cuadrada SineWave lfo = new SineWave (5, .3, sr); // nueva sinusoide; frecuencia=5.0Hz, amplitud=0.3 sine.setAmplitudeModulator (lfo); // agregamos el lfo a la onda cuadrada como modulador de amplitud out1.addSignal (sine, Oscillator.RELATIVE); // agregamos la onda cuadrada al Adder, de modo que su frecuencia sea modificable por éste
Ejemplo #5 – Síntesis FM simple
ADSR env1 = new ADSR (4, 150, .7, 200, sr, gate); // definimos la envolvente 1; ADSR env2 = new ADSR (850, 250, .25, 150, sr, gate); // definimos la envolvente 2; SineWave crr = new SineWave (261.63, .9, sr); // definimos la portadora, sinusoidal crr.setEnvelope (env1); // agregamos la envolvente 1 a la portadora TriangleWave mod = new TriangleWave (915.71, .03, sr); // definimos la moduladora, triangular; frecuencia = 3.5*fc mod.setEnvelope (env2); // agregamos la envolvente 2 a la moduladora crr.setFrequencyModulator (mod, Oscillator.RELATIVE); // agregamos la moduladora a la portadora como modulador de frecuencia relativo out1.addSignal (crr, Oscillator.RELATIVE); // agregamos la portadora al Adder, etc.
Ejemplo #6 – AM, Envolvente de frecuencia, PWM
ADSR env1 = new ADSR (4, 150, .7, 200, sr, gate); // definimos la envolvente 1; ADSR env2 = new ADSR (850, 250, .25, 150, sr, gate); // definimos la envolvente 2; env2.setAmp (2); // amplificamos la envolvente 2 ADSR env3 = new ADSR (1450, 70, .35, 10, sr, gate); // definimos la envolvente 3; PulseWave pulse = new PulseWave (261.63, .7, sr); // generador de pulsos SineWave lfo = new SineWave (.2, .5, sr); // oscilador de baja frecuencia SineWave sine = new SineWave (300, .5, sr); // sinusoide para hacer AM pulse.setEnvelope (env1); // agregamos la envolvente 1 al generador de pulsos pulse.setPWM (lfo); // utilizamos el lfo para hacer PWM pulse.setFrequencyModulator (env2); // utilizamos la envolvente 2 como envolvente de frecuencia sine.setEnvelope (env3); // asignamos la envolvente 3 a la sinusoide pulse.setAmplitudeModulator (sine); // agregamos la sinusoide como modulador de amplitud de los pulsos out1.addSignal (pulse, Oscillator.RELATIVE); // agregamos los pulsos al Adder, etc.
En todos los ejemplos vistos hasta el momento, nos cuidamos de no introducir una misma señal en más de un módulo a la vez, con la única excepción de los objetos Gate. Cabe entonces preguntarse: ¿es posible, por ejemplo, controlar dos o más osciladores distintos con una misma envolvente o con un mismo modulador? Bueno, lo cierto es que, si bien nada nos impide hacerlo a nivel del código, el resultado no será el esperado. Debido a la manera misma en que funcionan los generadores de señal en Minim, al hacer esto aparecerán saltos, discontinuidades, e imprevistos cambios de frecuencia en la forma de onda resultante.
Para superar este inconveniente, implementé otra solución “ad hoc”: la clase SignalClone. SignalClone es una clase que implementa AudioSignal y AudioListener, y puede “duplicar” la señal proveniente de otro AudioSignal. A diferencia de la señal original, la señal producida por SignalClone sí puede insertarse en varios módulos simultáneamente, por lo que nunca será necesario hacer más que un único clon de cada señal, en el peor de los casos. Es importante, no obstante, que la señal original sea también aplicada a algo, de lo contrario SignalClone no la duplicará. En caso de llamar a cualquier método como setAmp, setFreq, setEnvelope, etc. en la señal clónica, SignalClone llamará al método correspondiente en el objeto original, pasándole los parámetros.
(Nota: una operación que no podemos hacer en Minim 2.02 PE, pero que no se trata de un error, es crear estructuras recursivas, por ejemplo un oscilador que sea su propio modulador, o una cadena de osciladores en la que cada uno module al siguiente y el último module al primero, etc.)
Ejemplo #7 – 3 señales con la misma envolvente y el mismo modulador
ADSR env1 = new ADSR (4, 150, .7, 400, sr, gate); // definimos la envolvente 1; ADSR env2 = new ADSR (850, 450, .11, 150, sr, gate); // definimos la envolvente 2; SineWave cr1 = new SineWave (261.63, .5, sr); // definimos la portadora 1 SineWave cr2 = new SineWave (523.26, .3, sr); // definimos la portadora 2 (octava de cr1) SineWave cr3 = new SineWave (1046.52, .2, sr); // definimos la portadora 3 (octava de cr2) cr1.setPan(.3); cr2.setPan(-.8); cr3.setPan(.9); // paneamos las 3 señales SignalClone env11 = new SignalClone(env1); // clonamos la envolvente 1 cr1.setEnvelope (env1); // asignamos la envolvente 1 a la portadora 1 cr2.setEnvelope (env11); // asignamos el clon de la envolvente 1 a la portadora 2 cr3.setEnvelope (env11); // asignamos el clon de la envolvente 1 a la portadora 3 SineWave mod = new SineWave (287.79, .11, sr); // definimos la moduladora, sinusoidal; frecuencia = 1.1*fc SignalClone mod1 = new SignalClone (mod); // clonamos la moduladora mod1.setEnvelope (env2); // asignamos la envolvente 2 a la moduladora cr1.setFrequencyModulator (mod, Oscillator.RELATIVE); // agregamos la moduladora a la portadora como modulador de frecuencia relativo cr2.setFrequencyModulator (mod1, Oscillator.RELATIVE); // agregamos el clon de la moduladora a la portadora como modulador de frecuencia relativo cr3.setFrequencyModulator (mod1, Oscillator.RELATIVE); // agregamos el clon de la moduladora a la portadora como modulador de frecuencia relativo SineWave lfo = new SineWave (5, .02, sr); // definimos un oscilador de baja frecuencia (lfo) SignalClone env22 = new SignalClone (env2); // clonamos la envolvente 2 lfo.setEnvelope (env22); // asignamos el clon de la envolvente 2 al lfo mod1.setFrequencyModulator (lfo, Oscillator.ABSOLUTE); // agregamos el lfo a la moduladora como modulador de frecuencia absoluto out1.addSignal (cr1, Oscillator.RELATIVE); // agregamos la portadora al Adder, etc. out1.addSignal (cr2, Oscillator.RELATIVE); // agregamos la portadora al Adder, etc. out1.addSignal (cr3, Oscillator.RELATIVE); // agregamos la portadora al Adder, etc.
Como dije antes, muchas de estas funciones deberán ser reformuladas, dado que yo las programé tratando de modificar lo menos posible la estructura actual de la biblioteca, cuyas clases no fueron pensadas originalmente para usar de esta manera. De hecho, el error que dio origen a SignalClone lo descubrí en el momento en que estaba haciendo los ejemplos para este artículo, lo que explica su ausencia en el gráfico de arriba. Es una solución “sacada de la manga”, pero funciona, y lo bueno es que no sólo resuelve este problema, sino que además reduce el consumo de cómputo asociado con este tipo de configuraciones.
La clase Adder, como dijimos más arriba, funciona como una especie de “comodín”. Por un lado implementa AudioSignal, por lo que puede utilizarse como señal de control con todos los métodos anteriores, incluso con setFrequencyModulator. Por otro lado, al ser una AudioSignal, dispone de todos los métodos de Oscillator, incluyendo los cuatro recién vistos.
Adder se comporta como un Oscillator, sólo que en lugar de tener él mismo una frecuencia, una amplitud, etc., simplemente le trasmite estos controles a cada uno de los componentes que lo forman. Estos componentes son a su vez otras AudioSignals, y para agregarlas utilizamos el método addSignal. Al agregar un señal con AddSignal, podemos especificar si es ABSOLUTE o RELATIVE, lo cual dotará a la señal de la propiedad de ignorar o atender a los cambios y modulaciones de frecuencia, respectivamente, que se trasmitan a todo el objeto Adder. Si utilizamos AddSignal sin especificar esta opción, la señal se comportará como ABSOLUTE. Cuando se utiliza setFreq y el Adder consta de varias señales con frecuencias distintas, se toma como referencia la frecuencia de la señal[0] y todas las otras frecuencias se modifican manteniendo su proporción. (¿Qué ocurre si la señal[0] es ABSOLUTE, o si es un tipo de señal que no tiene “frecuencia”, como un ADSR por ejemplo? Buena pregunta; probablemente, nada bueno). SetAmp en un Adder tiene un efecto tal vez distinto al esperable: multiplica las amplitudes de cada componente por el valor proporcionado (vale decir que es, en cierto modo, “RELATIVE”).
La señal generada por un Adder, como habrán adivinado, es la suma de todas las señales que fueron agregadas a él, pero el asunto no termina ahí: Adder cuenta además con la posibilidad de agregar una cadena de efectos (objetos AudioEffect) a su salida, con el método addEffect. En resumen, Adder permite solucionar, de una forma talvez no muy ortodoxa, algunas configuraciones habituales en los sintetizadores: síntesis aditiva, suma de señales de control, producir vibrato en un sintetizador FM, etc., así como también realizar múltiples combinaciones libres y creativas, dándole a Minim una gran flexibilidad y modularidad, en lo que a síntesis se refiere.
Veamos algunos ejemplos del uso de Adder:
Ejemplo #8 – Sintetizador Aditivo
// creamos un array de 10 sinusoides, cada una con una envolvente distinta
SineWave[] armonico = new SineWave [10];
ADSR env[] = new ADSR [10];
for (int f=0; f<10; f=f+2) {
env[f] = new ADSR (int(random(1, 2000)), int(random(150,1100)), random(.05, 1/(f+1.0)), int(random(50,900)), sr, gate);
env[f+1] = new ADSR (int(random(1, 2000)), int(random(150,1100)), random(.05, 1/(f+1.5)), int(random(50,900)), sr, gate);
armonico[f] = new SineWave (pow(2,f), 1/(1+f*1.1), sr); armonico[f].setEnvelope (env[f]);
armonico[f+1] = new SineWave (1.5*pow(2,f), 1/(1.5+f*1.1), sr);
armonico[f+1].setEnvelope (env[f+1]);
}
Adder adder = new Adder (armonico); // al instanciar un Adder de esta forma, todos los componentes son RELATIVE
adder.setFreq (65.41); // esto tiene el efecto de setear la frecuencia de armonico[0], y todas las otras en relacion a ésta
adder.setAmp (1/adder.amplitude()); // esto tiene el efecto de normalizar la amplitud
out1.addSignal (adder, Oscillator.RELATIVE); // agregamos el Adder a la salida, etc.
Ejemplo #9 – Suma de varias señales modulando a una misma portadora
Adder adder = new Adder (armonico); // el mismo sintetizador aditivo del ejemplo anterior (ejemplo #8) adder.setFreq (130.82); // seteamos la frecuencia fundamental a 130.82Hz (1/2 fc) SineWave lfo = new SineWave (.4, .02, sr); // creamos un lfo sinusoidal de 0.2Hz adder.addSignal (lfo, Adder.ABSOLUTE); // agregamos el lfo al Adder pero en forma absoluta, es decir, independiente de la frecuencia de éste adder.setAmp (.3/adder.amplitude()); // esto tiene el efecto de normalizar la amplitud del Adder a 0.3 ADSR env1 = new ADSR (4, 150, .7, 200, sr, gate); // definimos la envolvente 1; ADSR env2 = new ADSR (1300, 150, .2, 30, sr, gate); // definimos la envolvente 2; ADSR env3 = new ADSR (300, 2450, .01, 30, sr, gate); // definimos la envolvente 3; env3.setAmp (.5); // reducimos la amplitud de la envolvente 3 SineWave crr = new SineWave (261.63, .9, sr); // definimos la portadora, sinusoidal crr.setEnvelope (env1); // asignamos la envolvente 1 a la portadora lfo.setEnvelope (env2); // asignamos la envolvente 2 al lfo lfo.setFrequencyModulator (env3); // asignamos la envolvente 3 a la frecuencia del lfo crr.setFrequencyModulator (adder, Oscillator.RELATIVE); // agregamos el Adder a la portadora como modulador de frecuencia relativo out1.addSignal (crr, Oscillator.RELATIVE); // agregamos la portadora a la salida, etc.
Ejemplo #10 – Suma de varias envolventes, lfos, ruido filtrado, etc.
ADSR env1 = new ADSR (4, 150, .7, 400, sr, gate); // definimos la envolvente 1; ADSR env2 = new ADSR (850, 450, .11, 150, sr, gate); // definimos la envolvente 2; ADSR env3 = new ADSR (1200, 120, .30, 50, sr, gate); // definimos la envolvente 3; env2.setAmp (-1.5); // invertimos y amplificamos la envolvente 2 Adder adder1 = new Adder(); // definimos un Adder y le sumamos las 3 envolventes adder1.addSignal (env1); adder1.addSignal (env2); adder1.addSignal (env3); SineWave lfo1 = new SineWave (.2, 1.7, sr); // definimos el lfo 1 SineWave lfo2 = new SineWave (.9, 1.1, sr); // definimos el lfo 2 Adder adder2 = new Adder(); // definimos un Adder y le sumamos los 2 lfos adder2.addSignal (lfo1); adder2.addSignal (lfo2); WhiteNoise wn = new WhiteNoise (1); // definimos un ruido blanco Adder adder3 = new Adder(); // definimos un Adder y le sumamos el ruido blanco adder3.addSignal (wn); BandPass bp = new BandPass (2000, 200, sr); // definimos un filtro pasabanda; frecuencia 2000Hz, ancho de banda 200Hz adder3.addEffect (bp); // agregamos el pasabanda al Adder que contenía el ruido blanco SineWave sine1 = new SineWave (261.63, .7, sr); // definimos 1a sinusoide 1 sine1.setEnvelope (adder1); // le asignamos el adder 1 como envolvente de amplitud sine1.setFrequencyModulator (adder2); // le agregamos el adder 2 como modulador de frecuencia absoluto sine1.setAmplitudeModulator (adder3); // le agregamos el adder 3 como modulador de amplitud SineWave sine2 = new SineWave (130.82, .7, sr); // definimos la sinusoide 2 SignalClone sine11 = new SignalClone (sine1); // clonamos la sinusoide 1 sine2.setEnvelope (sine11); // asignamos el clon de la sinusoide 1 como envolvente de la sinusoide 2 out1.addSignal (sine1, Oscillator.RELATIVE); // agregamos ambas sinusoides a la salida out1.addSignal (sine2, Oscillator.RELATIVE);
Nota: al agregar moduladores y/o envolventes a un Adder, debemos hacerlo con SignalClone, por las razones ya observadas. Para asegurarnos de que la señal duplicada por SignalClone también se utilice, talvez debamos recurrir a algún tipo de “truco”, como por ejemplo, aplicar esta señal a un setPWM de un oscilador cualquiera.
Siguente sección –> Nuevo generador: “BandLimitedPulse”