Les microcontrôleurs AVR sont assez bon marché et répandus. Probablement, presque tous les développeurs intégrés commencent par eux. Et parmi les amateurs, l'Arduino domine le ballon, dont le cœur est généralement l'ATmega328p. Beaucoup se sont sûrement demandé: comment pouvez-vous les faire sonner?
Si vous regardez des projets existants, ils sont de plusieurs types:
- Générateurs d'impulsions carrées. Générez en utilisant des broches PWM ou Yank dans les interruptions. Dans tous les cas, un grincement très caractéristique est obtenu.
- Utiliser un équipement externe tel qu'un décodeur MP3.
- Utilisation de PWM pour produire un son 8 bits (parfois 16 bits) au format PCM ou ADPCM. Comme la mémoire des microcontrôleurs n'est clairement pas suffisante pour cela, ils utilisent généralement une carte SD.
- Utiliser PWM pour générer du son basé sur des tables d'ondes comme MIDI.
Ce dernier type était particulièrement intéressant pour moi, car ne nécessite presque pas d'équipement supplémentaire. Je présente mon option à la communauté. Tout d'abord, une petite démo:
Intéressé, je demande chat.
Ainsi, l'équipement:
- ATmega8 ou ATmega328. Le portage sur d'autres ATmega n'est pas difficile. Et même sur ATtiny, mais plus à ce sujet plus tard;
- Résistance;
- Condensateur;
- Haut-parleur ou casque;
- La nutrition;
Comme tout.
Un simple circuit RC avec un haut-parleur est connecté à la sortie du microcontrôleur. La sortie est un son 8 bits avec une fréquence d'échantillonnage de 31250 Hz. À une fréquence cristalline de 8 MHz, jusqu'à 5 canaux sonores + un canal de bruit pour les percussions peuvent être générés. Dans ce cas, presque tout le temps du processeur est utilisé, mais après avoir rempli le tampon, le processeur peut être occupé par quelque chose d'utile en plus du son:
Cet exemple s'intègre parfaitement dans la mémoire ATmega8, 5 canaux + bruit sont traités à une fréquence cristalline de 8 MHz et il y a peu de temps pour l'animation sur l'écran.
Dans cet exemple, je voulais également montrer que la bibliothèque peut être utilisée non seulement comme une carte postale musicale ordinaire, mais aussi pour connecter le son à des projets existants, par exemple, pour les notifications. Et même lorsque vous n'utilisez qu'un seul canal sonore, les notifications peuvent être beaucoup plus intéressantes qu'un simple tweeter.
Et maintenant les détails ...
Tables ou tables d'ondes
Le calcul est extrêmement simple. Il existe une fonction de tonalité périodique, par exemple
ton (t) = sin (t * freq / (2 * Pi)) .
Il existe également une fonction pour changer le volume de la tonalité fondamentale dans le temps, par exemple
volume (t) = e ^ (- t) .
Dans le cas le plus simple, le son d'un instrument est le produit de ces fonctions
instrument (t) = tonalité (t) * volume (t) :
Sur le graphique, tout ressemble à ceci:

Ensuite, nous prenons tous les instruments qui sonnent à un moment donné et les résumons avec quelques facteurs de volume (pseudo-code):
for (i = 0; i < CHANNELS; i++) { value += channels[i].tone(t) * channels[i].volume(t) * channels[i].volume; }
Il suffit de sélectionner le volume pour qu'il n'y ait pas de débordement. Et c’est presque tout.
Le canal de bruit fonctionne à peu près de la même manière, mais au lieu d'une fonction de tonalité, un générateur de séquence pseudo-aléatoire.
La percussion est un mélange de canal de bruit et d'onde basse fréquence, à environ 50-70 Hz.
Bien sûr, la qualité sonore de cette manière est difficile à obtenir. Mais nous n'avons que 8 kilo-octets pour tout. J'espère que cela peut être pardonné.
Que puis-je extraire de 8 bits
Au départ, je me suis concentré sur ATmega8. Sans quartz externe, il fonctionne à une fréquence de 8 MHz et possède un PWM 8 bits, ce qui donne une fréquence d'échantillonnage de base de 8000000/256 = 31250 Hz. Un temporisateur utilise PWM pour produire du son, et il provoque une interruption pendant le débordement pour transmettre la valeur suivante au générateur PWM. En conséquence, nous avons 256 cycles pour calculer la valeur de l'échantillon pour tout, y compris la surcharge d'interruption, la mise à jour des paramètres du canal sonore, le suivi du moment où vous devez jouer la note suivante, etc.
Pour l'optimisation, nous utiliserons activement les astuces suivantes:
- Puisque nous avons un processeur huit bits, nous essaierons de rendre les variables identiques. Parfois, nous utilisons 16 bits.
- Les calculs sont conditionnellement divisés en fréquents et non. Les premiers doivent être calculés pour chaque échantillon, le second - beaucoup moins souvent, une fois tous les quelques dizaines / centaines d'échantillons.
- Pour répartir uniformément la charge dans le temps, nous utilisons un tampon circulaire. Dans la boucle principale du programme, nous remplissons le tampon, le soustrayons dans l'interruption. Si tout va bien, le tampon se remplit plus vite qu'il ne se vide et nous avons le temps pour autre chose.
- Le code est écrit en C avec beaucoup de ligne. La pratique montre que c'est tellement plus rapide.
- Tout ce qui peut être calculé par le préprocesseur, notamment avec la participation de la division, est fait par le préprocesseur.
Tout d'abord, divisez le temps en intervalles de 4 millisecondes (je les ai appelés ticks). À une fréquence d'échantillonnage de 31250 Hz, nous obtenons 125 échantillons par tick. Le fait que chaque échantillon doit être lu doit être compté pour chaque échantillon, et le reste - une fois par tick ou moins. Par exemple, en un seul tick, le volume de l'instrument sera constant:
instrument (t) = tone (t) * currentVolume ; et le volume actuel lui-même sera recalculé une fois par tick en tenant compte du volume (t) et du volume sélectionné du canal sonore.
Une durée de tick de 4 ms a été choisie sur la base d'une simple limite de 8 bits: avec un compteur d'échantillons de huit bits, vous pouvez travailler avec une fréquence d'échantillonnage allant jusqu'à 64 kHz, avec un compteur de ticks de huit bits, nous pouvons mesurer le temps jusqu'à 1 seconde.
Du code
Le canal lui-même est décrit par cette structure:
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;
Conditionnellement, les données ici sont divisées en 3 parties:
- Informations sur la forme d'onde, la phase, la fréquence.
waveForm: informations sur la fonction tone (t): référence à un tableau de 256 octets. Règle la tonalité, le son de l'instrument.
waveSample: l'octet haut indique l'index actuel du tableau waveForm.
waveStep: définit la fréquence à laquelle waveSample sera augmenté lors du comptage du prochain échantillon.
Chaque échantillon est considéré comme quelque chose comme ceci:
int8_t tone = channelData.waveForm[channelData.waveSample >> 8]; channelData.waveSample += channelaData.waveStep; return tone * channelData.currentVolume;
- Informations sur le volume. Définit la fonction de modification du volume au fil du temps. Comme le volume ne change pas si souvent, vous pouvez le recompter moins souvent, une fois par tick. Cela se fait comme ceci:
if ((channel->volumeTicksCounter--) == 0 && channel->volumeFormLength > 0) { channel->volumeTicksCounter = channel->volumeTicksPerSample; channel->volumeFormLength--; channel->volumeForm++; } channel->currentVolume = channel->volumeForm * channel->instrumentVolume >> 8;
- Règle le volume du canal et le volume actuel calculé.
Remarque: la forme d'onde est de huit bits, le volume est également de huit bits et le résultat est de 16 bits. Avec une légère perte de performances, vous pouvez rendre le son (presque) 16 bits.
Dans la lutte pour la productivité, j'ai dû recourir à de la magie noire.
Exemple numéro 1. Comment recalculer le volume des canaux:
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]); }
Ainsi, tous les canaux recomptent le volume une fois par tick, mais pas simultanément.
Exemple numéro 2. Conserver les informations de canal dans une structure statique est moins cher que dans un tableau. Sans entrer dans les détails de l'implémentation de wavechannel.h je dirai que ce fichier est inséré plusieurs fois dans le code (égal au nombre de canaux) avec différentes directives de préprocesseur. Chaque insert crée de nouvelles variables globales et une nouvelle fonction de calcul de canal, qui est ensuite intégrée dans le code principal:
Exemple numéro 3. Si nous commençons à jouer la note suivante un peu plus tard, alors personne ne le remarquera. Imaginons la situation: nous avons pris le processeur avec quelque chose et pendant ce temps, le tampon était presque vide. Ensuite, nous commençons à le remplir et tout à coup, il s'avère qu'une nouvelle mesure arrive: nous devons mettre à jour les notes actuelles, lire dans le tableau la suite, etc. Si nous n'avons pas le temps, il y aura un bégaiement caractéristique. Il est préférable de remplir un peu le tampon d'anciennes données et de ne mettre à jour que l'état des canaux.
while ((samplesToWrite) > 4) { // fillBuffer(SAMPLES_PER_TICK); // - updateMusicData(); // }
Dans le bon sens, il serait nécessaire de recharger le tampon après la boucle, mais comme nous avons presque tout en ligne, la taille du code est sensiblement gonflée.
La musique
Un compteur de ticks de huit bits est utilisé. Lorsque zéro est atteint, une nouvelle mesure commence, le compteur se voit attribuer la durée de la mesure (en ticks), un peu plus tard le tableau des commandes musicales est vérifié.
Les données musicales sont stockées dans un tableau d'octets. Il est écrit quelque chose comme ceci:
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 };
Tout ce qui commence par DATA_ sont des macros de préprocesseur qui développent les paramètres dans le nombre d'octets de données requis.
Par exemple, la commande DATA_PLAY est développée en 2 octets, dans lesquels sont stockés: le marqueur de commande (1 bit), la pause avant la commande suivante (3 bits), le numéro de canal sur lequel jouer la note (4 bits), les informations sur la note (8 bits). La limitation la plus importante est que cette commande ne peut pas être utilisée pour de longues pauses, avec un maximum de 7 mesures. Si vous en avez besoin de plus, vous devez utiliser la commande DATA_WAIT (jusqu'à 63 mesures). Malheureusement, je n'ai pas trouvé si la macro peut être étendue dans un nombre différent d'octets du tableau en fonction du paramètre de macro. Et même avertissement je ne sais pas comment afficher. Tu me dis peut-être.
Utiliser
Dans le répertoire des démos, il existe plusieurs exemples pour différents microcontrôleurs. Mais en bref, voici un morceau de readme, je n'ai vraiment rien à ajouter:
Si vous voulez faire autre chose que de la musique, vous pouvez augmenter la taille du tampon en utilisant BUFFER_SIZE. La taille du tampon doit être de 2 ^ n, mais, malheureusement, avec une taille de 256, une dégradation des performances se produit. Jusqu'à ce que je le comprenne.
Pour augmenter la productivité, vous pouvez augmenter la fréquence avec du quartz externe, vous pouvez réduire le nombre de canaux, vous pouvez réduire la fréquence d'échantillonnage. Avec la dernière astuce, vous pouvez utiliser une interpolation linéaire, qui compense quelque peu la baisse de la qualité sonore.
Tout retard n'est pas recommandé, car Le temps CPU est perdu. Au lieu de cela, sa propre méthode est implémentée dans le
fichier microsound / delay.h , qui, en plus de la pause elle-même, est impliquée dans le remplissage du tampon. Cette méthode peut ne pas fonctionner très précisément sur des pauses courtes, mais sur des pauses longues plus ou moins saines.
Faire votre propre musique
Si vous écrivez des commandes manuellement, vous devez pouvoir écouter ce qui se passe. Verser chaque changement dans le microcontrôleur n'est pas pratique, surtout s'il existe une alternative.
Il existe un service assez amusant,
wavepot.com - un éditeur JavaScript en ligne dans lequel vous devez définir de temps en temps la fonction du signal sonore, et ce signal est émis vers la carte son. L'exemple le plus simple:
function dsp(t) { return 0.1 * Math.sin(2 * Math.PI * t * 440); }
J'ai porté le moteur sur JavaScript, il se trouve dans
demos / wavepot.js . Le contenu du fichier doit être inséré dans l'éditeur
wavepot.com et vous pouvez effectuer des expériences. Nous écrivons nos données dans le tableau soundData, écoutez, n'oubliez pas de sauvegarder.
Il faut également mentionner la variable simulate8bits. Elle, selon le nom, simule un son huit bits. Si soudain, il semble que les tambours bourdonnent et que du bruit apparaisse dans les instruments amortis avec un son calme, alors c'est tout, une distorsion d'un son de huit bits. Vous pouvez essayer de désactiver cette option et écouter la différence. Le problème est beaucoup moins perceptible s'il n'y a pas de silence dans la musique.
Connexion
Dans une version simple, le circuit ressemble à ceci:
+5V ^ MCU | +-------+ +---+VC | R1 | Pin+---/\/\--+-----> OUT | | | +---+GN | === C1 | +-------+ | | | --- Grnd --- Grnd
La broche de sortie dépend du microcontrôleur. La résistance R1 et le condensateur C1 doivent être sélectionnés en fonction de la charge, de l'amplificateur (le cas échéant), etc. Je ne suis pas ingénieur en électronique et je ne donnerai pas de formules; elles sont faciles à utiliser avec les calculatrices en ligne.
J'ai R1 = 130 Ohms, C1 = 0,33 uF. À la sortie, je connecte un casque chinois ordinaire.
Qu'y avait-il à propos du son 16 bits?
Comme je l'ai dit plus haut, lorsque nous multiplions deux nombres à huit bits (fréquence et volume), nous obtenons un nombre à 16 bits. Vous ne pouvez pas l'arrondir à huit bits, mais produire les deux octets sur 2 canaux PWM. Si vous mélangez ces 2 canaux dans le rapport 1/256, nous pouvons obtenir un son 16 bits. La différence avec le huit bits est particulièrement facile à entendre sur les sons et les tambours qui s'estompent en douceur dans les moments où un seul instrument sonne.
Connexion de sortie 16 bits:
+5V ^ MCU | +-------+ +---+VCC | R1 | PinH+---/\/\--+-----> OUT | | | | | R2 | | PinL+---/\/\--+ +---+GND | | | +-------+ === C1 | | --- Grnd --- Grnd
Il est important de bien mélanger les 2 sorties: la résistance R2 doit être 256 fois supérieure à la résistance R1. Plus c'est précis, mieux c'est. Malheureusement, même les résistances avec une erreur de 1% ne donnent pas la précision requise. Cependant, même avec une sélection de résistances peu précise, la distorsion peut être sensiblement atténuée.
Malheureusement, lors de l'utilisation d'un son 16 bits, les performances se dégradent et 5 canaux + bruit n'ont plus le temps de traiter dans les 256 cycles d'horloge alloués.
Est-ce possible sur Arduino?
Oui tu peux. Je n'ai qu'un nano clone chinois sur ATmega328p, ça marche dessus. Très probablement, d'autres Arduins sur l'ATmega328p devraient également fonctionner. L'ATmega168 semble avoir les mêmes registres de contrôle de minuterie. Ils fonctionneront très probablement inchangés. Sur les autres microcontrôleurs que vous devez vérifier, vous devrez peut-être ajouter un pilote.
Il y a un croquis dans
demos / arduino328p , mais pour qu'il s'ouvre normalement dans l'IDE Arduino, vous devez le copier à la racine du projet.
Dans l'exemple, un son 16 bits est généré et les sorties D9 et D10 sont utilisées. Pour simplifier, vous pouvez vous limiter au son 8 bits et utiliser une seule sortie D9.
Étant donné que presque tous les arduins fonctionnent à 16 MHz, vous pouvez, si vous le souhaitez, augmenter le nombre de canaux à 8.
Et ATtiny?
ATtiny n'a pas de multiplication matérielle. La multiplication logicielle que le compilateur utilise est extrêmement lente et il vaut mieux l'éviter. Lors de l'utilisation d'inserts d'assemblage optimisés, les performances chutent de 2 fois par rapport à ATmega. Il semblerait que cela ne sert à rien d'utiliser ATtiny, mais ...
Certains ATtiny ont un multiplicateur de fréquence, PLL. Et cela signifie que sur ces microcontrôleurs, il existe 2 fonctionnalités intéressantes:
- La fréquence du générateur PWM est de 64 MHz, ce qui donne une période PWM de 250 kHz, ce qui est bien mieux que 31250 Hz à 8 MHz ou 62500 Hz avec du quartz à 16 MHz sur n'importe quel ATmega.
- Le même multiplicateur de fréquence permet au cristal de cadencer à 16 MHz sans quartz.
D'où la conclusion: certains ATtiny peuvent être utilisés pour générer du son. Ils parviennent à traiter les mêmes 5 instruments + canal de bruit, mais à 16 MHz et ils n'ont pas besoin de quartz externe.
L'inconvénient est que la fréquence ne peut plus être augmentée et que les calculs prennent presque tout le temps. Pour libérer des ressources, vous pouvez réduire le nombre de canaux ou la fréquence d'échantillonnage.
Un autre inconvénient est la nécessité d'utiliser deux temporisateurs à la fois: un pour PWM, le second pour interruption. C'est là que les chronomètres se terminent généralement.
Parmi les microcontrôleurs PLL que je connais, je peux citer ATtiny85 / 45/25 (8 jambes), ATtiny861 / 461/261 (20 jambes), ATtiny26 (20 jambes).
Quant à la mémoire, la différence avec ATmega n'est pas grande. En 8kb, plusieurs instruments et mélodies s'intégreront parfaitement. En 4 Ko, vous pouvez mettre 1-2 instruments et 1-2 morceaux. Il est difficile de mettre quelque chose en 2 kilo-octets, mais si vous le voulez vraiment, vous le pouvez. Il est nécessaire de séparer les méthodes, de désactiver certaines fonctions telles que le contrôle du volume sur les canaux, de réduire la fréquence d'échantillonnage et le nombre de canaux. En général, pour un amateur, mais il existe un exemple de travail sur ATtiny26.
Les problèmes
Il y a des problèmes. Et le plus gros problème est la vitesse de l'informatique. Le code est entièrement écrit en C avec de petites insertions de multiplication d'assembleur pour ATtiny. L'optimisation est donnée au compilateur et il se comporte parfois étrangement. Avec de petits changements qui ne devraient pas sembler influencer quoi que ce soit, vous pouvez obtenir une diminution notable des performances. De plus, passer de -Os à -O3 n'aide pas toujours. Un tel exemple est l'utilisation d'un tampon de 256 octets. Particulièrement désagréable est qu'il n'y a aucune garantie que dans les nouvelles versions du compilateur, nous n'obtiendrons pas une baisse des performances sur le même code.
Un autre problème est que le mécanisme d'atténuation avant la note suivante n'est pas du tout implémenté. C'est-à-dire quand sur un canal une note est remplacée par une autre, l'ancien son est brusquement interrompu, parfois un petit clic se fait entendre. Je voudrais trouver un moyen de m'en débarrasser sans perdre de performance, mais jusqu'à présent.
Il n'y a pas de commandes pour augmenter / diminuer le volume en douceur. Il est particulièrement important pour les sonneries de notification courtes, où vous devez finalement atténuer rapidement le volume afin qu'il n'y ait pas de coupure nette dans le son. Une partie du problème est en écrivant une série de commandes avec réglage manuel du volume et une courte pause.
L'approche choisie, en principe, n'est pas en mesure de fournir un son naturaliste aux instruments. Pour un son plus naturel, vous devez diviser les sons des instruments en attaque-sustain-release, utiliser au moins les 2 premières parties et avec une durée beaucoup plus longue qu'une période d'oscillation. Mais alors, les données de l'outil auront besoin de beaucoup plus. Il y avait une idée d'utiliser des tables d'ondes plus courtes, par exemple, en 32 octets au lieu de 256, mais sans interpolation, la qualité sonore diminue considérablement, et avec l'interpolation, les performances diminuent. Et un autre 8 bits d'échantillonnage n'est clairement pas suffisant pour la musique, mais cela peut être contourné.
La taille du tampon est limitée à 256 échantillons. Cela correspond à environ 8 millisecondes et c'est la période de temps intégrale maximale qui peut être donnée à d'autres tâches. Dans le même temps, l'exécution des tâches est toujours périodiquement suspendue par des interruptions.
Le remplacement du délai standard ne fonctionne pas très précisément pour les courtes pauses.
Je suis sûr que ce n'est pas une liste complète.
Les références