Guitare synthétique

../../_images/tuxguitar.png

Les objectifs de ce TP sont :

  • Écrire un programme synthétisant un modèle de corde de guitare : l’algorithme de Karplus & Strong.

  • Utiliser la classe StdAudio pour jouer les sons.

Simuler le pincement d’une corde de guitare

L’algorithme de Karplus-Strong est bien connu des physiciens et musiciens depuis sa publication en 1983 dans le Computer Music Journal. Lorsqu’on pince une corde de guitare, elle se met à vibrer et produit un son. La longueur de la corde est determinante dans la valeur de la fréquence fondamentale de la vibration. En utilisant le formalisme des automaticiens, on peut représenter le modèle comme suit :

../../_images/Karplus-strong-schematic.png

La corde est modélisée par son ondulation spatiale, évaluée en N points répartis régulièrement sur toute sa longueur. La valeur de N étant liée au rapport de la fréquence d’échantillonnage du signal audio à la fréquence fondamentale \(f_0\) de la note à jouer par la relation

\[N = \frac{F\_ECH}{f_0} + \frac{1}{2}\]

Le pincement de la corde représente l’excitation, c’est à dire l’apport initial en énergie, qui va engendrer le comportement observé (la vibration acoustique). Le modèle de Karplus-Strong fait l’hypothèse d’une excitation contenant de l’énergie à toutes les fréquences : nous utiliserons donc un bruit blanc, dont c’est précisément la propriété, pour exciter notre modèle.

Note

  • Dans notre cas, la fréquence d’échantillonnage est fixée, comme pour les CD audio, à \(F\_ECH = 44100Hz\).

  • \(F\_ECH\) est définie dans la classe StdAudio (StdAudio.SAMPLE_RATE).

  • Dans la pratique, on simule un bruit blanc par un tirage aléatoire uniformément réparti.

  • Pour ce modèle de corde on tirera des valeurs réelles (double) entre -0.5 et +0.5.

Lorsque la corde a été pincée, elle entre en vibration et l’onde se propage le long de la corde, entre ses deux extrémités. L’algorithme de Karplus-Strong modélise cette propagation par la mise à jour répétée (à F_ECH) d’un tampon circulaire de N valeurs. Ce tampon modélise la corde, dont les deux extrémités sont fixées ; La taille du tampon détermine la fréquence fondamentale de l’onde acoustique qui se propage alternativement d’une extrémité à l’autre de la corde.

Du point de vue algorithmique,

  • Le filtrage passe bas du schéma est réalisé en effectuant une moyenne pondérée des deux premiers éléments du tampon.

  • La propagation est simulée en rangeant le résultat de la moyenne calculée au point précédent en dernière position de ce même tampon, en le multipliant par un coefficient strictement inférieur à 1 qui permet de modéliser la dissipation énergétique de l’onde dans le temps (le son fini par s’éteindre).

  • On supprime enfin la première valeur du tampon.

Ce mécanisme est illustré ci-dessous

../../_images/ring-buffer2.png

Un grand nombre de paramètres peuvent être ajustés et/ou ajoutés au sein de ce modèle, permettant de générer des sons allant de la guitare au clavecin en passant par des percussions. Ci dessous, deux exemples de résultat :

  • Ce qu’il est possible d’obtenir avec notre modèle simplifié, fidèle à l’original des auteurs et un clavier virtuel comme à la question 8. L’interprétation est quelque peu hésitante, mais vous êtes invités à nous illuminer de vos talents.

  • Un autre extrait, joué automatiquement cette fois.

Mise en œuvre du modèle Karplus-Strong

Le tampon circulaire

La première étape consiste à modéliser le tampon circulaire. Il va consister en un tableau classique pour ranger les données, mais l’aspect circulaire nécessite d’adjoindre deux curseurs à ce tableau :

  • un qui contiendra la position du premier élément du tampon

  • un autre qui contiendra la position du dernier élement du tampon

On va pour cela concevoir une classe RingBuffer possédant les attributs ci-dessous. Un squelette de cette classe est donné plus bas.

