Generación de sonido en microcontroladores AVR por el método de tablas de ondas con soporte de polifonía.

Los microcontroladores AVR son bastante baratos y generalizados. Probablemente, casi cualquier desarrollador incrustado comienza con ellos. Y entre los aficionados, el Arduino gobierna la pelota, cuyo corazón suele ser el ATmega328p. Seguramente muchos se preguntaron: ¿cómo puedes hacerlos sonar?

Si observa los proyectos existentes, son de varios tipos:

  1. Generadores de pulso cuadrado. Genere usando PWM o pines de tirón en interrupciones. En cualquier caso, se obtiene un sonido chirriante muy característico.
  2. Uso de equipos externos como un decodificador de MP3.
  3. Uso de PWM para emitir sonido de 8 bits (a veces 16 bits) en formato PCM o ADPCM. Como la memoria en los microcontroladores claramente no es suficiente para esto, generalmente usan una tarjeta SD.
  4. Usando PWM para generar sonido basado en tablas de ondas como MIDI.

El último tipo fue especialmente interesante para mí, porque Casi no requiere equipo adicional. Presento mi opción a la comunidad. Primero, una pequeña demostración:



Interesado, pido gato.

Entonces, el equipo:

  • ATmega8 o ATmega328. Portar a otros ATmega no es difícil. E incluso en ATtiny, pero más sobre eso más tarde;
  • Resistencia;
  • Condensador;
  • Altavoz o auriculares;
  • Nutrición;

Como todo

Un circuito RC simple con un altavoz está conectado a la salida del microcontrolador. La salida es un sonido de 8 bits con una frecuencia de muestreo de 31250Hz. A una frecuencia de cristal de 8 MHz, se pueden generar hasta 5 canales de sonido + un canal de ruido para percusión. En este caso, se usa casi todo el tiempo del procesador, pero después de llenar el búfer, el procesador puede ocuparse con algo útil además del sonido:


Este ejemplo encaja completamente en la memoria ATmega8, se procesan 5 canales + ruido a una frecuencia de cristal de 8 MHz y hay poco tiempo para la animación en la pantalla.

En este ejemplo, también quería mostrar que la biblioteca se puede usar no solo como una postal musical normal, sino también para conectar el sonido a proyectos existentes, por ejemplo, para notificaciones. E incluso cuando se usa solo un canal de sonido, las notificaciones pueden ser mucho más interesantes que un simple tweeter.

Y ahora los detalles ...

Tablas de olas o tablas de olas


La matemática es extremadamente simple. Hay una función de tono periódica, por ejemplo, tono (t) = sin (t * freq / (2 * Pi)) .

También hay una función para cambiar el volumen del tono fundamental a lo largo del tiempo, por ejemplo, volumen (t) = e ^ (- t) .

En el caso más simple, el sonido de un instrumento es el producto de estas funciones instrumento (t) = tono (t) * volumen (t) :

En el gráfico, todo se ve así:



A continuación, tomamos todos los instrumentos que suenan en un momento dado y los resumimos con algunos factores de volumen (pseudocódigo):

for (i = 0; i < CHANNELS; i++) { value += channels[i].tone(t) * channels[i].volume(t) * channels[i].volume; } 

Solo es necesario seleccionar el volumen para que no haya desbordamiento. Y eso es casi todo.

El canal de ruido funciona de la misma manera, pero en lugar de una función de tono, un generador de secuencia pseudoaleatoria.

La percusión es una mezcla de canal de ruido y onda de baja frecuencia, a aproximadamente 50-70 Hz.
Por supuesto, el sonido de alta calidad de esta manera es difícil de lograr. Pero solo tenemos 8 kilobytes para todo. Espero que esto pueda ser perdonado.

¿Qué puedo sacar de 8 bits?


Inicialmente, me concentré en ATmega8. Sin cuarzo externo, funciona a una frecuencia de 8 MHz y tiene un PWM de 8 bits, que proporciona una frecuencia de muestreo base de 8000000/256 = 31250 Hz. Un temporizador usa PWM para emitir sonido, y provoca una interrupción durante el desbordamiento para transmitir el siguiente valor al generador PWM. En consecuencia, tenemos 256 ciclos para calcular el valor de la muestra para todo, incluida la sobrecarga de interrupción, la actualización de los parámetros del canal de sonido, el seguimiento del tiempo en que necesita tocar la siguiente nota, etc.

