Table des matières

Synthèse numérique d'un signal périodique

1. Introduction

Cette page montre comment effectuer une synthèse numérique d'un signal périodique sur les deux sorties DAC (Digital-Analog converter) de l'Arduino Due.

2. Convertisseur numérique-analogique

2.a. Caractéristiques du convertisseur

Le microcontrôleur SAM3X8E comporte un convertisseur numérique-analogique 12 bits (DAC) à deux sorties. La fréquence d'échantillonnage peut en principe atteindre 1 MHz. Le niveau de tension de ces sorties d'étend de 0,6 V à 2,7 V environ, pour un entier 12 bits allant de 0 à 4096. La valeur médiane (2048) est la moitié de la tension d'alimentation du microcontrôleur, soit 1,65 V. Le circuit suivant permet d'obtenir une tension avec une valeur médiane nulle et une amplitude totale de 3,3 V.

interfaceArduinoDue-DAC.svgFigure pleine page

Les sorties DAC0 et DAC1 sont branchées sur un amplificateur inverseur de gain 1,44. Le décalage est ajusté avec un potentiomètre multitours. Le réglage se fait en délivrant la valeur médiane 2048 et en tournant le potentiomètre pour annuler la tension de sortie. En plus de l'amplification et du décalage, ce circuit permet d'isoler les sorties DAC du microcontrôleur, qui sont fragiles et dont le courant maximal est de 3 mA. L'alimentation bipolaire du TL082 doit être au moins de +/-5 V. Les sorties peuvent déliver environ 20 mA. Pour brancher une charge de puissance (comme un haut-parleur), il faudra bien-sûr ajouter un étage d'amplification.

2.b. Programmation

Pour programmer le DAC, on peut utiliser trois niveaux de programmation :

  • La fonction analogWrite de l'API Arduino.
  • Les fonctions de l'API Atmel, dont le code source se trouve dans hardware/arduino/sam/system/libsam/source/dacc.c.
  • Accès direct aux registres de programmation du microcontrôleur.

La fonction analogWrite ne permet pas d'utiliser le DAC à une vitesse suffisante pour la synthèse de signaux. Nous allons donc utiliser les fonctions de l'API Atmel. Pour certaines opérations, on sera amené à utiliser directement les registres. La programmation directe du DAC est décrite dans la documentation officielle des microcontrôleurs Atmel SAM3X.

La conversion numérique-analogique peut être délenchée directement par interruption. Pour la synthèse de signaux par table, on utilise une méthode plus souple consistant à déclencher la conversion dans une fonction, elle-même appelée par interruption déclenchée à la fréquence d'échantillonnage (au moyen d'un Timer).

Pour configurer le DAC, on commence par activer l'horloge pour ce périphérique, en appelant la fonction suivante, définie dans hardware/arduino/sam/system/libsam/source/pmc.c (Power management controler) :

pmc_enable_periph_clk(DACC_INTERFACE_ID);   
                   

Le DAC est initialisé avec la fonction suivante (dacc.c) :

dacc_reset(DACC_INTERFACE);
                   

La fonction suivante permet de choisir le mode d'économie d'énergie. Dans le cas présent, on veut que le DAC réagisse au plus vite, donc on désactive le mode sleep :

dacc_set_power_save(DACC_INTERFACE, 0, 0);
                   

Le transfert des nombres 12 bits vers le DAC peut se faire par mot entier de 32 bits (pour utiliser les deux voies), ou par demi-mot de 16 bits (pour utiliser une seule voie). Voici comment faire ce choix :

dacc_set_transfer_mode(DACC_INTERFACE, 1); //  word transfert (1 = 32 bits, 0 = 16 bits)
                    

Le minutage du DAC se fait de la manière suivante :

dacc_set_timing(DACC_INTERFACE, 0x08, 0, 0x10); // refresh = 1024*8 dacc clocks, max speed mode disable, startup time = 1024 dacc clocks
                    