double[] buffer; // le tampon de valeurs à proprement parler
int first;       // indice du prochain élément à supprimer
int last;        // indice du prochain élément à insérer +1
int capacity;    // nmbre maximal d'éléments dans le tampon
int size;        // nombre d'éléments actifs dans le tampon

La classe RingBuffer adoptera en outre le comportement souhaité grâce aux méthodes suivantes :

        RingBuffer(int cap)  // constructeur initialisant un 'buffer' de taille 'capacity=cap'
int     size()                    // retourne le nombre d'éléments actifs dans le tampon
boolean isEmpty()                 // le tampon est-il vide ?
boolean isFull()                  // le tampon est-il plein ?
void    enqueue(double x)         // ajoute l'élément x en fin de tampon
double  dequeue()                 // supprime et retourne l'élément en tête du tampon
double  pick()                    // retourne, sans le supprimer, l'élément en tête du tampon

On peut représenter un RingBuffer comme suit, avec son buffer de taille capacity (ici 10) contenant les valeurs des échantillons audio et ses deux curseurs nommés first et last, indiquant respectivement la tête et la queue de la file des valeurs actives.

../../_images/ring-buffer3.png

Le squelette de la classe RingBuffer telle que décrite précédemment peut donc se présenter comme ci-dessous. Noter les blocs de commentaires à la javadoc en tête de chaque méthode. Le respect de ce format permet de générer automatiquement les documentations interactives.

public class RingBuffer {
    double[] buffer ;
    int first ;
    int last ;
    int capacity ;
    int size ;

    /**
     * Constructeur. prend en paramètre la capacité du buffer et
     * alloue le buffer.
     * @param cap
     */
    public RingBuffer(int cap){
        capacity = cap;
        first = 0 ;
        last = 0 ;
        size = 0 ;
        buffer = new double[cap] ;
    }

    ...

    /**
    * méthode de debug
    */
    public static void main(String[] args){
        RingBuffer buf = new RingBuffer(10);
        buf.enqueue(1.5);
        buf.enqueue(2.5);
        buf.enqueue(3.5);
        buf.enqueue(4.5);
        buf.enqueue(5.5);
        buf.enqueue(6.5);
        buf.enqueue(7.5);
        buf.enqueue(8.5);
        buf.enqueue(9.5);
        buf.enqueue(10);
        buf.display();
    }
}

À faire

  1. display(), qui permet d’afficher les attributs d’un RingBuffer ainsi que l’ensemble des valeurs rangées dans son buffer.

  2. Écrire les traitements et compléter le bloc de documentation des méthodes

    1. size() : Cette méthode n’est en réalité qu’un assesseur.

    2. isEmpty()

    3. isFull()

  3. enqueue(), qui ajoute un élément en queue de la file des éléments actifs dans le tableau buffer et met à jour le curseur last. À ce stade, il est possible d’exécuter le main() de la classe RingBuffer pour vérifier le bon comportement engendré par la méthode enqueue().

  4. dequeue(), qui supprime et retourne l’élément en tête de la file simulée par la classe RingBuffer. Cette suppression revient en fait à retirer le premier élément de la liste des éléments actifs dans le tableau buffer, ce qui se traduit par la simple mise à jour du curseur first. Pour vérifier le comportement apporté par cette méthode, vous pouvez modifier le corps de main() et utiliser la méthode display() pour visualiser le contenu et l’état du RingBuffer.

  5. Deux cas d’erreur peuvent survenir à l’exécution des méthodes enqueue() et dequeue() :

    • si dequeue() est appelée sur un buffer vide.

    • si enqueue() est appelée sur un buffer plein.

    En première approche, on va se contenter de signaler la survenue de ces erreurs à l’exécution en levant une exception et en lui associant un message explicite. Par exemple, pour la méthode enqueue() on peut écrire, au début de corps de la méthode :

    if( isFull() ) throw new RuntimeException("Erreur file pleine");
    

    Gérer de la même manière le cas d’erreur possible de la méthode dequeue().

  6. pick(), qui retourne l’élément en tête de file des éléments actifs dans le tableau buffer.

  7. Gérer le cas d’erreur évident de cette méthode.