Para la optimización, utilizaremos activamente los siguientes trucos:

  • Como tenemos un procesador de ocho bits, intentaremos que las variables sean las mismas. A veces usaremos 16 bits.
  • Los cálculos se dividen condicionalmente en frecuentes y no tan frecuentes. Los primeros deben calcularse para cada muestra, el segundo, con mucha menos frecuencia, una vez cada varias decenas / cientos de muestras.
  • Para distribuir uniformemente la carga a lo largo del tiempo, utilizamos un búfer circular. En el bucle principal del programa, llenamos el búfer, lo restamos en la interrupción. Si todo está bien, entonces el búfer se llena más rápido de lo que se vacía y tenemos tiempo para otra cosa.
  • El código está escrito en C con mucha línea. La práctica muestra que es mucho más rápido.
  • Todo lo que el preprocesador puede calcular, especialmente con la participación de la división, lo hace el preprocesador.

Primero, divida el tiempo en intervalos de 4 milisegundos (los llamé ticks). A una frecuencia de muestreo de 31250Hz, obtenemos 125 muestras por tic. El hecho de que cada muestra debe leerse debe contarse en cada muestra y el resto, una vez por marca o menos. Por ejemplo, dentro de un tic, el volumen del instrumento será constante: instrumento (t) = tono (t) * volumen actual ; y el Volumen actual se volverá a calcular una vez por marca teniendo en cuenta el volumen (t) y el volumen seleccionado del canal de sonido.

Se eligió una duración de tics de 4 ms en función de un límite simple de 8 bits: con un contador de muestras de ocho bits, puede trabajar con una frecuencia de muestreo de hasta 64 kHz, con un contador de tics de ocho bits podemos medir el tiempo hasta 1 segundo.

Un poco de código