La fonction suivante permet de choisir le réglage de la sortie analogique

dacc_set_analog_control(DACC_INTERFACE, DACC_ACR_IBCTLCH0(0x02)|DACC_ACR_IBCTLCH1(0x02)|DACC_ACR_IBCTLDACCORE(0x01));
                    

Les deux premiers arguments affectent les 2 bits du champ IBCTLCH (Analog Output Current). Le dernier argument affecte les deux bits du champ IBCTLDACCORE (Bias Current Control).

Pour activer une seule voie (DAC0 ou DAC1), on procède de la manière suivante :

dacc_set_channel_selection(DACC_INTERFACE, channel);
                    

L'écriture de la valeur à convertir se fait dans le registre DAC_CDR (Conversion Data Register) de la manière suivante. Avant d'écrire dans le registre, on attend que le bit Transmit Ready Interrupt Flag (TXRDY) du regitre DACC_ISR (Interrupt Status Register) soit à 1, ce qui indique que le DAC est prêt à recevoir un nouveau nombre à convertir.

while (DACC_INTERFACE->DACC_ISR & DACC_ISR_TXRDY == 0) {;} 
uint16_t x = 2048;
DACC_INTERFACE->DACC_CDR = x;
                    

Remarque : pour notre application, on peut se passer de tester le bit TXRDY car l'écriture sera cadencée par interruption.

Pour utiliser les deux voies simultanément, on active le bit TAG du registre DACC_MR (Mode register). Les deux bits du registre DACC_CHER (Channel Enable Register) permettent d'activer les voies; on met ces deux bits à 1. Avec le mode de sélection par TAG, le registre 32 bits DAC_CDR dans lequel on écrira les deux nombres à convertir contient aussi la voie du premier nombre (bits 12 et 13) et la voie du second nombre (bits 28 et 29). Pour notre application, on prépare un masque qui comporte les bits pour envoyer le premier nombre sur la voie 0 et le second sur la voie 1.

DACC_INTERFACE->DACC_MR |= DACC_MR_TAG;
DACC_INTERFACE->DACC_CHER = 0x3;
uint32_t chsel = (1<<13) | (1<<28);
                    

Voici comment se fait l'écriture dans le registre DAC_CDR :

uint16_t x0 = 2048;
uint16_t x1 = 3000;
while (DACC_INTERFACE->DACC_ISR & DACC_ISR_TXRDY == 0) {;} 
DACC_INTERFACE->DACC_CDR = x0 | (x1 << 16) | chsel;
                    

3. Programmation des interruptions

Pour générer un signal, il faut déclencher une interruption à la fréquence d'échantillonnage. La fonction appelée lors de l'interruption sera chargée d'obtenir l'échantillon à convertir dans une table et de l'envoyer au DAC.

Une interruption à intervalle de temps régulier se déclenche avec un chronomètre (Timer). Le microcontrôleur SAM3X8E possède 9 chronomètre-compteurs 32 bits (Timer-Counter). La bibliothèque DueTimer offre une interface simple de programmation d'interruptions par chronomètre. Pour expliquer le fonctionnement du chronomètre, nous allons plutôt faire une programmation directe, en utilisant l'API Atmel et l'accès direct aux regitres.

Pour notre usage, on peut utiliser le premier chronomètre (TC0). On commence par choisir la fréquence d'horloge du chronomètre :

uint8_t clock = TC_CMR_TCCLKS_TIMER_CLOCK1; // horloge 84MHz/2=42 MHz
              

Pour ce choix, la fréquence est la moitié de celle de l'horloge principale, soit 84/2=42 MHz. Les autres choix possibles sont TC_CMR_TCCLKS_TIMER_CLOCK2 (division par 8), TC_CMR_TCCLKS_TIMER_CLOCK3 (division par 32) et TC_CMR_TCCLKS_TIMER_CLOCK4 (division par 128). Sachant que le nombre de tops d'horloge pour une période d'échantillonnage est codé sur 32 bits, une fréquence d'horloge de 42 MHz permettra de descendre au centième de Hertz.

