Un petit synthetiseur 1 bit avec un arduino : le 1bitSynth









Cliquez sur le lien ci-dessous pour voir le montage d'origine.

Montage basé sur le 1 bit AVR Synth de Matthew Sarnoff.




Le dernier projet en cours. Un synthetiseur 1 bit pour générer des sons comme sur les vieux ordinateurs personnels Atari.

Pourquoi 1 bit ? Parce que ce type de synthese voit le jour suite à la variation d'un etat sur une pin en sortie de l'arduino et cet état ne peut être que 0 ou 1 (soit 0V ou 5V)...

Ce type de synthese existe depuis que l'ordinateur à vu le jour. Pour résumer, il s'agit uniquement d'un signal basé sur des impulsions, on ne peut rien faire d'autre avec ce type de montage. Le chip TIA (pour Television Interface Adaptator) était utilisé dans les Atari 2600. Ce chip était au coeur du fonctionnement de ces ordinateurs et gérait l'affichage, le son et la lecture des controlleurs (manette de jeu principalement). Le montage ne se contente pas d'écrire des 0 et des 1, alternativement. Il lit les données dans un registre 16 bits, ce qui va nous permettre de stocker des mini-forme d'ondes dans ces registres et d'appliquer éventuellement des opérations sur ces registres.

Le montage d'origine, (1 bit AVR Synth), conçu par Matthew Sarnoff, était basé sur un ATMEGA 48. Je vais utiliser un arduino nano (parce que j'en ai acheté pour 3$ sur ebay) et vous pourrez utiliser un arduino uno, le brochage est le même (le code aussi...).



Fonctionnalités


Voici la liste des fonctionnalités implémentée par Matthew. Voici la liste de mes modifications.



Synthese sonore sur le 1 bit synth



Comme vu plus haut, le système consiste à faire varier l'état d'une sortie sur l'arduino. Nous utiliserons ici la sortie D8.

