Compartir a través de


Tocar y listo

Instrumentos musicales para Windows Phone

Charles Petzold

Descargar el ejemplo de código

Charles PetzoldCada Windows Phone tiene un altavoz integrado y un conector para auriculares y seguramente sería una lástima si su uso se limitara solo a realizar llamadas telefónicas. Afortunadamente, las aplicaciones de Windows Phone también pueden usar las prestaciones de audio del teléfono para reproducir música u otros sonidos. Como he demostrado en entregas recientes de esta columna, una aplicación de Windows Phone puede reproducir archivos MP3 o WMA almacenados en la biblioteca de música del usuario o archivos descargados de Internet.

Una aplicación de Windows Phone también puede generar dinámicamente formas de onda de audio, una técnica llamada "transmisión de audio". Esta actividad usa gran cantidad de datos: para obtener un sonido con calidad de CD, es necesario generar muestras de 16 bits a una frecuencia de 44.100 muestras por segundo, tanto para el canal izquierdo como para el derecho, o la enorme cantidad de 176.400 bytes por segundo.

Pero la transmisión de audio es una técnica poderosa. Si la combina con multitoque, puede transformar el teléfono en un instrumento musical electrónico... ¿qué podría ser más entretenido que eso?

Concepción de un theremín

En la década de 1920, el inventor ruso León Thérémin creó uno de los primerísimos instrumentos musicales electrónicos. Para tocar un theremín, no se "toca" realmente el instrumento. En lugar de eso, las manos se mueven entre dos antenas, las que por separado controlan el volumen y el tono del sonido. El resultado es un temblor espeluznante que se desliza de nota en nota; es un sonido familiar en películas como "Spellbound" y "El día que la tierra se detuvo", la ocasional banda de rock y el episodio 12 de la cuarta temporada de "The Big Bang Theory". (Contrariamente a lo que se cree comúnmente, no se usó un theremín en el tema de "Star Trek").

¿Un Windows Phone puede convertirse en un theremín portátil? Ese era mi objetivo.

El theremín clásico genera sonido mediante una técnica heterodina en la que dos formas de ondas de alta frecuencia se combinan para producir un tono diferente en la gama de frecuencias acústicas. Pero esta técnica es poco práctica cuando las formas de onda se generan en software computacional. Tiene mucho más sentido generar directamente la forma de onda de audio.

Después de jugar un poco con la idea de usar la orientación del teléfono para controlar el sonido o para que el programa vea e interprete los movimientos de la mano a través de la cámara del teléfono, como ocurre con Kinect, me quedé con un enfoque mucho más prosaico: un dedo en la pantalla del teléfono es un punto de coordinación bidimensional, lo que permite que un programa use un eje para la frecuencia y el otro para la amplitud.

Hacer esto de manera inteligente requiere algo de conocimiento acerca de cómo percibimos los sonidos musicales.

Píxeles, tonos y amplitudes

Gracias al trabajo innovador de Ernst Webe y Gustav Fechner en el siglo XIX, sabemos que la percepción humana es más logarítmica que lineal. Los cambios lineales incrementales en la magnitud de los estímulos no se perciben de igual manera. Lo que sí percibe igual son los cambios proporcionales a la magnitud, a menudo expresados convenientemente como aumentos o disminuciones fraccionarios. (Este fenómeno va más allá de nuestros órganos sensoriales. Por ejemplo, creemos que la diferencia entre US$1 y US$2 es mucho mayor que la diferencia que existe entre US$100 y US$101).

Los seres humanos somos sensibles a las frecuencias de audio aproximadamente entre 20 HZ y 20.000 Hz, pero nuestra percepción de la frecuencia no es lineal. En muchas culturas, el tono musical se estructura en torno a la octava, que es el doble de una frecuencia. Cuando canta "Somewhere over the rainbow", las dos sílabas de la primera palabra son una octava aparte, independientemente de si la subida es de 100 Hz a 200 Hz o de 1.000 Hz a 2.000 Hz. Por lo tanto, el rango de la audición humana es de alrededor de 10 octavas.

Se le llama octava porque en la música occidental abarca ocho notas designadas con letras de una escala en las que la última nota es una octava más alta que la primera: A, B, C, D, E, F, G, A (que se denomina escala menor) o C, D, E, F, G, A, B, C (escala mayor).