Le gestionnaire d'interruption de TC0 est activé de la manière suivante :

pmc_set_writeprotect(false);
pmc_enable_periph_clk((uint32_t)TC0_IRQn);
               

Le chronomètre peut fonctionner en mode catpure ou en mode waveform. Ce dernier doit être utilisé pour générer un signal PWM ou pour générer des interruptions cadencées.

Les fonctions de l'API Atmel permettant de configurer les chronomètres se trouvent dans le fichier hardware/arduino/sam/system/libsam/source/tc.c. Chaque chronomètre (TC) comporte 3 voies, chacune ayant son propre compteur 32 bits. Nous allons utiliser la voie 0. La fonction suivante permet de configurer la voie 0 du Timer TC0 :

uint32_t channel = 0;
TC_Configure(TC0, channel, TC_CMR_WAVE | TC_CMR_WAVSEL_UP_RC | clock);
                 

La principale opération effectuée par cette fonction est la modification du registre TC_CMR (Channel Mode Register). Dans le cas présent, on sélectionne le mode waveform en activant le bit TC_CMR_WAVE. Les trois premiers bits configurent l'horloge définie plus haut. Le registre comporte 2 bits de configuration du mode de génération (waveform selection). On choisit ici le mode UP_RC, qui fonctionne commme expliqué ci-dessous.

Le compteur proprement dit d'une voie est constitué d'un registre 32 bits nommé CV (Counter Value), qui est incrémenté d'une unité à chaque top d'horloge. Pour notre choix d'horloge, il y a un top d'horloge tous les 1/42 microsecondes. Il y a aussi 3 registres 32 bits nommés RA, RB et RC. Le registre CV peut être comparé aux valeurs stockées dans ces registres pour déclencher différents évènements, selon le principe du déclenchement par seuil. On choisit ici le mode UP_RC (automatic trigger on RC compare), qui remet CV à zéro lorsque la valeur de RC est atteinte. La figure suivante montre l'évolution de CV au cours du temps :

RC-compare.svgFigure pleine page

La période T de l'onde obtenue (qui sera notre période d'échantillonnage) est :

τ est la période de l'horloge. Autrement dit, le registre RC définit la période en unité de top d'horloge.

La valeur du registre RC peut être modifiée avec la fonction TC_SetRC, ou directement de la manière suivante :

TC0->TC_CHANNEL[channel].TC_RC = ticks;
                    

ticks est un entier 32 bits contenant la période d'échantillonnage en tops d'horloge.

Pour configurer les interruptions, on doit agir sur le registre TC_IER (Interrupt Enable Register). On choisit de déclencher une interruption à chaque fois que le compteur atteint la valeur de RC, en mettant à 1 le bit correpondant (CPCS) et à 0 tous les autres bits.

TC0->TC_CHANNEL[channel].TC_IER=TC_IER_CPCS;
                     

On doit aussi annuler le bit dans le registre TC_IDR (Interrupt Disable Register) et mettre à 1 tous les autres :

TC0->TC_CHANNEL[channel].TC_IDR=~TC_IER_CPCS;
                     

Pour finir, il faut démarrer le compteur :

TC_Start(TC0, channel);
                     

et activer les interruptions déclenchées par le compteur TC0.

NVIC_EnableIRQ(TC0_IRQn);
                     

L'interruption est une opération accomplie par le microprocesseur (en l'occurence le processeur Cortex M3), qui consiste à interrompre l'exécution du programme principal, à stocker l'état des registres, à exécuter le programme d'interruption, puis à revenir au programme principal en lui redonnant l'état de ses registres. L'exécution du programme principal n'est pas affectée par l'interruption (mais celle-ci introduit un délai). La fonction NVIC_EnableIRQ fait partie de la bibliothèque CMSIS (Cortex Microcontrolers Software Interface Standard), utilisée par les microcontrôleurs qui comportent un microprocesseur Cortex. Le code source pour les microcontrôleurs SAM3 se trouve dans hardware/arduino/sam/system/CMSIS/CMSIS/Include/core_cm3.h.

