Este artículo proviene de un motor de traducción automática.

El factor DirectX

Creación de osciladores de audio para Windows 8

Charles Petzold

Descargar el ejemplo de código

Charles PetzoldHe estado haciendo instrumentos de música electrónica como un hobby hace unos 35 años. Comencé en el cableado de los años setenta finales hasta chips CMOS y TTL y más tarde fue la ruta de software, primero con las extensiones Multimedia Windows en 1991 y más recientemente con la biblioteca de NAudio de Windows Presentation Foundation (WPF) y la clase MediaStreamSource en Silverlight y Windows Phone 7. El año pasado, dedicó unas cuotas de mi Touch & Ir a columna a aplicaciones para Windows Phone que reproducir sonido y música.

Probablemente estaría hastiado por este tiempo y tal vez reacio a explorar otra API de generación de sonido. Pero no, estoy porque creo que Windows 8 es probablemente la mejor plataforma de Windows todavía para la fabricación de instrumentos musicales. Windows 8 combina una API de audio de alto rendimiento, el componente de XAudio2 de DirectX, con pantallas táctiles en las tablillas de la mano. Esta combinación ofrece muchas posibilidades, y yo estoy particularmente interesado en explorar cómo toque puede explotarse como una sutil e íntima interfaz para un instrumento musical implementado completamente en software.

Osciladores, Ejemplos y frecuencia

En el corazón de las instalaciones de generación de sonido de cualquier sintetizador de música son múltiples osciladores, llamados así porque generan una forma de onda periódica más o menos oscilante a una determinada frecuencia y volumen. En la generación de sonidos de la música, osciladores que crean formas de onda periódicas invariables generalmente suenan bastante aburridos. Osciladores más interesantes incorporan vibrato, trémolo o cambio de timbres, y sólo son más o menos periódicas.

Un programa que se desea crear osciladores usando XAudio2 comienza llamando la función XAudio2Create. Esto proporciona un objeto que implementa la interfaz IXAudio2. De ese objeto se puede llamar CreateMasteringVoice una sola vez para obtener una instancia de IXAudio2MasteringVoice, que funciona como el mezclador de audio principal. Solo IXAudio2MasteringVoice existe en cualquier momento. Por el contrario, usted generalmente llamaremos CreateSourceVoice varias veces para crear varias instancias de la interfaz IXAudio2SourceVoice. Cada una de estas instancias de IXAudio2SourceVoice puede funcionar como un oscilador independiente. Combinar múltiples osciladores para un instrumento multiphonic, un conjunto o una orquesta completa.

Un objeto IXAudio2SourceVoice genera el sonido mediante la creación y presentación de búferes que contiene una secuencia de números que describen una forma de onda. Estos números se llaman a menudo muestras. Son a menudo amplia de 16 bits (el estándar para CD de audio) y vienen a un ritmo constante, generalmente de 44.100 Hz (también la norma para CD de audio) o alrededores. Esta técnica tiene el nombre fantasia modulación por impulsos codificados o PCM.

Aunque esta secuencia de muestras puede describir una forma de onda muy complejo, a menudo un sintetizador genera una corriente bastante simple de muestras — comúnmente una onda cuadrada, una onda triangular o un diente de Sierra, con una periodicidad correspondiente a la frecuencia de la onda (percibido como tono) y una amplitud promedio que se percibe como volumen.

Por ejemplo, si la frecuencia de muestreo es de 44.100 Hz, y cada ciclo de 100 muestras tiene valores que progresivamente más grandes, entonces pequeños, luego negativo y volver a cero, la frecuencia del sonido resultante es 44.100 dividido por 100, o 441 Hz — una frecuencia cerca del centro perceptivo de la gama audible para los seres humanos. (Una frecuencia de 440 Hz es la A sobre C media y se utiliza como un afinación estándar).

La interfaz IXAudio2SourceVoice hereda un método denominado SetVolume de IXAudio2Voice y define un método propio denominado SetFrequencyRatio. Particularmente estaba intrigado por este método, porque parecía proporcionar una forma de crear un oscilador que genera una particular forma de onda periódica con una frecuencia variable con un mínimo de alboroto.