Une forme d'onde basique, stockée dans un registre 16 bits est lue, bit après bit. Le programme est initialisée avec 7 formes d'ondes préconfigurées, une huitième vide (celle qui servira pour la forme d'onde noise) et sur mon programme, une neuvième permettant de stocker une forme d'onde utilisateur. L'ensemble de ces formes d'ondes est stocké dans un tableau nommé waveforms[]. Après avoir sélectionné une forme d'onde, celle-ci est lue dans le tableau waveforms[] et stockée dans l'entier waveform qui servira de registre. Vous avez bien lu, la forme d'onde tient dans un entier...

static uint16_t waveforms[9] = { 0b1100000000000000, // 49152 0b1111111100000000, // 65280 0b1110111111101111, // 61423 0b1011011011011011, // 46811 0b0010100001110110, // 10358 0b1010101011010101, // 43733 0b1010101010101010, // 43690 0b0000000000000000, // noise 0b0000000000000000 // dummy (user) };
Vous pouvez bien entendu, remplacer ces formes d'ondes par d'autres...

Le principe est donc de faire varier la sortie D8, en fonction de l'état de chaque bit lu sucessivement dans le registre 16 bits. On commence par le premier, le suivant, jusqu'au 16eme et on recommence. Comment faire varier la fréquence du son ? C'est simple ! En lisant plus où moins vite ce registre et donc, en mettant plus ou moins vite la sortie D8 à jour.

Pour gèrer cette mise à jour, à une vitesse précise et régulière, nous utilisons les interruptions.

Nous utiliserons le timer 1. Le prescaler est initialisé à 8. Donc le timer est incrémenté à 16 / 8 = 2Mhz.

Le mode CTC (Clear Timer on Compare)est initialisé. Ce qui veut dire que lorsque le timer aura atteint la valeur initialisée dans OCR1A, il sera remis à zero et une interruption sera déclenchée. Le vecteur d'interruption utilisé sera dans ce cas, TIMER1_COMPA_vect.

L'interruption est initialisée dans la partie setup().

cli(); // disable interrupts TCCR1A = 0; // set entire TCCR1A register to 0 TCCR1B = 0; // same for TCCR1B TCNT1 = 0; // initialize counter value to 0 OCR1A = 0; // reset OCR1A TCCR1B |= (1 << WGM12); // turn on CTC mode TCCR1B |= (1 << CS11); // Set CS11 bit for 8 prescaler TIMSK1 |= (1 << OCIE1A); // enable timer compare interrupt waveform = waveforms[wavenum]; // Charger le registre de forme d'onde update_pitch(); // Updater le pitch oscillateur sei(); // enable interrupts
La fréquence de mise à jour de la pin D8 est fonction de la variable pitch. Cette variable pitch est mise à jour dans la procédure update_pitch().

Il y a 3 possibilités pour pitch : réglage manuel si le mode midi n'est pas actif (MIDI sur Off), réglage automatique, si le midi est actif, il suffit de lire dans un tableau pré-initialisée, ou bien réglage du pitch en fonction de la tension présente du l'entrée A1, l'entrée CV mais dans ce cas, le son ne sortira que si le gate est déclenchée donc entrée D2 à l'état haut.

Matthew avait calculé ce tableau pour l'ATMEGA48 qui tournait à 12Mhz, j'ai refait le tableau pour un ATMEGA328P qui tourne à 16Mhz. En fonction de la note midi qui est jouée, on lit ce tableau à un index qui correspond au numéro de la note midi jouée. On récupère ainsi une valeur qui servira à initialiser le registre OCR1A, ainsi, l'interruption sera déclenchée à une fréquence bien précise.

Voici ce tableau pour un ATMEGA328P à 16Mhz :

uint16_t freqvals[128] PROGMEM = { // Values for 16Mhz clocks 65535, 65535, 65535, 65535, 65535, 65535, 65535, 65535, 65535, 65535, 65535, 64792, 61155, 57723, 54483, 51425, 48539, 45814, 43243, 40816, 38525, 36363, 34322, 32395, 30577, 28861, 27241, 25712, 24269, 22907, 21621, 20407, 19262, 18181, 17160, 16197, 15288, 14430, 13620, 12855, 12134, 11453, 10810, 10203, 9630, 9090, 8580, 8098, 7644, 7214, 6809, 6427, 6066, 5726, 5404, 5101, 4815, 4544, 4289, 4049, 3821, 3607, 3404, 3213, 3033, 2862, 2702, 2550, 2407, 2272, 2144, 2024, 1910, 1803, 1702, 1606, 1516, 1431, 1350, 1275, 1203, 1135, 1072, 1011, 955, 901, 850, 803, 757, 715, 675, 637, 601, 567, 535, 505, 477, 450, 425, 401, 378, 357, 337, 318, 300, 283, 267, 252, 238, 224, 212, 200, 189, 178, 168, 168, 149, 141, 133, 126, 118, 112, 105, 99, 94, 88, 83, 79 };
Par exemple, le LA qui se trouve grosso-modo au milieu du clavier a une fréquence de 440hz. C'est la note midi n°56. On lit l'index dans le tableau, on tombe sur la valeur 4544. Si on fait les calculs avec une fréquence de processeur à 16Mhz et un préscaler de 8, cela donne 16000000/8/4544 = 440,14. L'interruption sera exécutée 440 fois par seconde, ce qui tombe bien pour la LA :) !!!

Nous avons vu que le registre de travail s'appelle waveform. Nous pourrions utiliser un index pour lire les bit un par un mais la technique utilisée sera celle du registre à décalage. On décale l'ensemble du registre de 1 bit. Pour cela on lit le bit 0, on le mémorise, on décale ensuite tous les bits vers la droite (le 15 dans le 14, le 14 dans le 13, etc ... le 1 dans le 0. Et on replace l'ancienne valeur du bit n°0 dans le bit n°15. Il suffit ensuite de lire le bit n°15 et à chaque cycle il prendra l'ancienne valeur du bit n°0. Nous pouvons donc lire toujours le même n° de bit c'est plus simple.

Donc, pour résumer, voici ce que va faire l'interruption :

1) Stocker le bit n° 0 du registre de travail (shiftout = waveform & 1;)

2) Décaler tous les bits d'une position (waveform >>= 1;)

3) restaurer le bit n°15 avec l'ancien bit n°0 (waveform |= (1 << 15);)

Et comme ce bit vient d'être lu et stocké, on se contente de reporter sa valeur sur la pin D8.AUDIOON ou AUDIOOFF Simple...


La forme d'onde n°7 est celle du bruit qui nécessite un calcul avec la fonction lfsr_random(), ce qui explique le test en début d'interruption)