El canal en sí se describe mediante esta estructura:

 typedef struct { // Info about wave const int8_t* waveForm; // Wave table array uint16_t waveSample; // High byte is an index in waveForm array uint16_t waveStep; // Frequency, how waveSample is changed in time // Info about volume envelope const uint8_t* volumeForm; // Array of volume change in time uint8_t volumeFormLength; // Length of volumeForm uint8_t volumeTicksPerSample; // How many ticks should pass before index of volumeForm is changed uint8_t volumeTicksCounter; // Counter for volumeTicksPerSample // Info about volume uint8_t currentVolume; // Precalculated volume for current tick uint8_t instrumentVolume; // Volume of channel } waveChannel; 

Condicionalmente, los datos aquí se dividen en 3 partes:

  1. Información sobre la forma de onda, fase, frecuencia.

    waveForm: información sobre la función tone (t): una referencia a una matriz de 256 bytes de longitud. Establece el tono, el sonido del instrumento.

    waveSample: el byte alto indica el índice actual de la matriz waveForm.

    waveStep: establece la frecuencia con la que se incrementará waveSample al contar la siguiente muestra.

    Cada muestra se considera algo así:

     int8_t tone = channelData.waveForm[channelData.waveSample >> 8]; channelData.waveSample += channelaData.waveStep; return tone * channelData.currentVolume; 

  2. Información de volumen. Establece la función de cambiar el volumen a lo largo del tiempo. Dado que el volumen no cambia con tanta frecuencia, puede contarlo con menos frecuencia, una vez por marca. Esto se hace así:

     if ((channel->volumeTicksCounter--) == 0 && channel->volumeFormLength > 0) { channel->volumeTicksCounter = channel->volumeTicksPerSample; channel->volumeFormLength--; channel->volumeForm++; } channel->currentVolume = channel->volumeForm * channel->instrumentVolume >> 8; 

  3. Establece el volumen del canal y el volumen actual calculado.

Tenga en cuenta: la forma de onda es de ocho bits, el volumen también es de ocho bits y el resultado es de 16 bits. Con una ligera pérdida de rendimiento, puede hacer que el sonido (casi) sea de 16 bits.

En la lucha por la productividad, tuve que recurrir a algo de magia negra.

Ejemplo número 1. Cómo volver a calcular el volumen de canales:

 if ((tickSampleCounter--) == 0) { //    tickSampleCounter = SAMPLES_PER_TICK – 1; //   - } // volume recalculation should no be done so often for all channels if (tickSampleCounter < CHANNELS_SIZE) { recalculateVolume(channels[tickSampleCounter]); } 

Por lo tanto, todos los canales cuentan el volumen una vez por marca, pero no simultáneamente.

Ejemplo número 2. Mantener la información del canal en una estructura estática es más barata que en una matriz. Sin entrar en detalles sobre la implementación de wavechannel.h, diré que este archivo se inserta en el código varias veces (igual al número de canales) con diferentes directivas de preprocesador. Cada inserción crea nuevas variables globales y una nueva función de cálculo de canal, que luego está en línea en el código principal:

 #if CHANNELS_SIZE >= 1 val += channel0NextSample(); #endif #if CHANNELS_SIZE >= 2 val += channel1NextSample(); #endif … 

Ejemplo número 3. Si comenzamos a tocar la siguiente nota un poco más tarde, nadie se dará cuenta. Imaginemos la situación: tomamos el procesador con algo y durante este tiempo el búfer estaba casi vacío. Luego comenzamos a llenarlo y de repente resulta que viene una nueva medida: necesitamos actualizar las notas actuales, leer de la matriz lo que sigue, etc. Si no tenemos tiempo, habrá un tartamudeo característico. Es mucho mejor llenar un poco el búfer con datos antiguos y solo luego actualizar el estado de los canales.

 while ((samplesToWrite) > 4) { //          fillBuffer(SAMPLES_PER_TICK); //     -  updateMusicData(); //    } 

En el buen sentido, sería necesario rellenar el búfer después del ciclo, pero como tenemos casi todo en línea, el tamaño del código está notablemente inflado.

Musica


Se utiliza un contador de ticks de ocho bits. Cuando se alcanza el cero, comienza una nueva medida, al contador se le asigna la duración de la medida (en ticks), un poco más tarde se verifica la matriz de comandos musicales.

Los datos de música se almacenan en una matriz de bytes. Está escrito algo como esto:

 const uint8_t demoSample[] PROGMEM = { DATA_TEMPO(160), // Set beats per minute DATA_INSTRUMENT(0, 1), // Assign instrument 1 (see setSample) to channel 0 DATA_INSTRUMENT(1, 1), // Assign instrument 1 (see setSample) to channel 1 DATA_VOLUME(0, 128), // Set volume 128 to channel 0 DATA_VOLUME(1, 128), // Set volume 128 to channel 1 DATA_PLAY(0, NOTE_A4, 1), // Play note A4 on channel 0 and wait 1 beat DATA_PLAY(1, NOTE_A3, 1), // Play note A3 on channel 1 and wait 1 beat DATA_WAIT(63), // Wait 63 beats DATA_END() // End of data stream }; 

Todo lo que comienza con DATA_ son macros de preprocesador que expanden los parámetros al número requerido de bytes de datos.

Por ejemplo, el comando DATA_PLAY se expande en 2 bytes en los que se almacenan: el marcador de comando (1 bit), la pausa antes del siguiente comando (3 bits), el número de canal en el que tocar la nota (4 bits), información sobre la nota (8 bits). La limitación más importante es que este comando no se puede usar para pausas largas, con un máximo de 7 medidas. Si necesita más, entonces necesita usar el comando DATA_WAIT (hasta 63 medidas). Desafortunadamente, no encontré si la macro se puede expandir a un número diferente de bytes de la matriz dependiendo del parámetro de macro. E incluso advirtiendo que no sé cómo mostrar. Quizás tú me lo digas.

Uso


En el directorio de demos hay varios ejemplos para diferentes microcontroladores. Pero en resumen, aquí hay una pieza del archivo Léame, realmente no tengo nada que agregar:

 #include "../../microsound/devices/atmega8timer1.h" #include "../../microsound/micromusic.h" // Make some settings #define CHANNELS_SIZE 5 #define SAMPLES_SIZE 16 #define USE_NOISE_CHANNEL initMusic(); // Init music data and sound control sei(); // Enable interrupts, silence sound should be generated setSample(0, instrument1); // Use instrument1 as sample 0 setSample(1, instrument2); // Init all other instruments… playMusic(mySong); // Start playing music at pointer mySong while (!isMusicStopped) { fillMusicBuffer(); // Fill music buffer in loop // Do some other stuff } 

Si desea hacer otra cosa además de música, puede aumentar el tamaño del búfer utilizando BUFFER_SIZE. El tamaño del búfer debe ser 2 ^ n, pero, desafortunadamente, con un tamaño de 256, se produce una degradación del rendimiento. Hasta que lo descubrí.

Para aumentar la productividad, puede aumentar la frecuencia con cuarzo externo, puede reducir el número de canales, puede reducir la frecuencia de muestreo. Con el último truco, puede utilizar la interpolación lineal, que compensa algo la caída de la calidad del sonido.

No se recomienda ningún retraso, porque Se pierde tiempo de CPU. En cambio, su propio método se implementa en el archivo microsound / delay.h , que, además de la pausa, está involucrado en llenar el búfer. Este método puede no funcionar con mucha precisión en pausas cortas, pero en pausas largas más o menos sanas.

Haciendo tu propia música


Si escribe comandos manualmente, debe poder escuchar lo que sucede. Verter cada cambio en el microcontrolador no es conveniente, especialmente si hay una alternativa.

Hay un servicio bastante divertido wavepot.com : un editor de JavaScript en línea en el que debe configurar la función de la señal de sonido de vez en cuando, y esta señal se envía a la tarjeta de sonido. El ejemplo más simple:

 function dsp(t) { return 0.1 * Math.sin(2 * Math.PI * t * 440); } 

Porté el motor a JavaScript, se encuentra en demos / wavepot.js . El contenido del archivo debe insertarse en el editor wavepot.com y puede realizar experimentos. Escribimos nuestros datos en la matriz soundData, escucha, no te olvides de guardar.

También debemos mencionar la variable simulate8bits. Ella, según el nombre, simula un sonido de ocho bits. Si de repente parece que la batería está zumbando y aparece ruido en instrumentos amortiguados con un sonido silencioso, entonces esto es todo, una distorsión de un sonido de ocho bits. Puede intentar deshabilitar esta opción y escuchar la diferencia. El problema es mucho menos notable si no hay silencio en la música.

Conexión


En una versión simple, el circuito se ve así:

 +5V ^ MCU | +-------+ +---+VC | R1 | Pin+---/\/\--+-----> OUT | | | +---+GN | === C1 | +-------+ | | | --- Grnd --- Grnd 

El pin de salida depende del microcontrolador. La resistencia R1 y el condensador C1 deben seleccionarse en función de la carga, el amplificador (si corresponde), etc. No soy ingeniero electrónico y no daré fórmulas; son fáciles de buscar en Google junto con las calculadoras en línea.

Tengo R1 = 130 ohmios, C1 = 0.33 uF. A la salida conecto auriculares chinos comunes.

¿Qué había sobre el sonido de 16 bits?


Como dije anteriormente, cuando multiplicamos dos números de ocho bits (frecuencia y volumen), obtenemos un número de 16 bits. No puede redondearlo a ocho bits, pero genera ambos bytes en 2 canales PWM. Si mezcla estos 2 canales en la relación 1/256, entonces podemos obtener un sonido de 16 bits. La diferencia con los ocho bits es especialmente fácil de escuchar en sonidos y tambores que se desvanecen suavemente en momentos en que solo suena un instrumento.

Conexión de salida de 16 bits:

 +5V ^ MCU | +-------+ +---+VCC | R1 | PinH+---/\/\--+-----> OUT | | | | | R2 | | PinL+---/\/\--+ +---+GND | | | +-------+ === C1 | | --- Grnd --- Grnd 

Es importante mezclar las 2 salidas correctamente: la resistencia R2 debe ser 256 veces mayor que la resistencia R1. Cuanto más preciso, mejor. Desafortunadamente, incluso las resistencias con un error del 1% no dan la precisión requerida. Sin embargo, incluso con una selección no muy precisa de resistencias, la distorsión puede atenuarse notablemente.

Desafortunadamente, cuando se usa sonido de 16 bits, el rendimiento se degrada y 5 canales + ruido ya no tienen tiempo para procesarse en los 256 ciclos de reloj asignados.

¿Es posible en Arduino?


Si puedes. Solo tengo un nano clon chino en ATmega328p, funciona en él. Lo más probable es que otros arduins en el ATmega328p también funcionen. El ATmega168 parece tener los mismos registros de control del temporizador. Lo más probable es que funcionen sin cambios. En otros microcontroladores que necesita verificar, es posible que deba agregar un controlador.

Hay un boceto en demos / arduino328p , pero para que se abra normalmente en el IDE de Arduino, debe copiarlo en la raíz del proyecto.

En el ejemplo, se genera sonido de 16 bits y se utilizan las salidas D9 y D10. Para simplificar, puede limitarse al sonido de 8 bits y usar solo una salida D9.

Como casi todas las arduins operan a 16 MHz, entonces, si lo desea, puede aumentar el número de canales a 8.

¿Qué hay de ATtiny?


ATtiny no tiene multiplicación de hardware. La multiplicación de software que utiliza el compilador es extremadamente lenta y es mejor evitarla. Cuando se utilizan insertos de ensamblador optimizados, el rendimiento se reduce 2 veces en comparación con ATmega. Parece que no tiene sentido usar ATtiny, pero ...

Algunos ATtiny tienen un multiplicador de frecuencia, PLL. Y esto significa que en tales microcontroladores hay 2 características interesantes:

  1. La frecuencia del generador PWM es de 64 MHz, lo que da un período PWM de 250 kHz, que es mucho mejor que 31250 Hz a 8 MHz o 62500 Hz con cuarzo a 16 MHz en cualquier ATmega.
  2. El mismo multiplicador de frecuencia permite que el cristal registre a 16 MHz sin cuarzo.

De ahí la conclusión: algunos ATtiny se pueden usar para generar sonido. Logran procesar los mismos 5 instrumentos + canal de ruido, pero a 16 MHz y no necesitan cuarzo externo.

La desventaja es que la frecuencia ya no se puede aumentar, y los cálculos toman casi todo el tiempo. Para liberar recursos, puede reducir el número de canales o la frecuencia de muestreo.

Otro inconveniente es la necesidad de usar dos temporizadores a la vez: uno para PWM, el segundo para interrupción. Aquí es donde generalmente terminan los temporizadores.

De los microcontroladores PLL que conozco, puedo mencionar ATtiny85 / 45/25 (8 patas), ATtiny861 / 461/261 (20 patas), ATtiny26 (20 patas).

En cuanto a la memoria, la diferencia con ATmega no es grande. En 8kb, varios instrumentos y melodías encajarán perfectamente. En 4kb puedes poner 1-2 instrumentos y 1-2 melodías. Es difícil poner algo en 2 kilobytes, pero si realmente quieres, puedes hacerlo. Es necesario separar los métodos, deshabilitar algunas funciones como el control de volumen sobre los canales, reducir la frecuencia de muestreo y la cantidad de canales. En general, para un aficionado, pero hay un ejemplo de trabajo en ATtiny26.

Los problemas


Hay problemas Y el mayor problema es la velocidad de la informática. El código está completamente escrito en C con pequeños insertos de multiplicación de ensamblador para ATtiny. La optimización se da al compilador y a veces se comporta de manera extraña. Con pequeños cambios que no deberían influir en nada, puede obtener una disminución notable en el rendimiento. Además, cambiar de -Os a -O3 no siempre ayuda. Un ejemplo de ello es el uso de un búfer de 256 bytes. Particularmente desagradable es que no hay garantía de que en las nuevas versiones del compilador no obtengamos una caída en el rendimiento del mismo código.

Otro problema es que el mecanismo de atenuación antes de la siguiente nota no está implementado en absoluto. Es decir cuando en un canal una nota se reemplaza por otra, el sonido antiguo se interrumpe abruptamente, a veces se escucha un pequeño clic. Me gustaría encontrar una manera de deshacerme de esto sin perder rendimiento, pero hasta ahora.

No hay comandos para aumentar / disminuir suavemente el volumen. Es especialmente crítico para tonos de llamada cortos de notificación, donde al final debe hacer una atenuación rápida del volumen para que no haya una interrupción brusca en el sonido. Parte del problema es escribir una serie de comandos con la configuración manual del volumen y una breve pausa.

El enfoque elegido, en principio, no es capaz de proporcionar un sonido naturalista para los instrumentos. Para un sonido más natural, debe dividir los sonidos de los instrumentos en ataque-sostenimiento-liberación, use al menos las primeras 2 partes y con una duración mucho más larga que un período de oscilación. Pero entonces los datos para la herramienta necesitarán mucho más. Hubo una idea de usar tablas de onda más cortas, por ejemplo, en 32 bytes en lugar de 256, pero sin interpolación, la calidad del sonido disminuye drásticamente, y con la interpolación, el rendimiento disminuye. Y otros 8 bits de muestreo claramente no son suficientes para la música, pero esto se puede evitar.

El tamaño del búfer está limitado a 256 muestras. Esto corresponde a aproximadamente 8 milisegundos y este es el período de tiempo integral máximo que puede asignarse a otras tareas. Además, la ejecución de tareas todavía se suspende periódicamente por interrupciones.

Reemplazar el retraso estándar no funciona con mucha precisión para pausas cortas.

Estoy seguro de que esta no es una lista completa.

Referencias


Source: https://habr.com/ru/post/466121/


All Articles