Figura 1 muestra la mayor parte de una clase denominada SawtoothOscillator1 que implementa esta técnica. Aunque utilizo muestras familiar entero de 16 bits para definir la forma de onda, internamente XAudio2 utiliza muestras de punto flotante de 32 bits. Para aplicaciones de rendimiento críticas, usted probablemente querrá explorar las diferencias de rendimiento entre enteros y coma flotante.

Figura 1 mucho de la clase SawtoothOscillator1

SawtoothOscillator1::SawtoothOscillator1(IXAudio2* pXAudio2)
{
  // Create a source voice
  WAVEFORMATEX waveFormat;
  waveFormat.wFormatTag = WAVE_FORMAT_PCM;
  waveFormat.nChannels = 1;
  waveFormat.nSamplesPerSec = 44100;
  waveFormat.nAvgBytesPerSec = 44100 * 2;
  waveFormat.nBlockAlign = 2;
  waveFormat.wBitsPerSample = 16;
  waveFormat.cbSize = 0;
  HRESULT hr = pXAudio2->CreateSourceVoice(&pSourceVoice, &waveFormat,
                                           0, XAUDIO2_MAX_FREQ_RATIO);
  if (FAILED(hr))
    throw ref new COMException(hr, "CreateSourceVoice failure");
  // Initialize the waveform buffer
  for (int sample = 0; sample < BUFFER_LENGTH; sample++)
    waveformBuffer[sample] =
      (short)(65535 * sample / BUFFER_LENGTH - 32768);
  // Submit the waveform buffer
  XAUDIO2_BUFFER buffer = {0};
  buffer.AudioBytes = 2 * BUFFER_LENGTH;
  buffer.pAudioData = (byte *)waveformBuffer;
  buffer.Flags = XAUDIO2_END_OF_STREAM;
  buffer.PlayBegin = 0;
  buffer.PlayLength = BUFFER_LENGTH;
  buffer.LoopBegin = 0;
  buffer.LoopLength = BUFFER_LENGTH;
  buffer.LoopCount = XAUDIO2_LOOP_INFINITE;
  hr = pSourceVoice->SubmitSourceBuffer(&buffer);
  if (FAILED(hr))
    throw ref new COMException(hr, "SubmitSourceBuffer failure");
  // Start the voice playing
  pSourceVoice->Start();
}
void SawtoothOscillator1::SetFrequency(float freq)
{
  pSourceVoice->SetFrequencyRatio(freq / BASE_FREQ);
}
void SawtoothOscillator1::SetAmplitude(float amp)
{
  pSourceVoice->SetVolume(amp);
}

En el archivo de encabezado, se establece una frecuencia baja que divide limpiamente en la frecuencia de muestreo de 44.100. De eso, se puede calcular un tamaño de buffer que es la longitud de un solo ciclo de una onda de frecuencia:

static const int BASE_FREQ = 441;
static const int BUFFER_LENGTH = (44100 / BASE_FREQ);

También en el encabezado del archivo es la definición de eso almacenador intermediario como un campo:

short waveformBuffer[BUFFER_LENGTH];

Después de crear el objeto IXAudio2SourceVoice, el Sawtooth­Oscillator1 constructor se llena un tampón con un ciclo de una onda de diente de Sierra, una simple forma de onda que va de una amplitud de-32,768 a una amplitud de 32.767. Este buffer es sometido a la IXAudio2SourceVoice con las instrucciones que se deben repetir para siempre.

Sin más código, se trata de un oscilador que juega una onda del sawtooth de 441 Hz para siempre. Eso es genial, pero no es muy versátil. Para dar a SawtoothOscillator1 un poco más de flexibilidad, también he incluido un método de SetFrequency. El argumento que esto es una frecuencia que utiliza la clase para llamar a SetFrequencyRatio. El valor pasado a SetFrequencyRatio puede variar desde valores flotantes de XAUDIO2_MIN_FREQ_RATIO (o 1/1,024.0) hasta un valor máximo especificado anteriormente como argumento para CreateSourceVoice. Usé XAUDIO2_MAX_FREQ_RATIO (o 1,024.0) para ese argumento. El umbral de audición humana — alrededor de 20 Hz a 20.000 Hz — es bien dentro de los límites definidos por los dos constantes aplicados a la frecuencia base de 441.

Los amortiguadores y las devoluciones de llamada