Debido a la manera en que se derivan estas notas, no se perciben equitativamente distantes una de otra. Una escala en la que todas las notas están a igual distancia requiere cinco notas más para tener un total de 12 (sin contar dos veces la primera nota): C, C#, D, D#, E, F, F#, G, G#, A, A# y B. Cada uno de estos pasos se conoce como "semitono" y, si se espacian equitativamente (como se hace en el ajuste de temperamento constante común), cada nota tiene una frecuencia que corresponde a la duodécima raíz de dos (o alrededor de 1,059) de veces de la frecuencia de la nota inferior.

El semitono se puede seguir dividiendo en 100 céntimos. Hay 1.200 céntimos en la octava. El paso de multiplicación entre céntimos es la 1.200° raíz de dos, o 1,000578. La sensibilidad de los seres humanos frente a los cambios en la frecuencia varía mucho, pero generalmente se indica que es de alrededor de los cinco céntimos.

Estos antecedentes sobre la física y la matemática de la música son necesarios, porque el programa del theremín debe convertir la ubicación de píxeles de un dedo en una frecuencia. Esta conversión se debe hacer para que cada octava corresponda a un número equivalente de píxeles. Si decidimos que el theremín tendrá un rango de cuatro octavas correspondiente a la longitud de 800 píxeles de la pantalla de Windows Phone en modo horizontal, es decir, 200 píxeles por octava, o seis céntimos por píxel, que corresponde bien a los límites de la percepción humana.

La amplitud de una forma de onda determina cómo percibimos el volumen y esto también es logarítmico. Un decibel se define como 10 veces el logaritmo base 10 de la relación de dos niveles de intensidad. Como la intensidad de una forma de onda es el cuadrado de la amplitud, la diferencia del decibel entre dos amplitudes es:

El audio de un CD usa muestras de 16 bits, lo que permite que la relación entre la amplitud máxima y la amplitud mínima sea de 65.536. Tome el logaritmo base 10 de 65.536 y multiplíquelo por 20 para obtener un rango de 96 decibeles.

Un decibel es el aumento de la amplitud en alrededor de 12%. La percepción humana frente a los cambios en la amplitud es mucho menos sensible que a la frecuencia. Se requieren algunos decibeles antes de que la gente perciba un cambio en el volumen, para poder adaptarse en la dimensión de 480 píxeles de la pantalla de Windows Phone.

Cómo transformarlo en realidad

El código descargable para este artículo es una solución de Visual Studio llamada MusicalInstruments. El proyecto Petzold.MusicSynthesis es un biblioteca de vínculos dinámicos que incluye principalmente los archivos que analicé en la entrega de esta columna el mes pasado (msdn.microsoft.com/magazine/hh852599). El proyecto de la aplicación del theremín consta de una sola página horizontal.

¿Qué tipo de forma de onda debe generar un theremín? En teoría, es una onda senoidal, pero en realidad es una onda senoidal algo distorsionada y si intenta buscar esta pregunta en Internet, no encontrará mayor consenso. Para mi versión, me quedaré con una onda senoidal recta, lo que parece razonable.

Tal como aparece en la figura 1, el archivo MainPage.xaml.cs define varios valores constantes y calcula dos enteros que rigen la manera en que los píxeles de la pantalla corresponden a notas.

Figura 1 Cálculo de la amplitud y la frecuencia para el theremín

public partial class MainPage : PhoneApplicationPage
{
  static readonly Pitch MIN_PITCH = new Pitch(Note.C, 3);
  static readonly Pitch MAX_PITCH = new Pitch(Note.C, 7);
  static readonly double MIN_FREQ = MIN_PITCH.Frequency;
  static readonly double MAX_FREQ = MAX_PITCH.Frequency;
  static readonly double MIN_FREQ_LOG2 = Math.Log(MIN_FREQ) / Math.Log(2);
  static readonly double MAX_FREQ_LOG2 = Math.Log(MAX_FREQ) / Math.Log(2);
  ...
  double xStart;      // The X coordinate corresponding to MIN_PITCH
  int xDelta;         // The number of pixels per semitone
  void OnLoaded(object sender, EventArgs args)
  {
    int count = MAX_PITCH.MidiNumber - MIN_PITCH.MidiNumber;
    xDelta = (int)((ContentPanel.ActualWidth - 4) / count);
    xStart = (int)((ContentPanel.ActualWidth - count * xDelta) / 2);
    ...
  }
  ...
  double CalculateAmplitude(double y)
  {
    return Math.Min(1, Math.Pow(10, -4 * (1 - y / ContentPanel.ActualHeight)));
  }
  double CalculateFrequency(double x)
  {
    return Math.Pow(2, MIN_FREQ_LOG2 + (x - xStart) / xDelta / 12);
  }
  ...
}