La fonction appelée lors de l'interruption doit être définie de la manière suivante :

void TC0_Handler()
{
    TC_GetStatus(TC0, 0);    
    // opération à exécuter lors de l'interruption
}
                     

La première ligne effectue la lecture du registre TC_SR (Status Register), ce qui est nécessaire pour que l'interruption puisse se déclencher à nouveau.

Les variables modifiées dans la fonction d'interruption doivent être déclarées avec l'attribut volatile. Si l'on souhaite appeler différentes fonctions d'interruptions en fonction du contexte, il faut déclarer un pointeur de fonction sous la forme :

volatile void (*TC0_function)();
                      

La fonction d'interruption est alors :

void TC0_Handler()
{
   TC_GetStatus(TC0, 0);
   (*TC0_function)();
}
                      

4. Synthèse DDS par table

La méthode de synthèse d'un signal périodique par table (DDS : direct digital synthesis) consiste à placer les échantillons du signal pour une période dans une table. La table comporte des nombres entiers de 16 bits (pour un DAC 12 bits, seulement 12 bits sur les 16 sont utilisés). Le nombre d'éléments de la table doit être une puissance de deux. Nous allons utiliser une table à 128 éléments, adressée par un indice codé sur 7 bits, qui contient une période complète du signal.

Pour faire varier finement la fréquence du signal, on utilise une phase allant de 0 à 127 mais comportant aussi une partie fractionnaire, car l'incrément de phase à chaque période d'échantillonnage est en général un nombre décimal non entier. On utilise pour cela un accumulateur de phase de 32 bits à virgule fixe. Les 25 premiers bits (en partant du plus faible) codent la partie fractionnaire de la phase, les 7 derniers codent la partie entière.

phase.svgFigure pleine page

L'utilisation d'entiers 32 bits sur un microprocesseur 32 bits conduit à la plus grande vitesse d'opération. On pourrait aussi utiliser un type flottant pour stocker la phase, mais le processeur Cortex M3 ne possède pas d'unité de calcul en virgule flottante. Les calculs en virgule flottante programmés en C sont convertis par le compilateur en calcul sur des entiers, et la vitesse d'opération résultante est beaucoup plus faible. Pour le maximum de vitesse, on utilisera donc un accumulateur de phase entier 32 bits à virgule fixe. La notation standard pour la représentation utilisée ici est Q7.25 (7 bits pour la partie entière, 25 bits pour la partie fractionnaire).

L'incrément à appliquer à l'accumulateur de phase à chaque période d'échantillonnage est donné par la partie entière suivante :

f est la fréquence du signal, c'est-à-dire la fréquence à laquelle la table complète se répète, et Te la période d'échantillonnage. L'incrément étant un entier, la résolution fréquentielle est :

Par exemple pour la fréquence d'échantillonnage maximale de 1 MHz, la résolution fréquentielle est 0,00023 Hz. Dans le domaine des fréquences audio (entre 50 et 1000 Hertz), il sera donc possible de fixer la fréquence au millième de Hertz près. On voit là le principal avantage de la synthèse DDS comparé aux oscillateurs analogiques : la possibilité d'obtenir une oscillation très stable à une fréquence très bien définie. En contrepartie, les sinusoïdes générées sont moins bonnes en raison de l'échantillonnage.

Le type non signé uint32_t doit être utilisé pour l'accumulateur. Lorsqu'on dépasse la plus grande valeur (232-1), la valeur revient à zéro. Il n'y a donc rien à faire pour gérer le débordement. Pour récupérer les 7 derniers bits afin d'adresser la table, il suffit de faire un décalage de 25 bits vers la droite.

Voici la déclaration de la table, de l'accumulateur et de l'incrément :