Debo confesar que era inicialmente un poco escéptico del método SetFrequencyRatio. Digitalmente aumentando y disminuyendo la frecuencia de una onda no son una tarea trivial. Me sentí obligado a comparar los resultados con una forma de onda generado algorítmicamente. Esto es el ímpetu detrás del proyecto OscillatorCompare, que se encuentra entre el código descargable para esta columna.

El proyecto de OscillatorCompare incluye la clase de SawtoothOscillator1 que ya he descrito así como una clase de SawtoothOscillator2. Esta segunda clase tiene un método de SetFrequency que controla cómo la clase genera dinámicamente las muestras que definen la forma de onda. Esta forma de onda continuamente se construye en un búfer y enviado en tiempo real para el objeto IXAudio2SourceVoice en respuesta a las devoluciones de llamada.

Una clase puede recibir las devoluciones de llamada de IXAudio2SourceVoice implementa la interfaz IXAudio2VoiceCallback. Luego, una instancia de la clase que implementa esta interfaz se pasa como argumento al método CreateSourceVoice. La clase SawtoothOscillator2 implementa esta interfaz en sí misma y pasa su propia instancia a CreateSourceVoice, indicando además que no estar haciendo uso de SetFrequencyRatio:

pXAudio2->CreateSourceVoice(&pSourceVoice, &waveFormat,
        XAUDIO2_VOICE_NOPITCH, 1.0f,
        this);

Una clase que implementa IXAudio2VoiceCallback puede utilizar el método OnBufferStart para que te notifiquen cuando es el momento de presentar un nuevo búfer de datos de forma de onda. Generalmente cuando se utiliza OnBufferStart para mantener actualizada la información de forma de onda, usted querrá mantener un par de amortiguadores y les alternan. Esto es probablemente la mejor solución si se están obteniendo datos de audio de otra fuente, como un archivo de audio. El objetivo es no dejar que el procesador de audio se convierten en "hambre". Manteniendo un tampón por delante el proceso ayuda a prevenir el hambre, pero no lo garantizamos.

Pero gravitaron hacia otro método definido por IXAudio2VoiceCallback, OnVoiceProcessingPassStart. A menos que se trabaja con amortiguadores muy pequeños, generalmente OnVoiceProcessingPassStart se llama más con frecuencia que OnBufferStart e indica cuando un fragmento de datos de audio está a punto de ser procesado y cuántos bytes se necesitan. En la documentación de XAudio2, este método de devolución de llamada se promociona como el que tiene la menor latencia, que a menudo es altamente deseable para instrumentos de música electrónica interactiva. Usted no quiere un retraso entre una tecla y la nota de la audiencia!

El archivo de cabecera SawtoothOscillator2 define dos constantes:

static const int BUFFER_LENGTH = 1024;
static const int WAVEFORM_LENGTH = 8192;

La primera constante es la longitud del búfer utilizado para enviar los datos de forma de onda. Aquí funciona como un búfer circular. Las llamadas al método OnVoiceProcessingPassStart solicitar un determinado número de bytes. El método responde poniendo esos bytes en el buffer (a partir de donde se quedó la última vez) y llamando a SubmitSourceBuffer sólo para ese segmento actualizado del búfer. Desea este buffer a ser suficientemente grande para que el código de programa no sobrescribe la parte del búfer sigue reproduciéndose en el fondo.

Resulta que una voz con una velocidad de muestreo de 44.100 Hz, llamadas a OnVoiceProcessingPassStart siempre solicitar 882 bytes o 441 muestras de 16 bits. En otras palabras, OnVoiceProcessingPassStart se llama a una velocidad constante de 100 veces por segundo, o cada 10 ms. Aunque no está documentado, esta duración de 10 ms puede ser tratada como un procesamiento de audio XAudio2 "cuántica", y es una buena cifra para tener en cuenta. Por lo tanto, no te entretengas el código que escriba para este método. Evitar llamadas de API y Biblioteca runtime.

La segunda constante es la longitud de un ciclo único de la forma de onda deseada. Podría ser el tamaño de una matriz que contiene las muestras de esa forma de onda, pero en SawtoothOscillator2 se utiliza solamente para los cálculos.

El método SetFrequency en SawtoothOscillator2 usa ese constante para calcular un incremento de ángulo que es proporcional a la frecuencia deseada de la forma de onda:

angleIncrement = (int)(65536.0
                * WAVEFORM_LENGTH
                * freq / 44100.0);

Aunque angleIncrement es un entero, se trata como si comprende palabras integrales y fraccionarios. Este es el valor que se utiliza para determinar cada muestra sucesivo de la onda.

Por ejemplo, supongamos que el argumento SetFrequency es 440 Hz. La angleIncrement se calcula como 5.356.535. En hexadecimal, esto es 0x51BBF7, que es tratado como un entero de 0x51 (o 81 decimal), con una parte fraccional de 0xBBF7, equivalente a 0.734. Si el ciclo completo de una forma de onda es 8.192 bytes y utilizar sólo la parte entera y saltar 81 bytes para cada muestra, la frecuencia resultante es de unos 436.05 Hz. (Es 81 44.100 veces dividido por 8.192). Si usted salta 82 bytes, la frecuencia resultante es 441.43 Hz. Usted quiere algo entre estas dos frecuencias.

Por esta razón una parte fraccionaria también necesita entrar en el cálculo. Todo sería más fácil en coma flotante, y coma flotante podría ser incluso más rápido en algunos procesadores modernos, pero figura 2 muestra un enfoque sólo entero "tradicional" más. Tenga en cuenta que sólo la sección actualizada del búfer circular se especifica con cada llamada a SubmitSourceBuffer.

Figura 2 OnVoiceProcessingPassStart en SawtoothOscillator2

void _stdcall SawtoothOscillator2::OnVoiceProcessingPassStart(UINT32 bytesRequired)
{
  if (bytesRequired == 0)
      return;
  int startIndex = index;
  int endIndex = startIndex + bytesRequired / 2;
  if (endIndex <= BUFFER_LENGTH)
  {
    FillAndSubmit(startIndex, endIndex - startIndex);
  }
  else
  {
    FillAndSubmit(startIndex, BUFFER_LENGTH - startIndex);
    FillAndSubmit(0, endIndex % BUFFER_LENGTH);
  }
  index = (index + bytesRequired / 2) % BUFFER_LENGTH;
}
void SawtoothOscillator2::FillAndSubmit(int startIndex, int count)
{
  for (int i = startIndex; i < startIndex + count; i++)
  {
    pWaveformBuffer[i] = (short)(angle / WAVEFORM_LENGTH - 32768);
    angle = (angle + angleIncrement) % (WAVEFORM_LENGTH * 65536);
  }
  XAUDIO2_BUFFER buffer = {0};
  buffer.AudioBytes = 2 * BUFFER_LENGTH;
  buffer.pAudioData = (byte *)pWaveformBuffer;
  buffer.Flags = 0;
  buffer.PlayBegin = startIndex;
  buffer.PlayLength = count;
  HRESULT hr = pSourceVoice->SubmitSourceBuffer(&buffer);
  if (FAILED(hr))
    throw ref new COMException(hr, "SubmitSourceBuffer");
}

SawtoothOscillator1 y SawtoothOscillator2 pueden ser comparado por lado en el programa de OscillatorCompare. Página principal tiene dos pares de controles deslizantes para cambiar la frecuencia y volumen de cada oscilador. El control deslizante para la frecuencia genera sólo valores enteros que van desde 24 hasta 132. He tomado prestada a estos valores de los códigos utilizados en el Musical instrumento Digital Interface (MIDI) estándar para representar campos. El valor de 24 corresponde a las C tres octavas debajo de C media, que se llama C 1 (C en la octava 1) en notación científica echada y tiene una frecuencia de aproximadamente 32,7 Hz. El valor de 132 corresponde a una frecuencia de aproximadamente 16.744 Hz, C 10 y seis octavas sobre C media. Un convertidor de tooltip en estos deslizadores muestra el valor actual en notación científica echada y la frecuencia equivalente.

Experimenté con estos dos osciladores, yo no podía oír una diferencia. También instalé un osciloscopio de software en otro equipo para examinar visualmente las formas de onda resultantes, y no podía ver ninguna diferencia tampoco. Me indica que el SetFrequency­relación método se implementa de manera inteligente, que por supuesto habrá que esperar en un sistema tan sofisticados como DirectX. Sospecho que las interpolaciones se realizan sobre datos de forma de onda remuestreada a cambio de la frecuencia. Si eres nervioso, se puede establecer que el BASE_FREQ es muy baja, por ejemplo, a 20 Hz, y la clase generará una forma de onda detallada de 2.205 muestras. También puede experimentar con un alto valor: Por ejemplo, Hz 8.820 provocará una forma de onda de sólo cinco muestras que se generen. Sin duda, esto tiene un sonido algo diferente, porque la forma de onda interpolada se encuentra en algún lugar entre un diente de sierra y una onda triangular, pero la forma de onda resultante es todavía liso sin imágenes "dentadas".