El rango va desde la C bajo la C media (una frecuencia de alrededor de 130,8 Hz) hasta la C tres octavas sobre la C media, alrededor de 2.093 Hz. Dos métodos calculan una frecuencia y una amplitud relativa (que va de 0 a 1) basándose en las coordenadas de un punto de toque a partir del evento Touch.FrameReported. 

Si solo usa estos valores para controlar un oscilador de ondas senoidales, no sonaría para nada como un theremín. Cuando mueve el dedo por la pantalla, el programa no obtiene un evento para cada uno de los píxeles en todo el recorrido. En lugar de un deslizamiento de frecuencia fluido, lo que escucharía sería pasos muy diferenciados. Para solucionar este problema, creé una clase de oscilador especial, que aparece en la figura 2. Este oscilador hereda una propiedad Frequency, pero define tres propiedades más: Amplitude, DestinationAmplitude y DestinationFrequency. Mediante el uso de factores multiplicativos, el oscilador mismo brinda desplazamientos. El código no puede realmente prever qué tan rápido se mueve un dedo, pero en la mayoría de los casos parece funcionar de manera aceptable.

Figura 2 La clase ThereminOscillator

public class ThereminOscillator : Oscillator
{
  readonly double ampStep;
  readonly double freqStep;
  public const double MIN_AMPLITUDE = 0.0001;
  public ThereminOscillator(int sampleRate)
    : base(sampleRate)
  {
    ampStep = 1 + 0.12 * 1000 / sampleRate;     // ~1 db per msec
    freqStep = 1 + 0.005 * 1000 / sampleRate;   // ~10 cents per msec
  }
  public double Amplitude { set; get; }
  public double DestinationAmplitude { get; set; }
  public double DestinationFrequency { set; get; }
  public override short GetNextSample(double angle)
  {
    this.Frequency *= this.Frequency < this.DestinationFrequency ?
                                     freqStep : 1 / freqStep;
    this.Amplitude *= this.Amplitude < this.DestinationAmplitude ?
                                     ampStep : 1 / ampStep;
    this.Amplitude = Math.Max(MIN_AMPLITUDE, Math.Min(1, this.Amplitude));
    return (short)(short.MaxValue * this.Amplitude * Math.Sin(angle));
  }
}

La figura 3 muestra el controlador para el evento Touch.FrameReported en la clase MainPage. Cuando se toca por primera vez la pantalla, la propiedad Amplitude se define en un valor mínimo para que suba el volumen del sonido. Cuando se deja de tocar, el sonido se atenúa.

Figura 3 El controlador Touch.FrameReported Handler en theremín

void OnTouchFrameReported(object sender, TouchFrameEventArgs args)
{
  TouchPointCollection touchPoints = args.GetTouchPoints(ContentPanel);
  foreach (TouchPoint touchPoint in touchPoints)
  {
    Point pt = touchPoint.Position;
    int id = touchPoint.TouchDevice.Id;
    switch (touchPoint.Action)
    {
      case TouchAction.Down:
        oscillator.Amplitude = ThereminOscillator.MIN_AMPLITUDE;
        oscillator.DestinationAmplitude = CalculateAmplitude(pt.Y);
        oscillator.Frequency = CalculateFrequency(pt.X);
        oscillator.DestinationFrequency = oscillator.Frequency;
        HighlightLines(pt.X, true);
        touchID = id;
        break;
      case TouchAction.Move:
        if (id == touchID)
        {
           oscillator.DestinationFrequency = CalculateFrequency(pt.X);
           oscillator.DestinationAmplitude = CalculateAmplitude(pt.Y);
           HighlightLines(pt.X, true);
        }
        break;
      case TouchAction.Up:
        if (id == touchID)
        {
          oscillator.DestinationAmplitude = 0;
          touchID = Int32.MinValue;
          // Remove highlighting
          HighlightLines(0, false);
        }
        break;
      }
    }
}

Como puede inferir del código, el programa del theremín genera solo un tono y pasa por alto cuando se usan varios dedos.

