Crea tu propio theremin virtual

Aunque había escuchado este instrumento en multitud de ocasiones, la primera vez que lo vi fue el año pasado en un capítulo de “la teoría del Big Bang” en el que Sheldom intentaba tocar la melodía de Star Trek con un theremin. Me llamó mucho la forma que tenía este instrumento y la posibilidad de cambiar de notas sin tocarlo físicamente.

A partir de aquí empecé a “investigar sobre este instrumento” buscando videos y encontré algunos interpretes que tocaban el theremin de manera profesional como Barbara Buchholz o Carolina Eyck y otros videos más amateurs pero sensacionales como este y este.

Utilizando esta inspiración, en este post vamos a realizar una tarea práctica que consistirá en crear un theremin virtual utilizando Java. Para ello, vamos a explicar algunos principios del sonido y obtención matemática de las notas musicales, parámetros a tener en cuenta en un muestreo, reproducción de sonido utilizando Java y finalmente ofreceremos un código ejemplo de un theremin virtual.

El theremin

Este instrumento fue creado en 1919 por Lev Serguéievich Termen, físico y músico ruso. A groso modo, se basa en un oscilador que emite un tono y un amplificador de la señal permitiendo subir o bajar el volumen del sonido emitido. Para la obtención del tono se utiliza una antena que mediante ondas de radio se regula dependiendo de la distancia entre esta y el cuerpo interprete.

Este instrumento fue muy utilizado en los años 40-50 y se puede escuchar en diferentes bandas sonoras de películas y series de la época. El sonido de un theremin es bastante característico de películas de terror y extraterrestres que invaden la tierra. Su sonido es una mezcla entre violín y voz humana.

Principios de sonido

Para la realización de esta práctica vamos a comentar algunos principios del sonido. El sonido se produce cuando hay una variación en la presión dentro de un medio. Esta variación es audible cuando el movimiento se produce dentro de un rango de velocidad determinada. Por ejemplo, una señal continua, aunque existe “presión”, no es audible por no existir fluctuaciones. Por ello, cuando se muestrea y se visualiza un sonido suele ser una señal oscilante.

Cuando esta oscilación se realiza a una frecuencia determinada constante, no solo se emite un sonido, si no que se emite un tono. La variación de la frecuencia de la señal emitida provoca la modificación del tono. Para la creación de notas musicales se han estipulado frecuencias concretas siendo la referencia la nota LA (A) pudiendose obtener a partir de la siguiente fórmula 55Hz * 2^octava: 55Hz, 110Hz, 220Hz, 440Hz, 880Hz, etc (contando las octavas en base 0).

El resto de notas siguen una función exponencial con la fórmula frecuencia = 55Hz * (2^(octava+(nota/12))), igualmente asumiendo que la numeración de las octavas y notas están en base 0, es decir la octava 1 es la 0 y la nota A es la 0. Como anécdota, la norma que regula la frecuencia del LA4 (nota de referencia) (440Hz) se documenta en la ISO 16 ya que la frecuencia de la nota referencia LA no siempre estuvo en la misma frecuencia hasta que se estandarizó.

Muestreo del sonido

En cuanto al muestreo del sonido hay que tener en cuenta diferentes parámetros de configuración. Uno de estos parámetros es la cantidad de canales que deseamos muestrear concurrentemente. Normalmente suelen utilizarse dos modos: 1 canal (mono) y 2 canales (estéreo).

Otro parámetro a configurar es el número de bits que representa la precisión de cada muestra En la actualidad lo habitual es 8bits, 16bits y 32bits. Finalmente, la frecuencia de muestreo representa cada cuanto tiempo tomaremos una muestra. Lo habitual es 11.025Hz, 22.050Hz y 44.100Hz.

Para hacer nuestro theremin utilizaremos una configuración de mono (solo 1 canal), 8bits que se reflejara en el tipo de variable a utilizar (byte) y 22.000Hz que afectará a la cantidad de bytes que introduciremos en el buffer de salida en 1 milisegundo.

Emisión de sonidos en Java

Esta práctica la vamos a realizar utilizando la plataforma Java. En el API existe un paquete especial para la reproducción de ficheros multimedia como wav o midi. Sin embargo, esto no es exactamente lo que necesitamos ya que el sonido generado lo creamos directamente, por lo que deberemos crear nosotros mismos un oscilador que genere una señal sinusoidal en tiempo real.

Para emitir sonidos hay que abrir un buffer de salida al dispositivo de audio y enviar vectores de bytes que se ejecutarán según la configuración determinada. Veamos un ejemplo para emitir un tono a 440Hz – Nota LA (A)