#define NECHANT 128
#define SHIFT_ACCUM 25
uint16_t table[NECHANT];
uint32_t accum;
uint32_t increm;
                   

Voici comment se fait l'incrémentation de l'accumulateur, l'accès à l'échantillon dans la table et l'écriture sur le DAC (pour une seule voie) :

accum += increm;
DACC_INTERFACE->DACC_CDR = table[accum>>SHIFT_ACCUM];
                   

5. Programme Arduino

On présente ici un programme complet pour l'Arduino Due, qui communique avec un ordinateur par le port USB natif (port USB géré par le microcontrôleur). Pour configurer le synthétiseur, l'ordinateur doit fournir le code de la commande SET_SYNTHESE_TABLE (1 caractère) suivi des données suivantes :

Une autre commande SET_FREQUENCE permet de modifier la fréquence sans interrompre la synthèse en cours, ce qui permet de faire de la modulation de fréquence.

Voici tout d'abord l'entête avec la déclaration des variables globales :

arduinoDueSignalPeriodique.ino
#include "Arduino.h"

#define SET_SYNTHESE_TABLE 101
#define SET_FREQUENCE 102
#define NECHANT 128
#define SHIFT_ACCUM 25
uint16_t table_0[NECHANT];
uint16_t table_1[NECHANT];
uint8_t channel;
uint32_t chsel;
uint32_t accum_0, accum_1;
uint32_t increm;
uint32_t ticks;
uint8_t timer_actif;
volatile void (*TC0_function)();
             

Pour modifier la taille de la table, il suffira de changer les constantes NECHANT et SHIFT_ACCUM (par exemple 256 et 24).

Voici la fonction qui configure et déclenche le chronomètre avec le déclenchement des interruptions. Les arguments sont le nombre de tops d'horloge pour une période d'échantillonnage et la fonction à appeler lors de l'interruption.

void declencher_timer(uint32_t ticks, volatile void (*function)()) {
   uint8_t clock = TC_CMR_TCCLKS_TIMER_CLOCK1; // horloge 84MHz/2=42 MHz
   uint32_t channel = 0;
   TC0_function = function;
   pmc_set_writeprotect(false);
   pmc_enable_periph_clk((uint32_t)TC0_IRQn);
   TC_Configure(TC0, channel, TC_CMR_WAVE | TC_CMR_WAVSEL_UP_RC | clock);
   TC0->TC_CHANNEL[channel].TC_RC = ticks;
   TC_Start(TC0, channel);
   TC0->TC_CHANNEL[channel].TC_IER=TC_IER_CPCS;
   TC0->TC_CHANNEL[channel].TC_IDR=~TC_IER_CPCS;
   timer_actif = 1;
   NVIC_EnableIRQ(TC0_IRQn);
}
              

La fonction suivante stoppe le chronomètre et les interruptions associées :

void stopper_timer() {
   NVIC_DisableIRQ(TC0_IRQn);
   pmc_disable_periph_clk((uint32_t)TC0_IRQn); 
   timer_actif = 0;
}
               

Voici la fonction appelée lors d'une interruption :

void TC0_Handler()
{
   TC_GetStatus(TC0, 0);
   (*TC0_function)();
}
               

La fonction suivante configure le DAC pour une des deux voies (0 ou 1) :

void configurer_dac(uint32_t channel) {
   pmc_enable_periph_clk(DACC_INTERFACE_ID);
   dacc_reset(DACC_INTERFACE);
   dacc_set_transfer_mode(DACC_INTERFACE, 0); // half word transfert (16 bits)
   dacc_set_power_save(DACC_INTERFACE, 0, 0);
   dacc_set_timing(DACC_INTERFACE, 0x08, 0, 0x10);
   dacc_set_analog_control(DACC_INTERFACE, DACC_ACR_IBCTLCH0(0x02)|DACC_ACR_IBCTLCH1(0x02)|DACC_ACR_IBCTLDACCORE(0x01));
   dacc_set_channel_selection(DACC_INTERFACE, channel);
   if ((dacc_get_channel_status(DACC_INTERFACE) & (1 << channel)) == 0) 
				dacc_enable_channel(DACC_INTERFACE, channel);
}     
               