A pesar de que la frecuencia del theremín varía continuamente, la pantalla de todas maneras muestra líneas que indican notas diferenciadas. Estas líneas aparecen rojas para C y azules para F (los colores que usan las cuerdas del arpa), blanco para naturales y gris para accidentales (los sostenidos). Después de jugar un poco con el programa, decidí que necesitaba alguna respuesta visual que indicara sobre qué nota estaba ubicado realmente el dedo, por lo que ensanché las líneas según su distancia desde el punto de toque. La figura 4muestra la pantalla cuando el dedo se encuentra entre C y C#, pero más cerca de C.

The Theremin Display
Figura 4 La pantalla del theremín

Latencia y distorsión

Un gran problema con la síntesis musical basada en software es la latencia, es decir, el retraso entre la intervención del usuario y el subsiguiente cambio en el sonido. Esto es bastante inevitable: la transmisión de audio en Silverlight requiere que una aplicación se derive de MediaStreamSource y reemplace el método GetSampleAsyn, que suministra datos de audio a pedido a través de un objeto Memory­Stream. Internamente, estos datos de audio se mantienen en un búfer. La existencia de este búfer ayuda a garantizar que el sonido se reproduzca sin ninguna laguna desconcertante pero, por supuesto, la reproducción del búfer siempre se quedará atrás del rellenado del búfer.

Afortunadamente, MediaStreamSource define una propiedad llamada AudioBufferLength que indica el tamaño del búfer en milisegundos de sonido. (Esta propiedad está protegida y solo se puede definir dentro del derivado de MediaStreamSource antes de abrir los medios). El valor predeterminado es 1.000 (o 1 segundo), pero puede definirlo incluso en 15. Una configuración inferior aumenta la interacción entre el SO y el derivado de MediaStreamSource y podría generar lagunas en el sonido. Sin embargo, descubrí que el ajuste mínimo de 15 parecía satisfactorio.

Otro potencial problema es que simplemente no se puedan escribir los datos. El programa necesita generar decenas o cientos de miles de bytes por segundo y, si no puede hacerlo de manera eficiente, el sonido comenzará a deteriorarse y crepitará mucho.

Hay un par de maneras de solucionar esto: puede hacer que la canalización de la generación de audio sea más eficiente (como analizaremos brevemente) o puede reducir la frecuencia de muestreo. Descubrí que la frecuencia de muestreo de CD de 44.100 era demasiado para mis programas y la disminuí a 22.050. También podría ser necesario disminuirla a 11.025. Siempre es recomendable probar los programas de audio en un par de dispositivos Windows Phone distintos. En un producto comercial, es probable que desee dar al usuario la opción de reducir la frecuencia de muestreo.

Varios osciladores

El componente Mixer de la biblioteca de sintetizadores tiene el trabajo de ensamblar varias entradas en canales izquierdo y derecho compuestos. Este es un trabajo bastante simple, pero recuerde que cada entrada es una forma de onda con una amplitud de 16 bits y el resultado también es una forma de onda con amplitud de 16 bits, por lo que las entradas se deben atenuar según cuántas son. Por ejemplo, si el componente Mixer tiene 10 entradas, cada una de ellas se debe atenuar a un décimo de su valor original.

Esto tiene una consecuencia profunda: las entradas del mezclador no se pueden agregar ni eliminar mientras se reproduce música sin disminuir o aumentar el volumen de las entradas restantes. Si desea un programa que pueda reproducir 25 sonidos diferentes a la vez, necesitará 25 entradas de mezcladores constantes.

Este es el caso de la aplicación de arpa en la solución MusicalInstruments. Preví un instrumento con cuernas que pudiera puntear con la yema de los dedos, pero que también pudiera rasguear para obtener el sonido común del arpa.

Tal como puede ver en la figura 5, visualmente es muy similar al theremín, pero con solo dos octavas en lugar de cuatro. Las cuerdas para los accidentales (los sostenidos) se posicionan en la parte superior, mientras que los naturales están en la parte inferior, lo que de algún modo imita el tipo de arpa conocido como "arpa de dos órdenes". Puede realizar un glissando pentatónico (en la parte superior), un glissando cromático (en el medio) o un glissando diatónico (en la parte inferior).

The Harp Program
Figura 5 El programa del arpa