Se trata de no dar a entender que todo funciona dory hunky. Con cada oscilador diente de Sierra, las octavas superior par Obtén más bien caóticas. El muestreo de la forma de onda tiende a emitir alta y baja frecuencia insinuaciones de una especie que he escuchado, y que voy a investigar más a fondo en el futuro.

Mantenga el volumen!

El método SetVolume definido por IXAudio2Voice y heredada por IXAudio2SourceVoice está documentado como un multiplicador de coma flotante que puede establecerse en valores que van de -2 ^ 24 a 2 ^ 24, lo que equivale a 16,777,216.

En la vida real, sin embargo, usted probablemente querrá mantener el volumen de un objeto IXAudio2SourceVoice a un valor entre 0 y 1. El valor corresponde al silencio de 0 y 1 se corresponden con ninguna ganancia o atenuación. Tenga en cuenta que cualquiera que sea la fuente de la forma de onda asociada con un IXAudio2SourceVoice, si se está generando algorítmico o se origina en un archivo de audio — probablemente tiene muestras de 16 bits que muy posiblemente se acercan a los valores mínimos y máximos de-32,768 y 32.767. Si se intenta ampliar las formas de onda con un nivel de volumen mayor que 1, las muestras superarán el ancho de un entero de 16 bits y se recortará en los valores mínimos y máximos. Resultarán la distorsión y el ruido.

Esto llega a ser crítico cuando empiezas a combinar varias instancias de IXAudio2SourceVoice. Las formas de onda de estas instancias múltiples son mezclados por se suman. Si permite que cada una de estas instancias para tener un volumen de 1, la suma de las voces muy bien podría resultar en muestras que exceden el tamaño de los enteros de 16 bits. Esto puede ocurrir de forma esporádica, resultando solamente en distorsión intermitente — o crónico, dando por resultado un verdadero desastre.

Cuando se utilizan varias instancias de IXAudio2SourceVoice que generan formas de onda completa 16-bit-ancho, una medida de seguridad es ajustar el volumen de cada oscilador a 1 dividido por el número de voces. Garantiza que la suma no supera un valor de 16 bits. También es posible un ajuste de volumen global mediante la voz de masterización. También puede buscar en la función XAudio2CreateVolumeMeter, que le permite crear un objeto de procesamiento de audio que puede ayudar a monitor volumen para propósitos de depuración.

Nuestro primer instrumento Musical

Es común que los instrumentos musicales en tabletas para tener un teclado tipo piano, pero yo he sido intrigado recientemente por un tipo de botón de teclado en acordeones como el bayan ruso (que conozco de la obra del compositor ruso Sofía Gubaidulina). Porque cada tecla es un botón en lugar de una palanca larga, se pueden embalar muchas más claves dentro del espacio limitado de la pantalla de la tableta, como se muestra en figura 3.

The ChromaticButtonKeyboard Program
Figura 3 el programa de ChromaticButtonKeyboard

Las dos filas de fondo duplican las claves en las dos filas superiores y son provistas para facilitar la digitación de acordes comunes y secuencias melódicas. De lo contrario, cada grupo de 12 teclas en las tres filas superiores proporcionan todas las notas de la octava, generalmente ascendente de izquierda a derecha. La gama total de aquí es de cuatro octavas, que es dos veces lo que se podrían obtener con un teclado de piano del mismo tamaño.

Un bayan real tiene una octava adicional, pero yo no pude colocarlo en sin hacer los botones demasiado pequeños. El código fuente permite definir constantes para probar esa octava adicional, o para eliminar otra octava y hacer que los botones aún mayor.