import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.SourceDataLine;
public class Ejemplo1 {
    public static void main(String arg[]){
        SourceDataLine line =null;
        try{
            byte b[]=new byte[22000]; //Buffer correspondiente a 1 segundo
            for (int n=0;n < b .length;n++){ 
                //Genera una nota de 1 segundo con frecuencia LA 440. 
                //22000 es la frecuencia de muestreo del sonido
                //127 es la amplitud máxima de un byte [-127,127]. Obviamos el 128 para hacerlo más fácil
                b[n]=(byte)(Math.sin(440*n*Math.PI*2/22000)*127);
            }
            //Abre el dispositivo de salida
            AudioFormat af = new AudioFormat(22000, 8, 1, true, true);
            line = AudioSystem.getSourceDataLine(af);
            line.open(af, 22000);
            line.start();
            //Vuelca la señal
            line.write(b, 0, b.length);
            //Finaliza a que se emita todo el sonido
            line.drain(); 
        }catch(Exception e){
            e.printStackTrace();
        }finally{
            //Cierra el canal
            if (line!=null){
                line.close();
            }
        }
    }
}

¿Cómo será nuestro theremin virtual?

Nuestro theremin virtual será una ventana en el que se interactuará únicamente con el ratón. Subiendo o bajando el ratón podremos subir o bajar el volumen del sonido. Para poder subir y bajar este sonido de manera más rápida, no es preciso desplazarse por toda la ventana. Se han dibujado una franjas rojas donde se realizará la variación del sonido. Desplazar el ratón fuera de las líneas rojas establecerás el volumen del theremin la máximo o mínimo según esté en la parte superior o inferior respectivamente.

Igualmente, moviendo el ratón horizontalmente, podremos modificar la frecuencia de la señal. Para que sea más fácil acertar con las notas, se ha dibujado la posición de la escala diatónica (notas no sostenidas) con líneas negras verticales para no tener que acertar la nota a oído, algo realmente complicado. Finalmente hemos añadido algunas funcionalidades más que explicaremos en los siguientes puntos.

La forma de la señal

En el anterior ejemplo hemos emitido un tono obtenido a partir de una señal sinusoidal (función seno Math.sin). El sonido emitido con esta función es el similar al tono telefónico. Sin embargo, no todas las señales que tienen frecuencias deben ser sinusoidales. Existen señales cuadradas, en sierra, etc. Cuanto más brusca o más fluctuaciones tiene una señal, más “agresivo” es el sonido que genera. Por ejemplo, esa sería la diferencia de sonido entre una guitarra acústica y una guitarra eléctrica con una distorsión.

El sonido del theremin no es exactamente una señal sinusoidal ya que parece un sonido mezcla entre voz humana y violín. Sin embargo, para facilitar el algoritmo vamos a crear una señal sinusoidal y se va a permitir que, pulsando el botón derecho, se pueda cambiar la forma de la onda. Para esta labor almacenaremos la señal sinusoidal en un vector que podrá ser modificado por el usuario haciendo clic derecho sobre la ventana.

En el momento de la reproducción, a partir del ángulo correspondiente de la señal sinusoidal, en vez de aplicar la función seno, el valor se podrá sacar del vector editado por el usuario obteniendo el indice utilizando una sencilla regla de tres: posición = angulo*tamañoVector/(2*PI) teniendo como premisas que el ángulo esté expresado en radianes y no pueda pasar de 2*PI.

Vibrator

En videos de interpretes más profesionales, son capaces de crear una vibración del sonido. Como en previos ensayos con este theremin virtual, comprobé que era relativamente dificultoso mover el ratón con esa velocidad, se ha añadido una función adicional llamada vibración que, pulsando el botón izquierdo del ratón, se emitirá el sonido oscilando en la amplitud de la señal (vibrator).

Autoajuste de frecuencia

Finalmente, si ves complicado acertar con la nota exacta, no te preocupes. Hemos añadido una funcionalidad que haciendo doble clic se activará/desactivará una variable llamada autoajuste que hará que tome la frecuencia exacta de una nota si estamos muy cerca del ratón de esa frecuencia.

Buffer dinámico de salida

Probando en diferentes ordenadores, utilizando una frecuencia de 22.000Hz, no en todos funcionaba bien cuando se introducían 22 valores por milisegundo. Es algo complicado tener una aplicación a tiempo real cuando se trabaja en precisiones de microsegundos ya que las propias instrucciones gastan estos microsegundos. Por ello, aunque se especifique un sleep de 1 milisegundo, no todas las iteraciones tardan 1 milisegundo exactamente.

El efecto que se produce es que el tiempo de respuesta de interacción al mover el ratón es más lento cuando se introducen más elementos al buffer que los que puede reproducir. Pero también, en el caso de introducir menos bytes que los que debe reproducirse en esa iteración, se pueden apreciar chasquidos o clips al existir un cambio brusco de la señal a valor 0 por quedarse sin información en el buffer.