Indication

  • Un RingBuffer est un tampon circulaire ; Les deux curseurs first et last doivent donc être gérés de manière à respecter cette caractéristique. Ainsi si un curseur atteint la valeur de capacity-1, il doit revenir à 0 à la tentative suivante de l’incrémenter.

corrigé

RingBuffer.java

La corde de guitare

La prochaine étape est de concevoir une classe modélisant la corde de guitare en vibrations. Cette classe GuitarString possèdera les méthodes suivantes :

       GuitarString(double frequency)  // constructeur avec un paramètre : la fréquence fondamentale de la note (f0)
  void pluck()                         // remplit le buffer avec du bruit blanc
  void tic()                           // fait avancer la simulation d'un pas de temps
double sample()                        // retourne l'échantillon courant, à envoyer sur la sortie audio
   int time()                          // retourne le nombre de pas de temps écoulés depuis le début de la simulation.

Le squelette de la classe GuitarString est donné ici

public class GuitarString {
  final Random RAND = new Random();  // pour la génération du bruit blanc
  final double ATTN = 0.994;         // coefficient d'atténuation énergétique
  int toc ;                          // compteur de pas de simulation
  RingBuffer buf ;                   // le RingBuffer de la corde

  /**
   * Constructeur. Alloue un RingBuffer de capacité Fech/freq
   * @param freq
   */
  public GuitarString(double freq){
      /* à écrire */
      ...
  }
  ...
}

À faire

  1. GuitarString(), le constructeur créant un RingBuffer de capacité N selon la fréquence passée en paramètre. La taille du buffer sera l’entier le plus proche du résultat calculé avec la formule fournie. Les éléments du buffer seront initialisés à 0 ainsi que le compteur de pas de simulation.

  2. pluck(), qui initialise le contenu du buffer avec du bruit blanc. Comme le constructeur initialise les valeurs du buffer à 0, il est donc nécessaire de vider le buffer avant de le remplir avec le bruit blanc. Cela peut paraître étrange mais l’initialisation à 0 est destinée à faciliter le mélange des sons lorsqu’on créé un clavier comme dans la question 8.

  3. tic(), qui réalise un pas de simulation, soit \(1/F_{ECH}\) secondes. Il s’agit d’effectuer la simulation du pincement de la corde détaillé plus haut : a)moyenne des deux premiers échantillons, b)ajout en queue, c)suppression du premier échantillon.

  4. sample(), qui retourne simplement le prochain échantillon à envoyer sur la sorte audio, c’est à dire celui se trouvant en tête de file du RingBuffer.

  5. time(), qui retourne le nombre de pas de simulation qui ont été effectués depuis le début de la simulation.

  6. main(), qui

    • déclare une fréquence \(f_0\) à votre convenance, de préférence dans le spectre aisément audible, soit approximativement de 100 à 3000Hz. Attention aux notes aiguës qui vont nous agacer les oreilles.

    • construit une GuitarString à cette fréquence.

    • peuple cette GuitarString avec du bruit blanc.

    • lance la simulation en faisant successivement jouer le prochain échantillon puis avancer d’un pas de simulation.

Pour les plus chevronnés :

  1. Modifier main() de sorte à créer un clavier où chaque note est associée à une touche. On pourra par exemple

    • représenter le clavier à l’aide d’une chaîne de caractère qui contient les caractères présents sur les touches.

    • créer un tableau de GuitarSTring dont chaque élément a sa propre fréquence fondamentale. Si on fait l’hypothèse d’un clavier chromatique (12 demi-tons par octave), la fréquence fondamentale de la touche numéro n (commençant à 0) a pour valeur \(f_0.2^{\frac{n-24}{12.0}}\).

    • détecter les appuis sur les touches, dans une boucle infinie, puis appliquer les traitements individuels à chaque RingBuffer associé à une touche enfoncée.

  2. Participer aux éliminatoires de votre groupe pour le concours de la nouvelle guitaralgo star du département.