Os microcontroladores AVR são bastante baratos e difundidos. Provavelmente, quase qualquer desenvolvedor incorporado começa com eles. E entre os amadores, a bola do Arduino domina, cujo coração geralmente é o ATmega328p. Certamente muitos se perguntaram: como você pode fazê-los soar?
Se você olhar para os projetos existentes, eles são de vários tipos:
- Geradores de pulso quadrado. Gere usando pinos PWM ou arrancados em interrupções. Em qualquer caso, é obtido um som de chiado muito característico.
- Usando equipamento externo, como um decodificador de MP3.
- Usando o PWM para emitir som de 8 bits (às vezes 16 bits) no formato PCM ou ADPCM. Como a memória nos microcontroladores claramente não é suficiente para isso, eles geralmente usam um cartão SD.
- Usando o PWM para gerar som com base em tabelas de ondas como MIDI.
O último tipo foi especialmente interessante para mim, porque quase não requer equipamento adicional. Apresento minha opção à comunidade. Primeiro, uma pequena demonstração:
Interessado, peço gato.
Então, o equipamento:
- ATmega8 ou ATmega328. Portar para outro ATmega não é difícil. E mesmo no ATtiny, mas mais sobre isso depois;
- Resistor;
- Capacitor;
- Alto-falante ou fone de ouvido;
- Nutrição;
Como tudo.
Um circuito RC simples com um alto-falante é conectado à saída do microcontrolador. A saída é um som de 8 bits com uma frequência de amostragem de 31250Hz. A uma frequência de cristal de 8 MHz, podem ser gerados até 5 canais de som + um canal de ruído para percussão. Nesse caso, quase todo o tempo do processador é usado, mas após o preenchimento do buffer, o processador pode ser ocupado com algo útil além do som:
Este exemplo se encaixa completamente na memória do ATmega8, 5 canais + ruído são processados a uma frequência de cristal de 8 MHz e há pouco tempo para animação no visor.
Neste exemplo, eu também queria mostrar que a biblioteca pode ser usada não apenas como um cartão postal musical comum, mas também para conectar som a projetos existentes, por exemplo, para notificações. E mesmo ao usar apenas um canal de som, as notificações podem ser muito mais interessantes do que um simples tweeter.
E agora os detalhes ...
Tabelas de ondas ou tabelas de ondas
A matemática é extremamente simples. Há uma função de tom periódica, por exemplo,
tom (t) = sin (t * freq / (2 * Pi)) .
Há também uma função para alterar o volume do som fundamental ao longo do tempo, por exemplo,
volume (t) = e ^ (- t) .
No caso mais simples, o som de um instrumento é o produto dessas funções
instrumento (t) = tom (t) * volume (t) :
No gráfico, tudo se parece com isso:

Em seguida, pegamos todos os instrumentos que tocam em um determinado momento e os resumimos com alguns fatores de volume (pseudo-código):
for (i = 0; i < CHANNELS; i++) { value += channels[i].tone(t) * channels[i].volume(t) * channels[i].volume; }
Só é necessário selecionar o volume para que não haja transbordamento. E isso é quase tudo.
O canal de ruído funciona da mesma maneira, mas, em vez de uma função de tom, um gerador de sequência pseudo-aleatória.
A percussão é uma mistura de canal de ruído e onda de baixa frequência, em cerca de 50-70 Hz.
Obviamente, é difícil obter um som de alta qualidade. Mas temos apenas 8 kilobytes para tudo. Espero que isso possa ser perdoado.
O que posso espremer de 8 bits
Inicialmente, concentrei-me no ATmega8. Sem quartzo externo, ele opera a uma frequência de 8 MHz e possui um PWM de 8 bits, que fornece uma frequência de amostragem básica de 8000000/256 = 31250 Hz. Um timer usa o PWM para emitir som e causa uma interrupção durante o estouro para transmitir o próximo valor ao gerador PWM. Conseqüentemente, temos 256 ciclos para calcular o valor da amostra para tudo, incluindo sobrecarga de interrupção, atualização dos parâmetros do canal de som, rastreamento do tempo em que você precisa tocar a próxima nota etc.
Para otimização, usaremos ativamente os seguintes truques:
- Como temos um processador de oito bits, tentaremos tornar as variáveis iguais. Às vezes, usaremos 16 bits.
- Os cálculos são condicionalmente divididos em frequentes e não são. Os primeiros precisam ser calculados para cada amostra, o segundo - com muito menos frequência, uma vez a cada dezenas / centenas de amostras.
- Para distribuir uniformemente a carga ao longo do tempo, usamos um buffer circular. No loop principal do programa, preenchemos o buffer e subtraímos na interrupção. Se tudo estiver bem, o buffer enche mais rápido do que esvazia e temos tempo para outra coisa.
- O código é escrito em C com muita inline. A prática mostra que é muito mais rápido.
- Tudo o que pode ser calculado pelo pré-processador, especialmente com a participação da divisão, é feito pelo pré-processador.
Primeiro, divida o tempo em intervalos de 4 milissegundos (eu os chamei de carrapatos). A uma frequência de amostragem de 31250Hz, obtemos 125 amostras por tick. O fato de que cada amostra deve ser lida deve ser contado em todas as amostras e o restante - uma vez por tick ou menos. Por exemplo, dentro de um tick, o volume do instrumento será constante:
instrument (t) = tone (t) * currentVolume ; e o próprio currentVolume será recalculado uma vez por tick, levando em consideração o volume (t) e o volume selecionado do canal de som.
Uma duração de escala de 4ms foi escolhida com base em um limite simples de 8 bits: com um contador de amostra de oito bits, você pode trabalhar com uma frequência de amostragem de até 64 kHz, com um contador de escala de oito bits, podemos medir o tempo até 1 segundo.
Algum código
O canal em si é descrito por esta estrutura:
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, os dados aqui são divididos em 3 partes:
- Informações sobre a forma de onda, fase, frequência.
waveForm: informações sobre a função tone (t): uma referência a uma matriz de 256 bytes. Define o som, o som do instrumento.
waveSample: byte alto indica o índice atual da matriz waveForm.
waveStep: define a frequência com que waveSample será aumentado ao contar a próxima amostra.
Cada amostra é considerada algo como isto:
int8_t tone = channelData.waveForm[channelData.waveSample >> 8]; channelData.waveSample += channelaData.waveStep; return tone * channelData.currentVolume;
- Informações de volume. Define a função de alterar o volume ao longo do tempo. Como o volume não muda com tanta frequência, você pode recontá-lo com menos frequência, uma vez por tick. Isso é feito assim:
if ((channel->volumeTicksCounter--) == 0 && channel->volumeFormLength > 0) { channel->volumeTicksCounter = channel->volumeTicksPerSample; channel->volumeFormLength--; channel->volumeForm++; } channel->currentVolume = channel->volumeForm * channel->instrumentVolume >> 8;
- Define o volume do canal e o volume atual calculado.
Observe: a forma de onda é de oito bits, o volume também é de oito bits e o resultado é de 16 bits. Com uma pequena perda de desempenho, você pode fazer o som (quase) em 16 bits.
Na luta pela produtividade, tive que recorrer a alguma magia negra.
Exemplo número 1. Como recalcular o volume de canais:
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]); }
Assim, todos os canais recontam o volume uma vez por tick, mas não simultaneamente.
Exemplo número 2. Manter as informações do canal em uma estrutura estática é mais barato que em uma matriz. Sem entrar em detalhes da implementação do wavechannel.h, direi que esse arquivo é inserido no código várias vezes (igual ao número de canais) com diretivas diferentes de pré-processador. Cada inserção cria novas variáveis globais e uma nova função de cálculo de canal, que é incorporada ao código principal:
Exemplo número 3. Se começarmos a tocar a próxima nota um pouco mais tarde, ninguém notará. Vamos imaginar a situação: pegamos o processador com alguma coisa e, durante esse período, o buffer estava quase vazio. Então começamos a preenchê-lo e de repente acontece que uma nova medida está chegando: precisamos atualizar as notas atuais, ler a partir da matriz o que vem a seguir, etc. Se não tivermos tempo, haverá gagueira característica. É muito melhor preencher um pouco o buffer com dados antigos e só então atualizar o estado dos canais.
while ((samplesToWrite) > 4) { // fillBuffer(SAMPLES_PER_TICK); // - updateMusicData(); // }
De uma maneira boa, seria necessário reabastecer o buffer após o loop, mas como temos quase tudo em linha, o tamanho do código é visivelmente inflado.
Música
Um contador de escala de oito bits é usado. Quando zero é atingido, uma nova medida começa, o contador recebe a duração da medida (em ticks), um pouco mais tarde, a matriz de comandos musicais é verificada.
Os dados da música são armazenados em uma matriz de bytes. Está escrito algo como isto:
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 };
Tudo o que começa com DATA_ são macros de pré-processador que expandem os parâmetros no número necessário de bytes de dados.
Por exemplo, o comando DATA_PLAY é expandido em 2 bytes, nos quais estão armazenados: o marcador de comando (1 bit), a pausa antes do próximo comando (3 bits), o número do canal no qual reproduzir a nota (4 bits), informações sobre a nota (8 bits). A limitação mais significativa é que esse comando não pode ser usado por longas pausas, com no máximo 7 medidas. Se precisar de mais, use o comando DATA_WAIT (até 63 medidas). Infelizmente, não encontrei se a macro pode ser expandida para um número diferente de bytes da matriz, dependendo do parâmetro da macro. E mesmo aviso eu não sei como exibir. Talvez você me diga.
Use
No diretório demos, existem vários exemplos para diferentes microcontroladores. Mas, resumindo, aqui está um artigo do leia-me, não tenho nada a acrescentar:
Se você quiser fazer outra coisa além da música, poderá aumentar o tamanho do buffer usando BUFFER_SIZE. O tamanho do buffer deve ser 2 ^ n, mas, infelizmente, com um tamanho de 256, ocorre uma degradação do desempenho. Até eu descobrir.
Para aumentar a produtividade, você pode aumentar a frequência com quartzo externo, reduzir o número de canais, reduzir a frequência de amostragem. Com o último truque, você pode usar a interpolação linear, que compensa um pouco a queda na qualidade do som.
Qualquer atraso não é recomendado, porque O tempo da CPU é desperdiçado. Em vez disso, seu próprio método é implementado no
arquivo microsound / delay.h , que, além da própria pausa, está envolvido no preenchimento do buffer. Esse método pode não funcionar com precisão em pausas curtas, mas em pausas longas mais ou menos saudáveis.
Fazendo sua própria música
Se você escrever comandos manualmente, precisará ouvir o que acontece. Despejar cada mudança no microcontrolador não é conveniente, especialmente se houver uma alternativa.
Existe um serviço bastante engraçado
wavepot.com - um editor de JavaScript on-line no qual você precisa definir a função do sinal sonoro de tempos em tempos, e esse sinal é emitido para a placa de som. O exemplo mais simples:
function dsp(t) { return 0.1 * Math.sin(2 * Math.PI * t * 440); }
Portamos o mecanismo para JavaScript, ele está localizado em
demos / wavepot.js . O conteúdo do arquivo deve ser inserido no editor
wavepot.com e você pode realizar experimentos. Nós escrevemos nossos dados no array soundData, ouça, não esqueça de salvar.
Também devemos mencionar a variável simulate8bits. Ela, de acordo com o nome, simula um som de oito bits. Se de repente parece que a bateria está zumbindo e o ruído aparece em instrumentos amortecidos com um som silencioso, então é isso, uma distorção de um som de oito bits. Você pode tentar desativar esta opção e ouvir a diferença. O problema é muito menos perceptível se não houver silêncio na música.
Ligação
Em uma versão simples, o circuito fica assim:
+5V ^ MCU | +-------+ +---+VC | R1 | Pin+---/\/\--+-----> OUT | | | +---+GN | === C1 | +-------+ | | | --- Grnd --- Grnd
O pino de saída depende do microcontrolador. O resistor R1 e o capacitor C1 devem ser selecionados com base na carga, no amplificador (se houver), etc. Não sou engenheiro eletrônico e não darei fórmulas; elas são fáceis de pesquisar no Google junto com calculadoras on-line.
Eu tenho R1 = 130 Ohms, C1 = 0,33 uF. Na saída, conecto fones de ouvido chineses comuns.
O que havia no som de 16 bits?
Como eu disse acima, quando multiplicamos dois números de oito bits (frequência e volume), obtemos um número de 16 bits. Você não pode arredondar para oito bits, mas gerar os dois bytes em 2 canais PWM. Se você misturar esses 2 canais na proporção 1/256, obteremos um som de 16 bits. A diferença com o de oito bits é especialmente fácil de ouvir em sons e baterias suavemente desbotados nos momentos em que apenas um instrumento soa.
Conexão de saída de 16 bits:
+5V ^ MCU | +-------+ +---+VCC | R1 | PinH+---/\/\--+-----> OUT | | | | | R2 | | PinL+---/\/\--+ +---+GND | | | +-------+ === C1 | | --- Grnd --- Grnd
É importante misturar as 2 saídas corretamente: a resistência R2 deve ser 256 vezes maior que a resistência R1. Quanto mais preciso, melhor. Infelizmente, mesmo resistores com um erro de 1% não fornecem a precisão necessária. No entanto, mesmo com uma seleção não muito precisa de resistores, a distorção pode ser visivelmente atenuada.
Infelizmente, ao usar som de 16 bits, o desempenho diminui e 5 canais + ruído não têm mais tempo para processar nos 256 ciclos de clock alocados.
É possível no Arduino?
Sim você pode. Eu só tenho um nano clone chinês no ATmega328p, ele funciona nele. Provavelmente, outros arduinos no ATmega328p também devem funcionar. O ATmega168 parece ter os mesmos registros de controle do timer. Muito provavelmente eles funcionarão inalterados. Em outros microcontroladores que você precisa verificar, pode ser necessário adicionar um driver.
Há um esboço em
demos / arduino328p , mas para que ele seja aberto normalmente no Arduino IDE, você precisa copiá-lo para a raiz do projeto.
No exemplo, um som de 16 bits é gerado e as saídas D9 e D10 são usadas. Para simplificar, você pode limitar-se ao som de 8 bits e usar apenas uma saída D9.
Como quase todos os arduins operam em 16 MHz, se desejado, você pode aumentar o número de canais para 8.
E o ATtiny?
ATtiny não tem multiplicação de hardware. A multiplicação de software que o compilador usa é muito lenta e é melhor evitar. Ao usar pastilhas de montagem otimizadas, o desempenho cai 2 vezes em comparação com o ATmega. Parece que não faz sentido usar ATtiny, mas ...
Alguns ATtiny têm um multiplicador de frequência, PLL. E isso significa que nesses microcontroladores existem 2 recursos interessantes:
- A frequência do gerador PWM é de 64 MHz, o que fornece um período PWM de 250 kHz, que é muito melhor que 31 250 Hz a 8 MHz ou 62500 Hz com quartzo a 16 MHz em qualquer ATmega.
- O mesmo multiplicador de frequência permite que o cristal faça clock de 16 MHz sem quartzo.
Daí a conclusão: algum ATtiny pode ser usado para gerar som. Eles conseguem processar os mesmos 5 instrumentos + canal de ruído, mas a 16 MHz e não precisam de quartzo externo.
A desvantagem é que a frequência não pode mais ser aumentada e os cálculos demoram quase o tempo todo. Para liberar recursos, você pode reduzir o número de canais ou a taxa de amostragem.
Outro ponto negativo é a necessidade de usar dois temporizadores ao mesmo tempo: um para PWM, o segundo para interrupção. É aqui que os temporizadores geralmente terminam.
Dos microcontroladores PLL que conheço, posso mencionar ATtiny85 / 45/25 (8 pernas), ATtiny861 / 461/261 (20 pernas), ATtiny26 (20 pernas).
Quanto à memória, a diferença com o ATmega não é grande. Em 8kb, vários instrumentos e melodias se encaixam perfeitamente. Em 4kb, você pode colocar 1-2 instrumentos e 1-2 músicas. É difícil colocar algo em 2 kilobytes, mas se você realmente quiser, pode. É necessário separar os métodos, desativar algumas funções, como controle de volume sobre os canais, reduzir a frequência de amostragem e o número de canais. Em geral, para um amador, mas há um exemplo de trabalho no ATtiny26.
Os problemas
Há problemas E o maior problema é a velocidade da computação. O código é completamente escrito em C com pequenas inserções de multiplicação de assembler para ATtiny. A otimização é dada ao compilador e às vezes se comporta de maneira estranha. Com pequenas alterações que parecem não influenciar nada, você pode obter uma diminuição perceptível no desempenho. Além disso, mudar de -Os para -O3 nem sempre ajuda. Um exemplo é o uso de um buffer de 256 bytes. Particularmente desagradável é que não há garantia de que em novas versões do compilador não obteremos uma queda no desempenho no mesmo código.
Outro problema é que o mecanismo de atenuação antes da próxima nota não é implementado. I.e. quando em um canal uma nota é substituída por outra, o som antigo é interrompido abruptamente, às vezes um pequeno clique é ouvido. Eu gostaria de encontrar uma maneira de me livrar disso sem perder o desempenho, mas até agora.
Não há comandos para aumentar / diminuir suavemente o volume. É especialmente crítico para toques curtos de notificação, onde, no final, você precisa fazer uma atenuação rápida do volume, para que não haja interrupção acentuada no som. Parte do problema é escrever uma série de comandos com a configuração manual do volume e uma breve pausa.
A abordagem escolhida, em princípio, não é capaz de fornecer um som naturalista para os instrumentos. Para um som mais natural, você precisa dividir os sons dos instrumentos em liberação de ataque-sustentação, usar pelo menos as 2 primeiras partes e com uma duração muito mais longa que um período de oscilação. Mas os dados da ferramenta precisarão de muito mais. Havia uma idéia de usar tabelas de ondas mais curtas, por exemplo, em 32 bytes em vez de 256, mas sem interpolação, a qualidade do som diminui drasticamente e, com a interpolação, o desempenho diminui. E outros 8 bits de amostragem claramente não são suficientes para música, mas isso pode ser contornado.
O tamanho do buffer é limitado a 256 amostras. Isso corresponde a aproximadamente 8 milissegundos e é o período máximo de tempo integral que pode ser concedido a outras tarefas. Ao mesmo tempo, a execução das tarefas ainda é suspensa periodicamente por interrupções.
A substituição do atraso padrão não funciona com muita precisão em pequenas pausas.
Estou certo de que esta não é uma lista completa.
Referências