Por ello, hemos creado una variable bufferajuste que inserte más o menos información en el buffer según cuanta información se haya quedado pendiente al acabar la iteración (1 milisegundo). Avisar que esta funcionalidad podría no ir bien dependiendo de las características de tu ordenador. En este caso habría que depurarlo algo más modificando los criterios de introducir más o menos datos en el buffer.

Código fuente del theremin virtual

Finalmente, aquí tenéis el código fuente para que podáis ejecutarlo y jugar con este así como modificarlo si queréis.

import java.awt.Color;
import java.awt.Graphics;
import java.awt.event.*;
import javax.sound.sampled.*;
import javax.swing.JFrame;
public class Theremin extends JFrame implements Runnable{
    private int frecuencia=440;  // Frecuencia Hz del sonido 
    private double volumen=0;    // Volumen del sonido [0...1] 
    private boolean bSeguir=true;  //Finalizar la iteración del hilo
    private byte onda[]=new byte[ 100 ];   //Forma de la onda
    private int escala=2;  //Escala de visualización
    private SourceDataLine line=null;   //Salida del audio
    private boolean vibracion=false;   //Activa la vibración
    private boolean autoajuste=false;  //Activa el autoajuste - entonar la nota cuando está cerca.
    private double escalaDiatonica[]=new double[12*6]; 
    public Theremin(){
        //Inicialmente genera una onda sinusoidal
        for (int n=0;n < 100;n++){
            onda[n]=(byte)(Math.sin(n*Math.PI*2/100)*127);
        }
        //Calcula las lineas a visualizar de la escala diatonica  
        calcularEscalaDiatonica();
        //Atributos de la ventana
        setVisible(true);
        setSize(800,200);
        setResizable(false);
        setTitle("Theremin Virtual. by Jorge Rubira");
        //Eventos del mouse
        this.addMouseListener(new MouseListener() {
            public void mousePressed(MouseEvent e) {
                //Al pulsar el boton 
                if ((e.getButton() & MouseEvent.BUTTON2) > 0){
                    int n=e.getX()*100/getWidth(); //Obtiene la posición del vector 
                    int t=-(e.getY()-100)*135/80; //Obtiene la amplitud clickeada.
                    int factor=1; //Determina si tiene que subir o bajar el valor según si clickea por arriba o por abajo
                    if (onda[n]>t){
                        factor=-1;
                    }
                    //Para que no dé chasquidos modifica la posición seleccionada y los 20 más cercanos (con una proporcion al centro).
                    for (int z=0;z < 10;z++){
                        int z2=(n+z)%onda.length;
                        onda[z2]=(byte)limite(onda[z2]+(10-z)*factor*2,-127,127);
                        if (z>0){
                            z2=n-z;
                            if (z2 < 0){
                                z2+=onda.length;
                            }
                            onda[z2]=(byte)limite(onda[z2]+(10-z)*factor*2,-127,127);
                        }
                    }
                }else if ((e.getButton() & MouseEvent.BUTTON1) > 0){
                    //Activa la vibración
                    vibracion=true;
                    if (e.getClickCount()==2){
                        //El doble click activa/desactiva el autoajuste de la nota
                        autoajuste=!autoajuste;
                    }
                }
                repaint();
            }
            public void mouseReleased(MouseEvent e) {
                if ((e.getButton() & MouseEvent.BUTTON1) > 0){
                    //Desactiva la vibración
                    vibracion=false;
                }
                repaint();
            }
            public void mouseExited(MouseEvent e) {}
            public void mouseEntered(MouseEvent e) {}
            public void mouseClicked(MouseEvent e) {}
        });
        this.addMouseMotionListener(new MouseMotionListener() {
            public void mouseMoved(MouseEvent e) {
                volumen=limite((200-e.getY())-75,0,50)/50.0;
                frecuencia=autoajuste(e.getX())*escala+100;
            }
            public void mouseDragged(MouseEvent e) {mouseMoved(e);}
        });
        this.addWindowListener(new WindowListener() {
            public void windowClosing(WindowEvent e) {
                //Finaliza la aplicación cerrando el dispositivo del audio.
                bSeguir=false;
                    line.drain();
                    line.close();
                    System.exit(0);
            }
            public void windowOpened(WindowEvent e) {}
            public void windowIconified(WindowEvent e) {}
            public void windowDeiconified(WindowEvent e) {}
            public void windowDeactivated(WindowEvent e) {}
            public void windowClosed(WindowEvent e) {}
            public void windowActivated(WindowEvent e) {}
        });
        try{
            //Abre el canal del audio
            AudioFormat af = new AudioFormat(22000, 8, 1, true, true);
                line = AudioSystem.getSourceDataLine(af);
            line.open(af, 22000);           
            line.start();
            new Thread(this).start();
        }catch(Exception e){
            e.printStackTrace();
        }
    }
    public void update(Graphics g){
        paint(g);
    }
    public void paint(Graphics g){
        //Limpia la ventana
        g.clearRect(0, 0, getWidth(), 200);
        //Pinta la señal sinusoidal (o la editada)  
        g.setColor(Color.blue);
        for (int n=0;n < getWidth ();n++){
            g.drawLine(n, 100-onda[n*100/getWidth()]*80/135, n, 100-onda[n*100/getWidth()]*80/135);
        }
        //Pinta las franjas rojas límites de variación de amplitud
        g.setColor(Color.red);
        g.drawLine(0, 75, getWidth(), 75);
        g.drawLine(0, 125, getWidth(), 125);
        //Pinta las notas de la escala diatónica
        g.setColor(Color.black);
        for (int not=0;not < escalaDiatonica.length;not++){
            int nX=(int)escalaDiatonica[not];
            g.drawLine(nX, 0, nX, 200);
            g.drawString(""+(char)(not%12+(byte)'A'), nX, 190);
        }
        //Pinta mensajes de estados (vibración y autoajuste)
        if (vibracion){
            g.drawString("Vibracion", 700, 40);
        }
        if (autoajuste){
            g.drawString("Autoajuste", 700, 60);
        }
    }
    public int autoajuste(int x){
        if (!autoajuste) return x;
        //Si el ratón está cerca de la nota, emite la nota exacta
        for (int not=0;not < escalaDiatonica.length;not++){
            if (x>escalaDiatonica[not]-11 && x < escalaDiatonica[not]+11){
                return (int)escalaDiatonica[not];
            }
        }
        return x;
    }
    