Para obtener los sonidos reales, use 25 instancias de una clase SawtoothOscillator, que genera una forma de onda dentada simple que se aproxima extremadamente al sonido de las cuerdas. También fue necesario crear un generador de envolvente rudimentario. En la vida real, los sonidos musicales no comienzan ni se detienen instantáneamente. El sonido demora un poco en comenzar y luego puede atenuarse solo (como ocurre con el piano o el arpa) o puede atenuarse después de que el músico deja de tocarlo. Un generador de envolvente controla estos cambios. No necesitaba nada tan sofisticado como un envolvente ADSR (ataque - decaimiento - sostenido - relajación) completo, por lo que creé una clase AttackDecayEnvelope más simple. (En la vida real, el timbre de un sonido, regido por sus componentes armónicos, también cambia durante un solo tono, por lo que también lo debe controlar un generador de envolvente).

Para la respuesta visual, decidí que quería que las cuerdas vibraran. Cada cuerda en realidad es un segmento Bezier cuadrático, con el punto de control central colineal con los dos puntos de los extremos. Al aplicar una PointAnimation repetitiva al punto de control, pude hacer que las cuerdas vibraran.

En la práctica, fue un desastre. Las vibraciones se veían geniales, pero el sonido se degeneró y crepitaba de muy mala manera. Cambié entonces a algo un poco menos drástico: usé un DispatchertTimer y desplacé manualmente los puntos a una frecuencia mucho más lenta que una animación real.

Después de jugar un poco con el programa del arpa, seguía sin convencerme el gesto que se requería para puntear las cuerdas, por lo que agregué algo de código para generar el sonido con solo un toque. En ese punto, probablemente debí haber cambiado el nombre del programa de Arpa a Dulcimer, pero no lo hice.

Cómo evitar el punto flotante

En el dispositivo Windows Phone que usé para la mayoría de mi desarrollo, el programa del arpa funcionó bien. En otro Windows Phone crepitaba mucho, lo que indicaba que los búferes no se rellenaban lo suficientemente rápido. Este análisis se confirmó al disminuir la frecuencia de muestreo a la mitad. La crepitación se detuvo con una frecuencia de muestreo de 11.025 Hz, pero no estaba dispuesto a sacrificar la calidad del sonido.

En lugar de eso, comencé a observar muy de cerca la canalización que brindaba estas miles de muestras por segundo. Estas clases (Mixer, MixerInput, SawtoothOscillator y AttackDecayEnvelope) tenían solo un elemento en común: todos usaban el cálculo de punto flotante de alguna manera para calcular estas muestras. ¿Cambiar a cálculos de entero podría ayudar a acelerar esta canalización como para hacer una diferencia?

Volví a escribir mi clase AttackDecayEnvelope para usar cálculo de enteros e hice lo mismo con SawtoothOscillator, que aparece en la figura 6. Estos cambios mejoraron considerablemente el rendimiento.

Figura 6 La versión con enteros de SawtoothOscillator

public class SawtoothOscillator : IMonoSampleProvider
{
  int sampleRate;
  uint angle;
  uint angleIncrement;
  public SawtoothOscillator(int sampleRate)
  {
    this.sampleRate = sampleRate;
  }
  public double Frequency
  {
    set
    {
      angleIncrement = (uint)(UInt32.MaxValue * value / sampleRate);
    }
    get
    {
      return (double)angleIncrement * sampleRate / UInt32.MaxValue;
    }
  }
  public short GetNextSample()
  {
    angle += angleIncrement;
    return (short)((angle >> 16) + short.MinValue);
  }
}

En los osciladores que usan punto flotante, las variables angle y angleIncrement son de tipo double, donde angle varía de 0 a 2π y angleIncrement se calcula de la siguiente manera:

Para cada muestra, el angle se aumenta por angleIncrement.

No eliminé completamente el punto flotante de SawtoothOscillator. La propiedad pública Frequency sigue definida como doble, pero solo se usa cuando está definida la frecuencia del oscilador. Tanto angle como angleIncrement son enteros de 32 bits no firmados. Los valores completos de 32 bits se usan cuando angleIncrement aumenta el valor de angle, pero solo los de 16 bits principales se usan como valor para calcular una forma de onda.

Incluso con estos cambios, el programa no se ejecuta tan bien en lo que ahora considero como mi "teléfono lento" en comparación con mi "teléfono rápido". Pasar el dedo por toda la pantalla sigue provocando algo de crepitación.

Pero lo que ocurre con cualquier instrumento musical también ocurre con los instrumentos musicales electrónicos: es necesario familiarizarse con el instrumento y saber no solo cuáles son sus puntos fuertes, sino que también sus limitaciones.

Charles Petzold ha sido colaborador durante largo tiempo de MSDN Magazine. La dirección de su sitio web es charlespetzold.com.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo:  Mark Hopkins