La fonction suivante configure le DAC pour utiliser les deux voies :

void configurer_double_dac() {
   pmc_enable_periph_clk(DACC_INTERFACE_ID);
   dacc_reset(DACC_INTERFACE);
   dacc_set_transfer_mode(DACC_INTERFACE, 1); //  word transfert (32 bits)
   dacc_set_power_save(DACC_INTERFACE, 0, 0);
   dacc_set_timing(DACC_INTERFACE, 0x08, 0, 0x10);
   dacc_set_analog_control(DACC_INTERFACE, DACC_ACR_IBCTLCH0(0x02)|DACC_ACR_IBCTLCH1(0x02)|DACC_ACR_IBCTLDACCORE(0x01));
   DACC_INTERFACE->DACC_MR |= DACC_MR_TAG;
   DACC_INTERFACE->DACC_CHER = 0x3;
   chsel = (1<<13) | (1<<28);
}
               

Voici les deux fonctions qui seront appelées à chaque interruption, respectivement pour une voie et deux voies :

volatile void synthese_table() {
    accum_0 += increm;
    DACC_INTERFACE->DACC_CDR = table_0[accum_0 >> SHIFT_ACCUM];
}

volatile void synthese_table_double() {
    accum_0 += increm;
    accum_1 += increm;
    DACC_INTERFACE->DACC_CDR = table_0[accum_0 >> SHIFT_ACCUM] | (table_1[accum_1 >> SHIFT_ACCUM] << 16) | chsel; 
}
               

La fonction setup ouvre le port USB (port USB natif). Le débit de données fourni ne correspond pas du tout au débit réel de ce port, qui est largement supérieur à 115200 bauds.

void setup() {
  SerialUSB.begin(115200);
}
               

La fonction suivante effectue la lecture des informations envoyées par l'ordinateur et déclenche la synthèse :

void lecture_synthese_table() {
    char com;
    uint32_t c1,c2,c3,c4;
    char k;
    uint32_t frequence;
    stopper_timer();
    while (SerialUSB.available()<1) {};
    channel = SerialUSB.read(); // voie : 0,1 ou 2
    while (SerialUSB.available()<4) {};
    c1 = SerialUSB.read();
    c2 = SerialUSB.read();
    c3 = SerialUSB.read();
    c4 = SerialUSB.read();
    ticks = ((c1 << 24) | (c2 << 16) | (c3 << 8) | c4); // nombre de tops d'horloge (42 MHz) pour la période d'échantillonnage
    while (SerialUSB.available()<4) {};
    c1 = SerialUSB.read();
    c2 = SerialUSB.read();
    c3 = SerialUSB.read();
    c4 = SerialUSB.read();
    frequence = ((c1 << 24) | (c2 << 16) | (c3 << 8) | c4); // fréquence en millième de Hz
    float fechant = 42.0e6/ticks;
    increm = (uint32_t) (((float)(0xFFFFFFFF))*((float)(frequence)*0.001/fechant)); // incrément de l'accumulateur de phase
    for (k=0; k<NECHANT; k++) {
        while (SerialUSB.available()<2) {};
        c1 = SerialUSB.read();
        c2 = SerialUSB.read();
        table_0[k] =  ((c1<<8)|c2);
    }
    if (channel==2) {
      for (k=0; k<NECHANT; k++) {
        while (SerialUSB.available()<2) {};
        c1 = SerialUSB.read();
        c2 = SerialUSB.read();
        table_1[k] =  ((c1<<8)|c2);
      }     
    }
    accum_0 = 0;
    accum_1 = 0;
    if (channel==2) {
      configurer_double_dac();
      declencher_timer(ticks,synthese_table_double);
    }
    else {
      configurer_dac(channel);
      declencher_timer(ticks,synthese_table);
    }
} 
               