    public void calcularEscalaDiatonica(){
        //Calcula la escala diatónica
        double ed[]={0, 2, 3, 5, 7, 8, 10};
        for (int not=0;not < ed.length;not++){
            ed[not]=Math.pow(2, ed[not]/12);
        }
        for (int oct=0;oct < 6;oct++){
            for (int not=0;not < ed.length;not++){
                int nX=((int)(110*Math.pow(2, oct)*ed[not])-100)/escala;
                escalaDiatonica[oct*12+not]=nX;
            }
        }
    }
    
    @Override
    public void run() {
        double ang=0;  //Angulo de la señal de audio en cada momento
        byte b[]=new byte[ 100 ];  //Tamaño máximo del buffer
        //Al utilizar 22000 Hz inicialmente se muestrará con 22 muestras por milisegundo.  
        int bufferAjuste=22;
        double ang2=0;
        //Ejecuta hasta finalizar la aplicación
        while(bSeguir){
            for (int z=0;z < bufferAjuste;z++){
                //Calculo del angulo según la frecuencia seleccionada con el ratón
                ang+=Math.PI*2*frecuencia/22000;
                while(ang>Math.PI*2){
                    ang-=Math.PI*2; 
                }
                //Obtiene el valor de la onda
                b[z]=(byte)(onda[(int)((ang*100/(Math.PI*2)))%onda.length]*volumen);
                //Si hay vibración multiplica la señal por otra señal sinusoidal con rango [0.5-1]
                if (vibracion){
                    b[z]=(byte)limite(b[z]*(Math.cos(ang2)/2+1),-127,127); 
                    ang2+=0.0025;
                    if (ang2>Math.PI*2){
                        ang2-=Math.PI*2;
                    }
                }else{
                    //Si no hay vibración resetea la señal sinusoidal
                    ang2=0;
                }
            }
            //Vuelca los valores al buffer
            line.write(b, 0, bufferAjuste);
            //Espera 1 milisegundo
                try{
                    Thread.sleep(1);
                }catch(Exception e){}
    
                //Variación de las muestras a volcar por milisegundo
                int nDes=0;
                if (line.getBufferSize()-line.available() < 80){
                    nDes=1;
                }
                if (line.getBufferSize()-line.available()>3000){
                    nDes=-1;
                }
                bufferAjuste+=nDes;
                if (bufferAjuste>100){
                    bufferAjuste=100;
                }
        }
    }
    
    //Funciones de delimitación de rangos
    public int limite(int valor, int min, int max){
        return Math.min(Math.max(valor,min),max); 
    }
    public int limite(double valor, int min, int max){
        return Math.min(Math.max((int)valor,min),max);  
    }
    //Inicio de la aplicación
    public static void main(String arg[]){
        new Theremin();
    }
}

Portada de Genbeta