ISR(TIMER1_COMPA_vect) { uint8_t shiftout; if (wavenum != 7) { shiftout = waveform & 1; waveform >>= 1; if (shiftout) waveform |= (1 << 15); } else { shiftout = lfsr_rand() & 1; } // apply envelope shiftout &= envval; // mute if note off shiftout &= noteon; // update audio pin if (shiftout) AUDIOON; else AUDIOOFF; }
Voila, grosso-modo, la façon donc la pin D8 va être mise à jour en fonction de la fréquence voulue. L'autre petit truc, c'est shiftout &= envval; et shiftout &= noteon;. Shiftout est masqué en fonction du contenu de noteon et de envval. Ce qui veut dire en clair que si envval=0 et que noteon=0, dans ce cas, shiftout vaudra toujours 0 et donc la sortie D8 restera à l'état bas (0V) en permanence, ce qui ne générera aucun son.

Concernant le calcul du pitch, tout se fait dans la procédure update_pitch(). Le pitch est calculé, soit en fonction de la note midi jouée, soit en fonction des réglages utilisateur. A cette base est ajoutée le décalage de fréquence en fonction du LFO (lfoval)

void update_pitch() { uint16_t newpitch = (usemidi) ? pitch-(lfoval*4U) : 200U + ((pitch-lfoval)*4U); if (newpitch<MIN_PITCH_INTERRUPT) return; if (newpitch != outpitch) { outpitch = newpitch; if (TCNT1 > outpitch) TCNT1 = 0; OCR1A = outpitch; } }


Comme vu plus haut, en fonction de l'état du bit n° 0, la pin D8 est mis à jour. Pour rappel, voici le brochage d'un arduino uno.

pinout-arduino.png

La pin D8 correspond au bit n°0 du port B. Pour passer ce bit à 0 ou à 1 on utilise les define AUDIOON et AUDIOOFF

#define AUDIOON PORTB |= (1<<0) #define AUDIOOFF PORTB &= ~(1<<0)

Matthew a prévu l'utilisation d'un LFO pour faire varier la fréquence de l'oscillateur lorsqu'une note est jouée. On a trois paramètres pour ce LFO :

- Sa fréquence : LFO speed
- Son action sur le pitch : LFO Depth
- Et la forme d'onde du LFO : triangle, Saw UP, Saw down, square, 1/2 square, 1/2 saw up, 1/2 saw down, random

Le LFO est mis à jour via la procédure update_lfo(). En fonction des réglages (fréquence du LFO, sa forme d'onde), la variable lfoval est mise à jour et update_lfo() est exécutée dans la boucle principale loop()

void update_lfo() { if (!lfodepth || !lfofreq) { lfoval=0; return; } // Verifie si le lfo doit être mise a jour // si le temps "RefreshRateLfo" est depasse if (millis() < lastlfocnt+RefreshRateLfo) return; lastlfocnt=millis(); //------------------------------- // calcul de lfoval // en fonction de sa forme d'onde //------------------------------- switch (lfowavenum) { case 0: // triangle lfoval = (lfotimer*lfodelta) >> 8; if (lfotimer >= lfofreq/2) lfoval = lfodepth - lfoval; break; case 1: // sawtooth up lfoval = (lfotimer*lfodelta) >> 8; break; case 2: // sawtooth down lfoval = lfodepth - ((lfotimer*lfodelta) >> 8); break; case 3: // square lfoval = (lfotimer >= lfofreq/2) ? lfodepth : 0; break; case 4: // half square lfoval = (lfotimer < lfofreq/4) ? lfodepth : 0; break; case 5: // half sawtooth up if (lfotimer < lfofreq/2) lfoval = (lfotimer*lfodelta) >> 7; else lfoval = 0; break; case 6: // half sawtooth down if (lfotimer < lfofreq/2) lfoval = lfodepth - ((lfotimer*lfodelta) >> 7); else lfoval = 0; break; case 7: // random if (lfotimer == 0) lfoval = lfsr_rand() % lfodepth; break; } lfotimer++; if (lfotimer >= lfofreq) lfotimer = 0; }




Le midi



Petit changement par rapport à ce qui était prévu. J'ai intégré la librairie midi, je voulais gérer des paramètres supplémentaires via des controleurs, éventuellement plus tard du pitch-bend, bref, le code par interruption commencait à devenir plus lourd et si je rafraichis fréquemment les controleurs, je serai obligé de ne gérer que le buffer midi dans l'interruption et du code à part pour lire les controleurs, le pitch-bend, etc, ce qui revient à réécrire en partie l'équivalent de la bibliothèque midi.

Déclaration de l'utilisation de la librairie midi

#include <MIDI.h>

Déclaration des handlers
// Initialisation Handler Midi //---------------------------- MIDI.setHandleNoteOn(MyHandleNoteOnCV); // handler note on MIDI.setHandleNoteOff(MyHandleNoteOffCV); // handler note off MIDI.setHandleControlChange(MyHandleCtrlCV); // handler CC

Le MIDI.read() est se fait dans la boucle principale loop()

La gestion des controleurs se fait dans la routine MyHandleCtrlCV()

void MyHandleCtrlCV(byte channel, byte number, byte value) { if (usemidi!=1) return; switch(number) { case CTRLLFOWAVE : RaffParamLcd[1]=1; lfowavenum=value/16; break; case CTRLLFOFREQ : RaffParamLcd[2]=1; if (!value) { lfofreq=0; break; } if (value<20) { lfofreq=value; break; } lfofreq=value*2; break; case CTRLLFODEPTH : RaffParamLcd[3]=1; if (!value) { lfodepth=0; break; } if (value<20) { lfodepth=value; break; } lfodepth=value*2; break; default: break; } }
Ca correspondance des controleurs est : - 110 : forme d'onde du LFO
- 111 : fréquence du LFO
- 112 : profondeur du LFO

Pour l'instant je ne gère que ca.

La routine MyHandleNoteOffCV() gère les note-off.

void MyHandleNoteOffCV(byte channel, byte mynote, byte velocity) { if (usemidi!=1) return; noteon = 0; // midilastnote = 0xFF; if (CountNote) // Si countnote >0 CountNote--; // decrementer jusqu'a 0 si besoin }

La routine MyHandleNoteOnCV() gère les note-on et calcule la variable pitch qui sera utilisée dans l'interruption gèrant la sortie audio.

void MyHandleNoteOnCV(byte channel, byte mynote, byte velocity) { if (usemidi!=1) return; // Si velocity == 0 alors on a recu un note off //--------------------------------------------- if (velocity==0) { MyHandleNoteOffCV(channel,mynote,velocity); return; } noteon = 1; // recalcul de la frequence pitch = pgm_read_word(freqvals+mynote); }




Gestion du LCD



Pour me simplifier la vie j'ai utilisé un LCD géré par du protocole I2C, cela me permet de n'utiliser que 2 fils (les broches A4 et A5 de l'arduino en l'occurence). Mon LCD fait 20 car x 4 lignes et est préréglé sur l'ardresse 0x20. Les librairies Wire.h et LiquidCrystal_I2C.h sont nécessaires.

Ensuite on crée un objet lcd : LiquidCrystal_I2C lcd(0x20,20,4)

La suite du code est la déclaration de symbole utilisateur, symbole Up et down, flèches diverses...

#include <Wire.h> #include <LiquidCrystal_I2C.h> #if defined(ARDUINO) && ARDUINO >= 100 #define printByte(args) write(args); #else #define printByte(args) print(args,BYTE); #endif LiquidCrystal_I2C lcd(0x20,20,4); // sLCD adress 0x27, 16 car, 2 ligne uint8_t symbUp[8] = {0x4,0xe,0x15,0x4,0x4,0x4,0x4}; //car fleche up uint8_t symbDown[8] = {0x4,0x4,0x4,0x4,0x15,0xe,0x4}; //car fleche down uint8_t symbTriUp[8] = {0x4,0xe,0x1f,0x0,0x0,0x0,0x0}; //car fleche up uint8_t symbTriDown[8] = {0x0,0x0,0x0,0x0,0x1f,0xe,0x4}; //car fleche down uint8_t symbTriRight[8] = {0x8,0xc,0xe,0xf,0xe,0xc,0xc8}; //car triangle right uint8_t symbTriLeft[8] = {0x2,0x6,0xe,0x1e,0xe,0x6,0x2}; //car fleche down #define CAR_RIGHT 126 // Numero car fleche vers la droite #define CAR_LEFT 127 // Numero car fleche vers la gauche #define CAR_UP 0 // Numero car fleche vers le haut #define CAR_DOWN 1 // Numero car fleche vers le bas #define CAR_TRIUP 2 // Numero car triangle vers le haut #define CAR_TRIDOWN 3 // Numero car triangle vers le bas #define CAR_TRIRIGHT 4 // Numero car triangle vers la droite #define CAR_TRILEFT 5 // Numero car triangle vers la gauche


Attention, l'I2C utilise des interruptions, il ne faut donc pas utiliser d'affichage LCD au sein d'une autre interruption sous peine de planter le processeur. Je vous dis ca, parce que vous pourriez être tenté de faire du debug sur le LCD dans une interruption. Ca me marche pas :)




Utilisation des encodeurs



Les encodeurs était à l'origine lus par interruptions via le timer 2, l'ennui, c'est que j'avais quelques petits craquements dans l'audio quand les fréquences étaient elevées lorsque les 2 interruptions étaient proches. Les encodeurs seront lus dans la boucle principale loop(). Apparemment, le code est assez rapide pour ne pas perdre trop d'encodage. D'habitude je les lis par interruption mais la, ca me rajoutait des craquements, donc je préfère être un peu moins précis sur les encodeurs et avoir un son plus propre.

Le setup des pins réliées aux encodeurs se fait dans la procédure encodeursSetup();
void encodeursSetup(void) { byte i; //------------------------------------- // Initialisation des pin des encodeurs // Mode input // Resistance pull-up activee //------------------------------------- for(i=0;i<2;i++) { pinMode(pinA[i], INPUT); pinMode(pinB[i], INPUT); digitalWrite(pinA[i],HIGH); digitalWrite(pinB[i],HIGH); } }


Etant donné que nous n'avons que 2 encodeurs, je me suis permis de les lire alternativement, cela se fait avec la variable nencRead qui détermine si je suis en index 0 ou 1. Simple.

Je me permet aussi de sortie de la procédure si l'encodeur n'a pas bougé if (encoded==lastEncoded[nencRead])

Il suffit maintenant le lire les données pour chaque encodeur, en l'occurence les données des pinA et pinB respectifs. Lire les données ne suffit pas, cela ne permet pas de déterminer si l'encodeur a été manoeuvré et dans quel sens. Pour le savoir il faut comparer les données actuelles avec les précédentes.

Pour un encodeur :
Lecture de pinA et stockage dans MSB
Lecture de pinB et stockage dans LSB

On crée ensuite une valeur de 2 bits dont le premier bit est MSB et le second LSB : encoded = (MSB << 1) |LSB;

Ensuite une valeur de 4 bits est créée : les bits 3 et 2 contiennent les anciennes valeurs de l'encodeur et les bits 1 et 0 les valeurs actuelles : sum = (lastEncoded[i] << 2) | encoded; Maintenant, il suffit de regarder l'état de ces 4 bits :

Si nous avons 0b1101 ou 0b0100 ou 0b0010 ou 0b1011 l'encodeur a été tourné à droite, on incrémente son compteur counter[i]++;

Si nous avons 0b1110 ou 0b0111 ou 0b0001 ou 0b1000 l'encodeur a été tourné à gauche et on décrémente son compteur counter[i]--;

Vous remarquerez que la routine scan_encodeurs, ne lit qu'un encodeur à chaque execution, cela se fait avec le flip-flop nencRead... J'ai fait ca pour limiter au maximum le scan des encodeurs et tant qu'ils offrent un temps de réponse correcte, je limite le scan.

void scan_encodeurs(void) { int MSB; // MSB encodeur int LSB; // LSB encodeur int encoded; // Etat sur 4 bits encodeurs int sum; // sum comparaison encodeurs if (nencRead) nencRead=0; else nencRead=1; MSB = bitRead(PIND,pinA[nencRead]); // MSB = most significant bit LSB = bitRead(PIND,pinB[nencRead]); // LSB = least significant bit encoded = (MSB << 1) |LSB; // conversion // Le test suivant permet juste d'accelerer l'ISR // si aucun encodeur n'a ete manoeuvre if (encoded==lastEncoded[nencRead]) return; sum = (lastEncoded[nencRead] << 2) | encoded; // Ajout a la valeur precedent switch(sum) { case 0b1101: case 0b0100: case 0b0010: case 0b1011: counter[nencRead]++; break; case 0b1110: case 0b0111: case 0b0001: case 0b1000: counter[nencRead]--; break; } lastEncoded[nencRead] = encoded; // On memorise la valeur pour la prochaine fois bcounter[nencRead]=counter[nencRead]>>1; // Compteur utilisateur divise par 2 }


On voit donc ici que les compteurs des encodeurs montent et descendent, mais ce compteur est global par encodeur. Je vais utiliser la fonction readEncodeurs() pour retourner une valeur de 1 ou 2 si l'encodeur 1 ou 2 a bougé à gauche et 11 ou 12 si l'encodeur 1 ou 2 a bougé à droite.

#define ENC1DOWN 1 // Encodeur 1 down #define ENC2DOWN 2 // Encodeur 2 down #define ENC1UP 11 // Encodeur 1 up #define ENC2UP 12 // Encodeur 2 up
Dans l'ensemble du programme, pour lire les encodeurs il suffit d'executer la fonction readEncodeurs et la valeur retournée nous dit quel encodeur a bougé et dans quel sens.

Ainsi, je peux différencier 2 procédures, celle qui lit les données des encodeurs en permanence et mets ces données dans un buffer (Et si le coeur vous en dit, vous pouvez toujours la remettre par interruption) et celle qui lit leur valeur quand j'en ai besoin simplement en lisant le buffer.

//-------------------------------------------- // Routine principale de lecture des encodeurs //-------------------------------------------- byte readEncodeurs(void) { byte i,val; val=0; // Lire les 2 encodeurs for (i=0;i<2;i++) { // si la valeur a variee pour un des encodeurs if (lcounter[i]!=bcounter[i]) { // Tester le send // Si UP valeur de 11 a 12 if (bcounter[i]>lcounter[i]) val=i+11; // Si down valeur de 1 a 2 else val=i+1; lcounter[i]=bcounter[i]; return val; } } return 0; // retourne 0 si aucun encodeur a bouge }






Lecture des interrupteurs



Les interrupteurs sont intégrés dans les encodeurs, mais cela importe peu, vous pouvez utilisez des interrupteurs séparés, cela ne change rien au principe.

Le but est de limiter l'utilisation des entrées sorties (Ca permet de vous laisser des pins libres supplémentaires pour rajouter des fonctions). Nous allons constituer un pont de résistance constitué de 3 résistances de 680 ohm , 330 ohm et 1Kohms. La mesure se fera sur une entrée analogique reliée entre la 1K et la 330 ohms.

Cela permet d'avoir une tension variable suivant l'interrupteur qui est actionné. On peut chainer jusqu'a une huitaine d'interrupteur de cette facon en utilisant qu'une seule entrée analogique pour déterminer l'interrupteur qui est appuyé.

Pour connaitre le bouton appuyé, on utilise la fonction readButtons()
byte readButtons() { int c = 0; c=analogRead(A0); // Lit lentree analogique if (c>950) { lastBoutonNav=0; // Remettre flag etat a zero return 0; // Pas de bouton appuyé sortir } if (lastBoutonNav>0) // Si le dernier bouton n'a pas ete relache return 9; // Arrivé ici on est sur que le bouton a ete appuye if (c>150 && c<350) { lastBoutonNav=1; return 1; // bouton menu } if (c>400 && c<600) { lastBoutonNav=2; return 2; // bouton presets } return 0; }




Entrées CV/GATE



La pin D2 est utilisée pour le GATE. J'ai pris D2 car cette pin peut éventuellement être utilisé via une interruption lors d'un changement de son état. Je n'ai pas utilisé d'interruption pour le GATE mais c'est possible sur les pins D2 et D3, donc autant prendre une de ces 2 pins pour une utilisation future.

La pin est protégée par 2 diodes 1N4148 en série, d'un coté à la masse, de l'autre au +5V.

(Le même type de protection est utilisée pour les entrées CV)

Le gate est déclenchée sur 2 conditions. D'une part, le paramètre Midi, dans le premier menu, doit être sur CV/Gate. Ensuite, une tension de 5V doit être présente sur la pin D2, ce qui permet de déclencher le noteon.

Pour lire les CV/gate on passe par la routine ReadCvGate(), si usemidi n'est pas à la valeur 2 (Midi sur CV/Gate dans le premier menu), on sort.

Un ET logique entre la valeur 4 et le portD nous permet de savoir si l'entrée D2 est à l'état haut ou bas.

La tension présente sur A1 déterminera la fréquence. Je mettrai peut être la correspondance adéquat pour faire du volt/Octave, ca serait pas bien dur, il suffit de faire un tableau des tensions pour trouver la note et ensuite chercher la note dans notre tableau habituel pour régler le pitch. Pour l'instant, c'est expérimental...

L'entrée A2 déterminera la fréquence du LFO. Cette fréquence est stocké dans un byte non signé et peut prendre une valeur de 0 à 255. Comme les entrées analogiques sont en 10 bits, il suffit de diviser la valeur lue par 4 pour tomber dans les clous.

void ReadCvGate(void) { int AnalogIn; if (usemidi!=2) // Mode CV/GATE uniquement si usemidi==2 return; // Lecture de la PIN 2 pour déclenchement GATE if (PIND & 0x04) noteon=1; else noteon=0; AnalogIn=analogRead(A1); pitch=4200-AnalogIn*4; AnalogIn=analogRead(A2); if (AnalogIn) lfofreq=AnalogIn/4; else lfofreq=0; RaffParamLcd[2]=1; }




Chargement et sauvegarde des presets



J'ai décidé de me limiter à 16 presets et de ne pas les nommer comme je fais habituellement, ce qui permet d'avoir une quantité de données à sauvegarder assez compacte et je peux donc utiliser l'EEPROM interne de l'arduino

Rien de particulier de ce coté la. LoadPreset() charge un preset, SavePreset() le sauvegarde. Les fonctions utilisées sont celles fournies en standard par l'arduino. EEPROM.read() et EEPROM.write().

A la première execution du code il est quand même nécessaire d'initialiser l'EEPROM. Pour cela, on reste appuyé sur l'encodeur 2 au premier démarrage et l'EEPROM sera formatée.

if (valbouton==B_ENC2) // Si interrupteur encodeur 2 appuye initEEPROM=1; // Valider la possible initialisation de l'EEPROM




Montage



Voici le montage utilisé. Vous pouvez le réaliser avec un arduino uno ou un arduino nano, c'est le même brochage. L'alimentation se fait entre 7V et 9V continu sur la prise Vin de l'arduino. On utilise le régulateur 5V intégré pour alimenter le LCD, l'optocoupleur et le 5V pour tester les boutons.

On voit que le schéma est très simple. L'optocoupleur est branché entre la pin RX et la prise din pour l'entrée midi. Un interrupteur permet d'isoler l'entrée midi-in de l'arduino pour le programmer.

Depuis la rédaction de cet article j'ai rajouté une prise midi-thru. J'utilise un 74HC14 pour dupliquer l'entrée midi-in et réinjecter les données sur le midi-thru.

Le point milieu des encodeurs est à la masse et les pinA et pinB sont reliées sur les pin D4 à D7.

La pin D8 est reliée à un pont diviseur constitué par 2 résistances 1K et 10K. Le point milieu constitue la sortie Audio OUT, et est filtrée par un condensateur de 100nF.

La chaine d'interrupteur est branchée sur un pont avec une résistance de 1K + 330 ohms + 680 ohms.

L'écran LCD est alimenté en 5V depuis l'arduino et les entrée SDA et SCL de l'I2C sont reliés sur les pin A4 et A5. Elles sont portées à 5V via 2 resistances de pull-up (pas forcément obligatoires mais c'est plus propre, surtout si vous reliez d'autres composants I2C en parallèle comme une EEPROM par exemple.)

Les entrées CV/Gate, sont protégées par des diodes en série afin de limiter la casse sur les entrées de l'arduino si la tension présente sur les jacks dépasse les 5V.

C'est tout....







La construction



Voici les étapes de la construction de ce synthé.

J'ai utilisé une base arduino nano our sa compacité et pour son brochage au pas de 2.54mm, c'est plus pratique pour installer sur un breadboard. Ce module comporte le port USB pour la programmation ainsi qu'un régulateur 5V intégré qui alimentera le reste du montage.




DSC07896.JPG


DSC07899.JPG


DSC07902.JPG


J'ai utilisé un boitier monacor AH-103 dont la facade est coulissante, j'ai découpé le logement du LCD à la dremel et percer les trous pour les 2 prises DIN, les 4 jacks et le passage des axes des encodeurs. Pour ne pas avoir les fixations du LCD qui dépasse de l'autre coté, j'ai collé les vis à l'araldite coté intérieur.

DSC07903.JPG


DSC07904.JPG


J'ai rajouté des entretoises en caoutchouc entre le circuit du LCD et le boitier.

DSC07905.JPG


Le bouzin en cours de test...

SC07907.JPG


La petite platine avec les 2 encodeurs munis de leur interrupteurs intégrés.

DSC07911.JPG


Apparemment, tout à l'air de fonctionner.

DSC07923.JPG


Les diodes de protection pour l'entrée digitale D2 et les entrées analogiques A1 et A2

DSC07940.JPG


Le CI de gauche est l'optocoupleur pour isoler l'entrée midi de l'arduino, celui de droite est un 74HC14 et sert à dupliquer l'entrée midi pour faire une midi-thru.

DSC07941.JPG


Je prépare la facade. Elle sera imprimée sur une feuille A4. Désolé pour la tache au milieu, je crois que le capteur de mon appareil a un problème... La feuille A4 est recouverte d'un film adhésif.

DSC07952.JPG


La feuille sera découpée au cutter, pour le passage des axes des encodeurs, les jacks et les 2 prises DIN.

DSC07953.JPG


DSC07956.JPG


La platine d'alimentation. J'utilise un bloc secteur alternatif, je redresse avec un pont, puis filtrage, suivi d'un 7805 mais au lieu de relier la patte du milieu à la masse, j'intercale 2 diodes 1N4148 en série, ce qui me permet de monter la régulation à 6.2V afin d'attaquer l'entrée Vin de l'arduino et le laisser chuter les 1.2V restant. On peut attaquer en 9V directement, c'est même l'intérêt mais cela fait chauffer pas mal le petit régulateur low-drop intégré, su coup j'utilise cette petite bidouille.

DSC07978.JPG


Une fois que tous les éléments sont dans le boitier, voici ce que ca donne.

DSC07983.JPG


DSC07985.JPG


Les différents menus du synthé.

Oscillateur : avec de haut en bas, forme d'onde,fréquence et mode de déclenchement Midi : Off, On, CV/Gate.

DSC07971.JPG


Le LFO : avec forme d'onde, fréquence du LFO et profondeur de modulation sur la fréquence de l'oscillateur.

DSC07972.JPG


Le générateur d'enveloppe : Fréquence du gate automatique et longueur du gate.

DSC07973.JPG


Taux de rafraichissement du LFO et de l'enveloppe : réglage en ms pour les 2.

DSC07974.JPG


Forme d'onde utilisateur : permet de créer une forme d'onde 16 bits en mettant les bits à 1 ou 0, un encodeur sélectionne le bit, l'autre la valeur.

DSC07975.JPG


Le menu des presets : 16 presets maxi avec chargement ou sauvegarde.

DSC07976.JPG




Code source et facade



Cliquez sur le lien ci-dessous pour récupèrer l'archive du code source.

Code source 1bitSynthfa



Le boitier utilisé est un boitier Monacor type AH-103.

Cliquez sur le lien ci-dessous pour récupérer l'archive contenant la sérigraphie de la facade.

facade.png