Porque yo no puedo decir que este programa suena como cualquier instrumento que existe en el mundo real, simplemente llamé ChromaticButton­teclado. Las claves son instancias de un control personalizado denominado clave que se deriva de ContentControl pero realiza algún procesamiento táctil para mantener una propiedad IsPressed y generar un evento IsPressedChanged. La diferencia entre el toque en este control y el toque en un botón ordinario (que también tiene una propiedad IsPressed) es notable cuando se barre el dedo en el teclado: Un botón estándar establecerá la propiedad IsPressed en true sólo si la prensa dedo se produce en la superficie del botón, mientras que este control personalizado de clave considera que la clave para ser presionado si un dedo arrasa en el lado.

El programa crea seis instancias de una clase de SawtoothOscillator que es prácticamente idéntica a la clase de SawtoothOscillator1 del proyecto anterior. Si su pantalla táctil lo soporta, puedes jugar seis notas simultáneas. Hay no hay devoluciones de llamada y la frecuencia del oscilador es controlada por las llamadas al método SetFrequencyRatio.

Para hacer seguimiento de que osciladores están disponibles y que osciladores están jugando, el archivo MainPage.xaml.h define dos objetos de la colección estándar como campos:

std::vector<SawtoothOscillator *> availableOscillators;
std::map<int, SawtoothOscillator *> playingOscillators;

Originalmente, cada objeto clave tenía su propiedad Tag para el código de la nota de MIDI que se ha discutido anteriormente. Es como el controlador IsPressedChanged determina qué tecla se ha presionado y qué frecuencia a calcular. Ese código MIDI también fue utilizado como la clave del mapa para la colección de playingOscillators. Funcionó bien hasta que toqué una nota de las dos filas de fondo que duplicar una nota ya jugando, resultando en una clave duplicada y una excepción. Resolví fácilmente ese problema mediante la incorporación de un valor en la propiedad de la etiqueta que indica la fila donde se encuentra la clave: La etiqueta ahora es igual al código de nota MIDI más de 1.000 veces el número de fila.

Figura 4 muestra el controlador IsPressedChanged para las instancias de la llave. Cuando se presiona una tecla, un oscilador es quitado de la colección de availableOscillators, una frecuencia y volumen cero y poner en la colección de playingOscillators. Cuando se suelta una tecla, el oscilador es dado un volumen cero y regresó a availableOscillators.

Figura 4 el controlador IsPressedChanged para las instancias claves

void MainPage::OnKeyIsPressedChanged(Object^ sender, bool isPressed)
{
  Key^ key = dynamic_cast<Key^>(sender);
  int keyNum = (int)key->Tag;
  if (isPressed)
  {
    if (availableOscillators.size() > 0)
    {
      SawtoothOscillator* pOscillator = availableOscillators.back();
      availableOscillators.pop_back();
      double freq = 440 * pow(2, (keyNum % 1000 - 69) / 12.0);
      pOscillator->SetFrequency((float)freq);
      pOscillator->SetAmplitude(1.0f / NUM_OSCILLATORS);
      playingOscillators[keyNum] = pOscillator;
    }
  }
  else
  {
    SawtoothOscillator * pOscillator = playingOscillators[keyNum];
    if (pOscillator != nullptr)
    {
      pOscillator->SetAmplitude(0);
      availableOscillators.push_back(pOscillator);
      playingOscillators.erase(keyNum);
    }
  }
}

Es casi tan simple como puede ser un instrumento incorporado, y por supuesto es defectuoso: Sonidos no deben estar apagadas y como un interruptor. El volumen debe deslizarse rápidamente pero suavemente cuando comienza una nota y la caída hacia atrás cuando se detenga. Muchos instrumentos reales también hay un cambio en el volumen y timbre conforme avanza la nota. Todavía hay mucho espacio para mejoras.

Pero teniendo en cuenta la simplicidad del código, funciona sorprendentemente bien y es muy sensible. Si se compila el programa para el procesador del brazo, puede desplegarlo en la basados en ARM Microsoft Surface y pasear por acunando la tableta sin ataduras en un brazo mientras jugaba en él con la otra mano, que tengo que decir que es un poco de emoción.

Charles Petzold es colaborador desde hace mucho tiempo a la MSDN Magazine y el autor de "Programación Windows, 6ª edición" (o ' Reilly Media, 2012), un libro acerca de cómo escribir aplicaciones para Windows 8. Su sitio Web es charlespetzold.com.

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: Tom Mathews y Thomas Petchel