La fonction suivante effectue la lecture de la fréquence envoyée par l'ordinateur, ce qui a pour effet de changer l'incrément de l'accumulateur de phase :

void lecture_frequence() {
    uint32_t c1,c2,c3,c4;
    uint32_t frequence;
    while (SerialUSB.available()<4) {};
    c1 = SerialUSB.read();
    c2 = SerialUSB.read();
    c3 = SerialUSB.read();
    c4 = SerialUSB.read();
    frequence = ((c1 << 24) | (c2 << 16) | (c3 << 8) | c4); // fréquence en millième de Hz
    float fechant = 42.0e6/ticks;
    increm = (uint32_t) (((float)(0xFFFFFFFF))*((float)(frequence)*0.001/fechant));   
}
              

La fonction suivante lit le port série et agit en fonction du caractère de commande :

void lecture_serie() {
  char com;
  if (SerialUSB.available()>0) {
        com = SerialUSB.read();
        if (com==SET_SYNTHESE_TABLE) lecture_synthese_table();
        else if (com==SET_FREQUENCE) lecture_frequence();
  }
}
               

La fonction loop appelle la fonction précédente :

void loop() {
   lecture_serie();
}
               

Il est important que la fonction lecture_serie soit non bloquante, pour ne pas gêner le déclenchement des interruptions.

6. Programme python

arduinoDueSignalPeriodique.py
# -*- coding: utf-8 -*-
import serial
import numpy
import time
            

Le programme python comporte une classe dont les fonctions permettent d'envoyer les données de configuration à l'arduino.

Le constructeur ouvre la communication avec l'arduino et définit des constantes, qui doivent bien sûr être identiques à celles définies dans le programme arduino.

class Arduino:
    def __init__(self,port):
        self.ser = serial.Serial(port,115200)
        time.sleep(1)
        self.SET_SYNTHESE_TABLE = 101
        self.SET_FREQUENCE = 102
        self.NECHANT = 128
        self.clockFreq = 42.0e6 # frequence d'horloge
    def close(self):
        self.ser.close()        
             

Les fonctions suivantes permettent d'envoyer un entier 8 bits, un entier 16 bits, un entier 12 bits avec saturation en cas de dépassement, et un entier 32 bits. Dans tous les cas, les octets de poids fort sont envoyés en premier, conformément à la convention utilisée dans le code arduino présenté plus haut (convention big endian).

    def write_int8(self,v):
        v = numpy.int8(v)
        self.ser.write(chr(v & 0xFF))
    def write_int16(self,v):
        v = numpy.int16(v)
        char1 = (v & 0xFF00) >> 8
        char2 = (v & 0x00FF)
        self.ser.write(chr(char1))
        self.ser.write(chr(char2))
    def write_int12_sat(self,v):
        if (v<0):
            v = 0
        if (v>0xFFF):
            v = 0xFFF
        self.write_int16(v)
    def write_int32(self,v):
        v = numpy.int32(v)
        char1 = (v & 0xFF000000) >> 24
        char2 = (v & 0x00FF0000) >> 16
        char3 = (v & 0x0000FF00) >> 8
        char4 = (v & 0x000000FF)
        self.ser.write(chr(char1))
        self.ser.write(chr(char2))
        self.ser.write(chr(char3))
        self.ser.write(chr(char4))
             

La fonction suivante effectue la configuration d'une synthèse sur une seule voie. Les échantillons doivent être fournis sous la forme d'un tableau numpy de 128 valeurs comprises entre 0 et 4096. Les valeurs non comprises dans cet intervalle sont saturées. Les valeurs sont bien sûr converties en entiers.

    def synthese_table(self,voie,echantillons,fechant,freq):
        if echantillons.size!=self.NECHANT:
            raise Exception("taille incorrecte")
        ticks = int(self.clockFreq/fechant)
        self.ser.write(chr(self.SET_SYNTHESE_TABLE))
        if voie!=0 and voie!=1:
            raise Exception("voie incorrecte")
        self.write_int8(voie)
        self.write_int32(ticks)
        self.write_int32(freq*1000)
        for i in range(self.NECHANT):
            self.write_int12_sat(echantillons[i])
             

Voici la fonction analogue pour configurer les deux voies :

    def synthese_double_table(self,echant_0,echant_1,fechant,freq):
        if echant_0.size!=self.NECHANT and echant_1.size!=self.NECHANT:
            raise Exception("taille incorrecte")
        ticks = int(self.clockFreq/fechant)
        self.ser.write(chr(self.SET_SYNTHESE_TABLE))
        self.write_int8(2)
        self.write_int32(ticks)
        self.write_int32(freq*1000)
        for i in range(self.NECHANT):
            self.write_int12_sat(echant_0[i])
        for i in range(self.NECHANT):
            self.write_int12_sat(echant_1[i])
             

et enfin la fonction pour changer la fréquence :

    def set_frequence(self,freq):
        self.ser.write(chr(self.SET_FREQUENCE))
        self.write_int32(freq*1000)
             

7. Test

Voici un programme python de test, qui génère des tables comportant des sinusoïdes. Pour la voie 0, la table comporte une période de sinusoïde, alors qu'elle en comporte deux pour la voie 1.

import numpy
from arduinoDueSignalPeriodique import Arduino        
        
ard=Arduino(6)
t=numpy.linspace(0,1.0,ard.NECHANT)
echant_0 = numpy.sin(2*numpy.pi*t)*2048+2048
echant_1 = numpy.cos(4*numpy.pi*t)*2048+2048
fechant = 500000 # maximum environ 800 kHz pour une voie, 600 kHz pour deux voies
freq = 500.00
ard.synthese_double_table(echant_0,echant_1,fechant,freq)
ard.close()
            

Il faut faire des tests pour déterminer la fréquence d'échantillonnage maximale. Lorsque l'exécution de la fonction d'interruption est plus longue que la période d'échantillonnage programmée, la période d'échantillonnage effective est plus longue, ce qui se traduit par un alongement de la période du signal. Un analyseur de spectre est utile pour faire ces tests. Pour deux voies, nous avons obtenu une synthèse correcte jusqu'à une fréquence d'environ 600 kHz.

Avec le circuit d'interface analogique décrit plus haut, l'amplitude en sortie est +/-1,65 V.

Il peut être utile d'avoir une fonction qui calcule la table à partir d'une liste d'harmoniques :

def harmonics_table(amp_list,phase_list,normalize=False,gain=1.0):
    TABLE_SIZE = 128
    amp = numpy.array(amp_list,dtype=numpy.float32)
    phase = numpy.array(phase_list,dtype=numpy.float32)
    if amp.size != phase.size:
        raise SystemExit("amplitude et phase : tailles incompatibles")
    t = numpy.arange(TABLE_SIZE)*1.0/TABLE_SIZE
    table = numpy.zeros(TABLE_SIZE,dtype=numpy.float32)
    for n in range(amp.size):
        table = table+amp[n]*numpy.sin(2*numpy.pi*(n+1)*t+phase[n])
    table = numpy.array(table,dtype=numpy.float32)
    m = abs(table.min())
    M = abs(table.max())
    if normalize:
        table = table/max(m,M)
    return table*gain*2048+2048
             

Cette fonction peut éventuellement faire une normalisation et multiplier par un gain, ce qui permet de choisir la tension maximale.

Voici un exemple d'utilisation sur la voie 1 et l'oscillographe obtenu :

ard=Arduino(6)
echant = harmonics_table([1.0,0.5,0.2],[0.0,-1.0,0.3],normalize=True,gain=1.0)
fechant = 500000
freq = 500.0
ard.synthese_table(1,echant,fechant,freq)
ard.close()
              
oscillo
Creative Commons LicenseTextes et figures sont mis à disposition sous contrat Creative Commons.