Recherche de site Web

Construire un synthétiseur de guitare : jouer une tablature musicale en Python


Avez-vous déjà eu envie de composer de la musique sans équipement coûteux ni studio professionnel ? Peut-être avez-vous déjà essayé de jouer d’un instrument de musique, mais avez trouvé la dextérité manuelle requise trop intimidante ou trop longue. Si tel est le cas, vous pourriez être intéressé à exploiter la puissance de Python pour créer un synthétiseur de guitare. En suivant quelques étapes relativement simples, vous pourrez transformer votre ordinateur en une guitare virtuelle capable de jouer n’importe quelle chanson.

Dans ce didacticiel, vous allez :

  • Implémenter l'algorithme de synthèse de cordes pincées Karplus-Strong
  • Imitez différents types d'instruments à cordes et leurs accordages
  • Combinez plusieurs cordes vibrantes en accords polyphoniques
  • Simulez des techniques réalistes de picking de guitare et de grattage des doigts.
  • Utilisez les réponses impulsionnelles d'instruments réels pour reproduire leur timbre unique
  • Lisez les notes de musique à partir de la notation scientifique de hauteur et de la tablature de guitare

À tout moment, vous pouvez télécharger le code source complet du synthétiseur de guitare, ainsi que l'exemple de tablature et d'autres ressources que vous utiliserez tout au long de ce didacticiel. Ils peuvent s'avérer utiles si vous souhaitez explorer le code plus en détail ou prendre une longueur d'avance. Pour télécharger les bonus maintenant, visitez le lien suivant :

Démo : synthétiseur de guitare en Python

Dans ce guide étape par étape, vous allez créer un synthétiseur d'instruments à cordes pincées basé sur l'algorithme Karplus-Strong en Python. En cours de route, vous créerez un ensemble d’instruments virtuels, comprenant une guitare acoustique, une basse et une guitare électrique, ainsi qu’un banjo et un ukulélé. Ensuite, vous implémenterez un lecteur d'onglets de guitare personnalisé afin que vous puissiez jouer vos chansons préférées.

À la fin de ce didacticiel, vous serez en mesure de synthétiser de la musique à partir de tablatures de guitare, ou tablatures de guitare en abrégé, qui est une forme simplifiée de notation musicale qui vous permet de jouer de la musique sans avoir à apprendre à lire des partitions standard. Enfin, vous stockerez le résultat dans un fichier MP3 pour la lecture.

Vous trouverez ci-dessous une courte démonstration du synthétiseur, recréant les bandes sonores emblématiques de jeux vidéo classiques comme Doom et Diablo. Cliquez sur le bouton de lecture pour écouter l'exemple de sortie :

Une fois que vous avez trouvé une tablature de guitare que vous aimez, vous pouvez la brancher sur votre synthétiseur de guitare Python et donner vie à la musique. Par exemple, le site Web Songsterr est une ressource fantastique proposant un large éventail de chansons parmi lesquelles vous pouvez choisir.

Aperçu du projet

Pour votre commodité, le projet que vous êtes sur le point de construire, ainsi que ses dépendances tierces, seront gérés par Poetry. Le projet contiendra deux packages Python avec des domaines de responsabilité clairement différents :

  1. digitar : Pour la synthèse du son de la guitare numérique
  2. tablature : pour lire et interpréter une tablature de guitare à partir d'un fichier

Vous allez également concevoir et mettre en œuvre un format de données personnalisé pour stocker les tablatures de guitare sur disque ou en mémoire. Cela vous permettra de jouer de la musique basée sur une notation de tablature assez standard, que vous trouverez à divers endroits sur Internet. Votre projet fournira également un script Python pour relier le tout, ce qui vous permettra d'interpréter les onglets avec une seule commande directement depuis votre terminal.

Vous pouvez maintenant plonger dans les détails de ce dont vous aurez besoin pour configurer votre environnement de développement et commencer à coder.

Conditions préalables

Bien que vous n'ayez pas besoin d'être musicien pour suivre ce didacticiel, une compréhension de base des concepts musicaux tels que les notes, les demi-tons, les octaves et les accords vous aidera à saisir les informations plus rapidement. Ce serait également bien si vous aviez une idée générale de la façon dont les ordinateurs représentent et traitent l'audio numérique en termes de taux d'échantillonnage, de profondeur de bits et de formats de fichiers comme WAV.

Mais ne vous inquiétez pas si vous êtes nouveau dans ces idées ! Vous serez guidé à travers chaque étape par petits incréments avec des explications et des exemples clairs. Ainsi, même si vous n’avez jamais fait de synthèse musicale auparavant, vous disposerez d’une guitare numérique ou d’un digital fonctionnel à la fin de ce didacticiel.

Le projet que vous allez créer a été testé avec Python 3.12, mais devrait également fonctionner correctement dans les versions antérieures de Python, jusqu'à Python 3.10. Au cas où vous auriez besoin d’un rappel rapide, voici une liste de ressources utiles couvrant les fonctionnalités linguistiques les plus importantes dont vous profiterez dans votre parcours de guitare numérique :

  • Expressions d'affectation
  • Classes de données
  • Énumérations
  • Protocoles (typage de canard statique)
  • Correspondance des modèles structurels
  • Astuces de saisie

En dehors de cela, vous utiliserez les packages Python tiers suivants dans votre projet :

  • NumPy pour simplifier et accélérer la synthèse sonore sous-jacente
  • Pedalboard pour appliquer des effets spéciaux semblables aux amplificateurs de guitare électrique
  • Pydantic et PyYAML pour analyser une tablature musicale représentant les mouvements des doigts sur le manche d'une guitare

La connaissance de ceux-ci vous aidera certainement, mais vous pouvez également apprendre au fur et à mesure et considérer ce projet comme une opportunité de pratiquer et d'améliorer vos compétences Python.

Étape 1 : Configurer le projet de guitare numérique

La première étape consiste à préparer votre environnement de développement. Pour commencer, vous allez créer un nouveau projet Python et installer les bibliothèques tierces requises. Ensuite, vous le chargerez dans un éditeur, où vous continuerez à écrire le code nécessaire pour votre synthétiseur de guitare.

Créer un nouveau projet et installer les dépendances

Il existe de nombreuses façons de créer et de gérer des projets Python. Dans ce didacticiel, vous utiliserez Poetry comme un outil pratique pour la gestion des dépendances. Si vous ne l'avez pas déjà fait, installez Poetry (par exemple, avec pipx) et démarrez un nouveau projet en utilisant la disposition du dossier src/ pour garder votre code organisé :

$ poetry new --src --name digitar digital-guitar/
Created package digitar in digital-guitar

Cela donnera la structure de dossiers ci-dessous, qui comprend des fichiers fictifs avec les métadonnées et le code source de votre projet que vous remplirez plus tard :

digital-guitar/
│
├── src/
│   └── digitar/
│       └── __init__.py
│
├── tests/
│   └── __init__.py
│
├── pyproject.toml
└── README.md

Ensuite, remplacez le répertoire par votre nouveau projet et ajoutez quelques dépendances sur lesquelles vous vous appuyerez plus tard :

$ cd digital-guitar/
$ poetry add numpy pedalboard pydantic pyyaml

Après avoir exécuté cette commande, Poetry créera un environnement virtuel isolé dans un emplacement désigné pour votre projet et y installera les packages Python tiers répertoriés. Vous devriez également voir un nouveau fichier poetry.lock dans le dossier racine de votre projet.

Vous pouvez maintenant ouvrir le dossier digital-guitar/ dans l'IDE Python ou l'éditeur de code de votre choix. Si vous utilisez Visual Studio Code ou PyCharm, les deux programmes découvriront l'environnement virtuel créé par Poetry. Ce dernier l'associera également au projet, vous permettant d'accéder immédiatement aux packages installés.

Dans VS Code, vous devrez peut-être sélectionner manuellement l'environnement virtuel géré par Poetry. Pour ce faire, affichez la palette de commandes, tapez Python : Sélectionner un interprète et choisissez l'interpréteur souhaité. À l'inverse, après avoir ouvert le dossier dans PyCharm, confirmez l'invite vous demandant de configurer un environnement Poetry. L'interpréteur Python correspondant apparaîtra dans le coin inférieur droit de la fenêtre.

Alternativement, si vous êtes un utilisateur inconditionnel de Vim ou Sublime Text, vous pouvez continuer à utiliser Poetry dans la ligne de commande :

$ poetry install
$ poetry run play-tab demo/tabs/doom.yaml
Saved file /home/user/digital-guitar/doom.mp3

La première commande installera votre projet, ainsi que ses dépendances, définies dans le fichier pyproject.toml. La deuxième commande, que vous implémenterez plus tard, exécutera un script depuis l'environnement virtuel associé géré par Poetry. Notez que vous utiliserez ces commandes de toute façon, quel que soit l’éditeur de code que vous choisissez.

Adoptez des types de données immuables dans votre projet

À quelques exceptions près, vous définirez presque exclusivement des types de données immuables dans ce projet. Les objets immuables sont ceux que vous ne pouvez pas modifier une fois créés. Même si cela peut paraître limitatif au premier abord, cela présente en réalité de nombreux avantages. C’est donc une bonne idée de vous familiariser avec le concept d’immuabilité et son impact sur le comportement de votre programme avant de commencer.

Tout d’abord, la plupart des objets immuables en Python sont hachables, ce qui en fait des clés de dictionnaire valides. Plus tard, cela deviendra essentiel pour mettre en cache les valeurs des arguments afin d’éviter les calculs répétitifs. À long terme, cela vous aidera à réduire le temps global nécessaire à la synthèse sonore.

En dehors de cela, vous pouvez utiliser en toute sécurité des objets immuables comme valeurs d’argument par défaut sans vous soucier des effets secondaires involontaires. En revanche, les arguments par défaut mutables sont l’un des pièges les plus courants de Python, qui peuvent conduire à des bogues surprenants et difficiles à suivre. En vous en tenant à des types immuables lorsque cela est possible, vous vous épargnerez bien des maux de tête.

En outre, vous pouvez considérer les objets immuables comme des valeurs simples comme des entiers ou des chaînes. Lorsque vous affectez une variable immuable à une autre variable, l'affectation lie les deux références au même objet en mémoire. Mais dès que vous tentez de modifier l’état de votre objet immuable via l’une de ces variables, vous créez une copie de cet objet, laissant l’original intact. Ainsi, votre code devient plus prévisible et résilient.

Les objets immuables sont également thread-safe et facilitent le raisonnement sur votre code. Ces caractéristiques les rendent particulièrement adaptées au paradigme de programmation fonctionnelle, mais vous bénéficierez également de leurs avantages dans le domaine orienté objet.

Il est maintenant temps de mettre cette théorie en pratique en implémentant votre premier type de données immuable pour ce projet de synthétiseur de guitare.

Représenter les instants de temps, les durées et les intervalles

La musique est une forme d’art éphémère que l’on ne peut apprécier que pendant une courte période lorsqu’elle est jouée ou interprétée. Parce que la musique existe par nature dans le temps, il est crucial que vous soyez capable de représenter correctement les instants temporels, les durées et les intervalles si vous souhaitez créer un synthétiseur robuste.

Le type de données float de Python n'est pas assez précis pour le timing musical en raison des erreurs de représentation et d'arrondi enracinées dans la norme IEEE 754. Lorsque vous avez besoin d'une plus grande précision, la pratique recommandée en Python consiste à remplacer les nombres à virgule flottante par un type de données Décimal ou Fraction. Cependant, l'utilisation directe de ces types peut s'avérer fastidieuse et ils ne contiennent pas les informations nécessaires sur les unités de temps impliquées.

Pour atténuer ces désagréments, vous allez implémenter quelques classes personnalisées, en commençant par le type de données polyvalent Time. Allez-y et créez un nouveau module Python nommé temporal dans votre package digitar et définissez-y la classe de données suivante :

from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Self

type Numeric = int | float | Decimal | Fraction

@dataclass(frozen=True)
class Time:
    seconds: Decimal

    @classmethod
    def from_milliseconds(cls, milliseconds: Numeric) -> Self:
        return cls(Decimal(str(float(milliseconds))) / 1000)

Cette classe n'a qu'un seul attribut, représentant le nombre de secondes sous forme d'objet Decimal pour une précision améliorée. Vous pouvez créer des instances de votre nouvelle classe en fournissant les secondes via son constructeur ou en appelant une méthode de classe qui attend des millisecondes et les convertit en secondes enveloppées dans un type de données approprié.

from typing import TypeAlias

Numeric: TypeAlias = int | float | Decimal | Fraction

Alternativement, si vous exécutez une version encore antérieure de Python, utilisez simplement une instruction d'affectation simple sans aucun mot-clé ni annotation.

En raison de la nature dynamique de Python, le constructeur par défaut généré par l'interpréteur pour votre classe de données n'appliquera pas les indications de type avec lesquelles vous avez annoté vos attributs. En d’autres termes, l’interpréteur ne vérifiera pas si les valeurs fournies sont des types attendus. Ainsi, dans ce cas, si vous transmettez un nombre entier ou un nombre à virgule flottante au lieu d'un objet Decimal, vous créerez par inadvertance une instance avec un type d'attribut incorrect.

Heureusement, vous pouvez éviter ce problème en implémentant votre propre méthode d'initialisation dans la classe qui remplacera celle générée par Python par défaut :

from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Self

type Numeric = int | float | Decimal | Fraction

@dataclass(frozen=True)
class Time:
    seconds: Decimal

    @classmethod
    def from_milliseconds(cls, milliseconds: Numeric) -> Self:
        return cls(Decimal(str(float(milliseconds))) / 1000)

    def __init__(self, seconds: Numeric) -> None:
        match seconds:
            case int() | float():
                object.__setattr__(self, "seconds", Decimal(str(seconds)))
            case Decimal():
                object.__setattr__(self, "seconds", seconds)
            case Fraction():
                object.__setattr__(
                    self, "seconds", Decimal(str(float(seconds)))
                )
            case _:
                raise TypeError(
                    f"unsupported type '{type(seconds).__name__}'"
                )

Vous utilisez la correspondance de modèles structurels pour détecter le type d’argument transmis à votre méthode au moment de l’exécution et bifurquer en conséquence. Ensuite, vous vous assurez que l'attribut d'instance, .seconds, est toujours défini sur un objet Decimal, quel que soit le type d'entrée. Si vous transmettez une instance Decimal à votre constructeur, alors il n'y a plus rien à faire. Sinon, vous utilisez la conversion appropriée ou déclenchez une exception pour signaler une mauvaise utilisation du constructeur.

Étant donné que vous avez défini une classe de données gelée, ce qui rend ses instances immuables, vous ne pouvez pas définir directement la valeur de l'attribut ni appeler la fonction intégrée setattr() sur une classe de données existante. objet. Cela violerait le contrat d'immuabilité. Si jamais vous avez besoin de changer de force l'état d'une instance de classe de données gelée, vous pouvez alors recourir à un hack en appelant explicitement object.__setattr__(), comme dans l'extrait de code ci-dessus.

Vous vous souviendrez peut-être que les classes de données prennent en charge une méthode spéciale précisément pour ce type de personnalisation. Cependant, l'avantage d'écraser la méthode d'initialisation par défaut au lieu d'implémenter .__post_init__() est que vous prenez le contrôle total du processus de création d'objet. En conséquence, un objet peut soit exister et être dans un état valide, soit ne pas exister du tout.

Enfin, vous pouvez implémenter une méthode pratique que vous utiliserez plus tard pour traduire une durée en secondes en nombre correspondant d'échantillons audio :

from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Self

type Numeric = int | float | Decimal | Fraction
type Hertz = int | float

@dataclass(frozen=True)
class Time:
    # ...

    def get_num_samples(self, sampling_rate: Hertz) -> int:
        return round(self.seconds * round(sampling_rate))

Cette méthode prend comme argument un taux d'échantillonnage en hertz (Hz), qui représente le nombre d'échantillons par seconde. En multipliant la durée en secondes par la fréquence d'échantillonnage en hertz, vous obtenez le nombre requis d'échantillons, que vous pouvez arrondir pour renvoyer un nombre entier.

Voici une courte session Python REPL démontrant comment vous pouvez tirer parti de votre nouvelle classe de données :

>>> from digitar.temporal import Time

>>> Time(seconds=0.15)
Time(seconds=Decimal('0.15'))

>>> Time.from_milliseconds(2)
Time(seconds=Decimal('0.002'))

>>> _.get_num_samples(sampling_rate=44100)
88

Le trait de soulignement (_) dans le REPL est une variable implicite qui contient la valeur de la dernière expression évaluée. Dans ce cas, il fait référence à votre instance Time représentant deux millisecondes.

Avec la classe Time en place, vous êtes prêt à passer à l'étape suivante. Vous plongerez vos orteils dans la physique d’une corde vibrante et verrez comment elle produit un son.

Étape 2 : Modéliser l'onde acoustique d'une corde vibrante

En fin de compte, chaque son que vous entendez est une perturbation locale de la pression atmosphérique provoquée par un objet vibrant. Qu’il s’agisse de vos cordes vocales, d’une corde de guitare ou d’un haut-parleur, ces vibrations poussent et tirent les molécules d’air qui les entourent. Ce mouvement se propage ensuite dans l’air sous forme d’onde acoustique jusqu’à ce qu’il atteigne votre tympan, qui vibre en réponse.

Dans cette étape, vous examinerez de plus près l'algorithme de synthèse Karplus-Strong, qui modélise la vibration d'une corde pincée. Ensuite, vous l'implémenterez en Python à l'aide de NumPy et produisez votre premier son synthétique ressemblant à celui d'une corde pincée.

Apprenez à connaître l'algorithme Karplus-Strong

L’algorithme Karplus-Strong est étonnamment simple, compte tenu des sons complexes qu’il peut produire. En un mot, cela commence par remplir un tampon très court avec une rafale de bruit aléatoire ou un autre signal possédant une énergie riche ou de nombreuses composantes de fréquence. Ce bruit correspond à l’excitation d’une corde réelle, qui vibre initialement selon plusieurs schémas de mouvement incohérents.

Ces vibrations apparemment aléatoires deviennent progressivement de plus en plus sinusoïdales, avec une période et une fréquence clairement sinusoïdales que vous percevez comme une hauteur distinctive. Alors que les amplitudes de toutes les vibrations s'affaiblissent avec le temps en raison de la dissipation d'énergie provoquée par la friction interne et le transfert d'énergie, une certaine fréquence fondamentale reste plus forte que la plupart des harmoniques et harmoniques qui s'estompent plus rapidement.

L'algorithme Karplus-Strong applique un filtre passe-bas au signal pour simuler la décroissance des fréquences plus élevées à un rythme plus rapide que la fréquence fondamentale. Pour ce faire, il calcule une moyenne mobile de deux niveaux d'amplitude consécutifs dans le tampon, agissant effectivement comme un filtre de convolution simple. Il supprime les fluctuations à court terme tout en laissant la tendance à long terme.

De plus, l’algorithme réinjecte les valeurs moyennes dans le tampon pour renforcer et poursuivre la vibration, bien qu’avec une perte d’énergie progressive. Jetez un œil au diagramme ci-dessous pour avoir une meilleure idée du fonctionnement de cette boucle de rétroaction positive :

Le générateur de gauche sert d’entrée à l’algorithme, fournissant la première explosion de bruit. Il s’agira généralement d’un bruit blanc avec une distribution de probabilité uniforme de sorte qu’en moyenne, aucune fréquence particulière n’est privilégiée par rapport à une autre. L’analogie est similaire à la lumière blanche, qui contient toutes les fréquences du spectre visible à des intensités à peu près égales.

Le générateur s'arrête après avoir rempli un tampon circulaire, également appelé ligne à retard, qui retarde le signal d'un certain temps avant de le renvoyer dans la boucle. Le signal déphasé du passé est ensuite mélangé au signal actuel. Considérez-le comme la réflexion de l’onde se propageant le long de la corde dans la direction opposée.

La quantité de retard détermine la fréquence de vibration de la corde virtuelle. Tout comme pour la longueur des cordes de la guitare, un délai plus court entraîne une hauteur plus élevée, tandis qu'un délai plus long produit une hauteur plus grave. Vous pouvez calculer la taille requise du tampon (en termes de nombre d'échantillons audio) à l'aide de la formule suivante :

Pour obtenir le nombre d'échantillons, D, multipliez la période de vibration ou l'inverse de la fréquence fondamentale souhaitée, F0, par la fréquence d'échantillonnage de votre signal, Fs. En termes simples, divisez la fréquence d'échantillonnage par la fréquence fondamentale.

Ensuite, le signal retardé passe par un filtre passe-bas avant d'être ajouté à l'échantillon suivant du tampon. Vous pouvez implémenter à la fois le filtre et l'additionneur en appliquant une moyenne pondérée aux deux échantillons, à condition que leurs poids totalisent un ou moins. Sinon, vous augmenteriez le signal au lieu de l’atténuer. En ajustant les poids, vous pouvez contrôler la décroissance ou l'amortissement des vibrations de votre corde virtuelle.

Au fur et à mesure que le signal traité traverse le tampon, il perd davantage de contenu haute fréquence et s'installe dans un motif qui ressemble beaucoup au son d'une corde pincée. Grâce à la boucle de feedback, vous obtenez l'illusion d'une corde vibrante qui s'estompe progressivement.

Enfin, à l'extrême droite du diagramme, vous pouvez voir la sortie, qui peut être un haut-parleur ou un fichier audio dans lequel vous écrivez les échantillons audio résultants.

Lorsque vous tracez les formes d’onde et leurs spectres de fréquence correspondants à partir de cycles successifs de la boucle de rétroaction, vous observerez l’émergence du modèle suivant :

Le graphique du haut montre les oscillations d’amplitude au fil du temps. Le graphique juste en dessous représente le contenu fréquentiel du signal à des moments précis. Initialement, le tampon est rempli d’échantillons aléatoires dont la répartition des fréquences est à peu près égale sur tout le spectre. Au fil du temps, l’amplitude du signal diminue et la fréquence des oscillations commence à se concentrer sur une bande spectrale particulière. La forme de la forme d’onde ressemble désormais à une onde sinusoïdale.

Puisque vous comprenez maintenant les principes de l’algorithme de Karplus-Strong, vous pouvez implémenter le premier élément du diagramme présenté précédemment.

Utiliser des valeurs aléatoires comme rafale de bruit initiale

Il existe de nombreux types de générateurs de signaux parmi lesquels vous pouvez choisir en synthèse sonore. Certaines des plus populaires incluent des fonctions périodiques telles que l’onde carrée, l’onde triangulaire et l’onde en dents de scie. Cependant, dans l'algorithme de synthèse Karplus-Strong, vous obtiendrez les meilleurs résultats avec une fonction apériodique, comme le bruit aléatoire, en raison de son riche contenu harmonique que vous pouvez filtrer au fil du temps.

Le bruit se décline en différentes couleurs, comme le rose ou le blanc. La différence réside dans leur densité spectrale de puissance à travers les fréquences. Dans le bruit blanc, par exemple, chaque bande de fréquence a approximativement la même puissance. Il est donc parfait pour une première rafale de bruit car il contient une large gamme d'harmoniques que vous pouvez façonner à travers un filtre.

Pour permettre d'expérimenter les différents types de générateurs de signaux, vous définirez une classe de protocole personnalisée dans un nouveau module Python nommé burst :

from typing import Protocol
import numpy as np
from digitar.temporal import Hertz

class BurstGenerator(Protocol):
    def __call__(self, num_samples: int, sampling_rate: Hertz) -> np.ndarray:
        ...

Le but d'une classe de protocole est de spécifier le comportement souhaité via des signatures de méthodes sans implémenter ces méthodes. En Python, vous utilisez généralement des points de suspension (...) pour indiquer que vous avez intentionnellement laissé le corps de la méthode indéfini. Par conséquent, une classe de protocole agit comme une interface en Java, où les classes concrètes implémentant cette interface particulière fournissent la logique sous-jacente.

Dans ce cas, vous avez déclaré la méthode spéciale .__call__()pour rendre appelables les instances des classes qui adhèrent au protocole. Votre méthode attend deux arguments :

  1. Le nombre d’échantillons audio à produire
  2. Le nombre d'échantillons par seconde

De plus, les générateurs de rafales sont censés renvoyer un tableau NumPy de niveaux d'amplitude, qui doivent être des nombres à virgule flottante normalisés à un intervalle compris entre moins un et plus un. Une telle normalisation rendra le traitement audio ultérieur plus pratique.

Votre premier cours de générateur de béton produira du bruit blanc, car vous avez déjà établi qu'il est le plus approprié dans ce contexte :

# ...

class WhiteNoise:
    def __call__(self, num_samples: int, sampling_rate: Hertz) -> np.ndarray:
        return np.random.uniform(-1.0, 1.0, num_samples)

Même si votre nouvelle classe n'hérite pas de BurstGenerator, elle est toujours conforme au protocole que vous avez défini précédemment en fournissant une méthode .__call__() avec la signature correcte. Notez que la méthode prend le taux d'échantillonnage comme deuxième argument même si elle n'y fait référence nulle part dans le corps. C’est nécessaire pour satisfaire au protocole.

Les instances de votre classe génératrice WhiteNoise sont désormais appelables :

>>> from digitar.burst import WhiteNoise

>>> burst_generator = WhiteNoise()
>>> samples = burst_generator(num_samples=1_000_000, sampling_rate=44100)

>>> samples.min()
-0.9999988055552775

>>> samples.max()
0.999999948864092

>>> samples.mean()
-0.0001278112173601203

Les échantillons résultants sont limités à la plage comprise entre -1 et 1, car les valeurs minimales et maximales sont très proches de ces limites. De plus, la valeur moyenne est proche de zéro car, sur un grand nombre d’échantillons, les amplitudes positives et négatives s’équilibrent, confirmant une distribution uniforme des valeurs.

D'accord. Le prochain élément important du diagramme de l’algorithme de Karplus-Strong est la boucle de rétroaction elle-même. Vous allez maintenant le décomposer en morceaux plus petits.

Filtrez les fréquences plus élevées avec une boucle de rétroaction

Une manière élégante de simuler une boucle de rétroaction en Python consiste à câbler les fonctions du générateur ensemble et à leur envoyer des valeurs. Vous pouvez également définir des fonctions asynchrones et les connecter en tant que coroutines coopératives pour obtenir un effet similaire. Cependant, dans ce didacticiel, vous utiliserez une implémentation beaucoup plus simple et légèrement plus efficace basée sur l'itération.

Créez un autre module nommé synthesis dans votre package Python et définissez l'espace réservé de classe suivant :

from dataclasses import dataclass
from digitar.burst import BurstGenerator, WhiteNoise

AUDIO_CD_SAMPLING_RATE = 44100

@dataclass(frozen=True)
class Synthesizer:
    burst_generator: BurstGenerator = WhiteNoise()
    sampling_rate: int = AUDIO_CD_SAMPLING_RATE

Cette classe de données gelées se compose de deux attributs facultatifs, qui vous permettent de spécifier l'implémentation attendue du générateur de rafales et la fréquence d'échantillonnage. Si vous ignorez ces paramètres lors de la création d'une nouvelle instance de la classe, vous vous fierez aux valeurs par défaut, qui utilisent le générateur de bruit blanc à une fréquence d'échantillonnage de 44,1 kHz définie comme constante Python.

En utilisant le package itertools de la bibliothèque standard, vous pouvez désormais implémenter un itérateur infini qui cycle() à travers le tampon des échantillons audio. L'extrait de code suivant reflète le diagramme Karplus-Strong que vous avez vu dans une section précédente :

from dataclasses import dataclass
from itertools import cycle
from typing import Iterator

import numpy as np

from digitar.burst import BurstGenerator, WhiteNoise
from digitar.temporal import Hertz, Time

AUDIO_CD_SAMPLING_RATE = 44100

@dataclass(frozen=True)
class Synthesizer:
    burst_generator: BurstGenerator = WhiteNoise()
    sampling_rate: int = AUDIO_CD_SAMPLING_RATE

    def vibrate(
        self, frequency: Hertz, duration: Time, damping: float = 0.5
    ) -> np.ndarray:
        assert 0 < damping <= 0.5

        def feedback_loop() -> Iterator[float]:
            buffer = self.burst_generator(
                num_samples=round(self.sampling_rate / frequency),
                sampling_rate=self.sampling_rate
            )
            for i in cycle(range(buffer.size)):
                yield (current_sample := buffer[i])
                next_sample = buffer[(i + 1) % buffer.size]
                buffer[i] = (current_sample + next_sample) * damping

Vous définissez la méthode .vibrate() qui prend la fréquence fondamentale, la durée et un coefficient d'amortissement facultatif. comme arguments. Lorsqu’elle n’est pas spécifiée, la valeur par défaut du coefficient divise par deux la somme de deux échantillons adjacents à chaque cycle, ce qui équivaut au calcul d’une moyenne mobile. Il simule la perte d’énergie à mesure que la vibration s’estompe.

Jusqu'à présent, votre méthode définit une fonction interne qui renvoie un itérateur générateur lorsqu'elle est appelée. L'objet générateur résultant alloue et remplit un tampon à l'aide du générateur de rafale fourni. La fonction entre ensuite dans une boucle for infinie qui continue de produire des valeurs du tampon indéfiniment de manière circulaire car elle n'a pas de condition d'arrêt.

Vous utilisez l'opérateur Walrus (:=) pour produire et intercepter simultanément la valeur d'amplitude actuelle dans chaque cycle. Lors de l'itération suivante, vous calculez la moyenne des deux valeurs adjacentes pour simuler l'effet d'amortissement. L'opérateur modulo (%) garantit que l'index revient au début du tampon une fois qu'il atteint la fin, créant un effet de tampon circulaire.

Pour consommer un nombre fini d'échantillons déterminés par le paramètre duration, vous pouvez envelopper votre feedback_loop() avec un appel à la fonction fromiter() de NumPy. :

# ...

@dataclass(frozen=True)
class Synthesizer:
    # ...

    def vibrate(
        self, frequency: Hertz, duration: Time, damping: float = 0.5
    ) -> np.ndarray:
        assert 0 < damping <= 0.5

        def feedback_loop() -> Iterator[float]:
            buffer = self.burst_generator(
                num_samples=round(self.sampling_rate / frequency),
                sampling_rate=self.sampling_rate
            )
            for i in cycle(range(buffer.size)):
                yield (current_sample := buffer[i])
                next_sample = buffer[(i + 1) % buffer.size]
                buffer[i] = (current_sample + next_sample) * damping

        return np.fromiter(
            feedback_loop(),
            np.float64,
            duration.get_num_samples(self.sampling_rate),
        )

Tant que le paramètre duration est une instance de la classe de données Time que vous avez définie précédemment, vous pouvez convertir le nombre de secondes en nombre correspondant d'échantillons audio en appelant .get_num_samples(). N'oubliez pas de transmettre le bon taux d'échantillonnage. Vous devez également spécifier float64 comme type de données pour les éléments de votre tableau NumPy afin de garantir une haute précision et d'éviter les conversions de type inutiles.

Vous avez presque fini d'implémenter l'algorithme de synthèse Karplus-Strong, mais votre code présente deux problèmes mineurs que vous devez d'abord résoudre.

Supprimez la polarisation CC et normalisez les échantillons audio

En fonction de la rafale initiale et du coefficient d'amortissement, vous pouvez vous retrouver avec des valeurs en dehors de la plage d'amplitude attendue, ou les valeurs peuvent s'éloigner de zéro, introduisant une polarisation continue. Cela pourrait entraîner des clics audibles ou d’autres artefacts désagréables. Pour résoudre ces problèmes potentiels, vous supprimerez le biais en soustrayant la valeur moyenne du signal, puis vous normaliserez les échantillons résultants.

NumPy ne fournit pas de fonctions intégrées pour ces tâches, mais créer les vôtres n'est pas trop compliqué. Commencez par créer un nouveau module nommé processing dans votre package avec ces deux fonctions :

import numpy as np

def remove_dc(samples: np.ndarray) -> np.ndarray:
    return samples - samples.mean()

def normalize(samples: np.ndarray) -> np.ndarray:
    return samples / np.abs(samples).max()

Les deux fonctions profitent des capacités de vectorisation de NumPy. Le premier soustrait la valeur moyenne de chaque élément et le second divise tous les échantillons par la valeur absolue maximale dans le tableau d'entrée.

Désormais, vous pouvez importer et appeler vos fonctions d'assistance dans le synthétiseur avant de renvoyer le tableau d'échantillons audio calculés :

from dataclasses import dataclass
from itertools import cycle
from typing import Iterator

import numpy as np

from digitar.burst import BurstGenerator, WhiteNoise
from digitar.processing import normalize, remove_dc
from digitar.temporal import Hertz, Time

AUDIO_CD_SAMPLING_RATE = 44100

@dataclass(frozen=True)
class Synthesizer:
    # ...

    def vibrate(
        self, frequency: Hertz, duration: Time, damping: float = 0.5
    ) -> np.ndarray:
        # ...
        return normalize(
            remove_dc(
                np.fromiter(
                    feedback_loop(),
                    np.float64,
                    duration.get_num_samples(self.sampling_rate),
                )
            )
        )

Bien que l’ordre ne fasse pas de différence significative, il est d’usage de supprimer le biais DC avant d’effectuer la normalisation. La suppression du composant DC garantit que votre signal est centré autour de zéro. Sinon, il pourrait toujours y avoir une composante continue, ce qui pourrait affecter l’échelle globale de la normalisation.

Super! Vous venez d'implémenter l'algorithme de synthèse Karplus-Strong en Python. Pourquoi ne pas le tester pour entendre les résultats ?

Pincez la corde pour produire des sons monophoniques

À proprement parler, votre synthétiseur renvoie un tableau NumPy de niveaux d'amplitude normalisés au lieu d'échantillons audio correspondant directement au son numérique. Dans le même temps, vous pouvez choisir parmi plusieurs formats de données, schémas de compression et encodages pour déterminer comment stocker et transmettre vos données audio.

Par exemple, la modulation linéaire par impulsions et code (LPCM) est un codage standard dans les fichiers WAV non compressés, qui utilisent généralement des entiers signés de 16 bits pour représenter des échantillons audio. D'autres formats comme le MP3 utilisent des algorithmes de compression avec perte qui réduisent la taille du fichier en supprimant les informations moins perceptibles par l'oreille humaine. Ces formats peuvent offrir des débits constants ou variables selon la qualité souhaitée et la taille du fichier.

Pour éviter de vous enliser dans les détails techniques, vous utiliserez la bibliothèque Pedalboard de Spotify, qui peut gérer ces détails de bas niveau pour vous. Vous fournirez les niveaux d’amplitude normalisés de votre synthétiseur et Pedalboard les encodera en conséquence en fonction de votre format de données préféré :

>>> from pedalboard.io import AudioFile

>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time

>>> frequencies = [261.63, 293.66, 329.63, 349.23, 392, 440, 493.88, 523.25]
>>> duration = Time(seconds=0.5)
>>> damping = 0.495

>>> synthesizer = Synthesizer()
>>> with AudioFile("monophonic.mp3", "w", synthesizer.sampling_rate) as file:
...     for frequency in frequencies:
...         file.write(synthesizer.vibrate(frequency, duration, damping))

Dans ce cas, vous enregistrez les sons synthétisés sous forme de fichier MP3 en utilisant les paramètres par défaut de la bibliothèque. L'extrait de code ci-dessus produit un fichier MP3 avec un canal mono échantillonné à 44,1 kHz et un débit binaire constant de 320 kilobits par seconde<, qui est la plus haute qualité prise en charge par ce format. N'oubliez pas d'exécuter le code depuis l'environnement virtuel de votre projet pour accéder aux modules requis.

Pour confirmer certaines de ces propriétés audio, vous pouvez ouvrir le fichier en lecture et vérifier quelques-uns de ses attributs :

>>> with AudioFile("monophonic.mp3") as file:
...     print(f"{file.num_channels = }")
...     print(f"{file.samplerate = }")
...     print(f"{file.file_dtype = }")
...
file.num_channels = 1
file.samplerate = 44100
file.file_dtype = 'float32'

Les fichiers MP3 étant compressés, vous ne pouvez pas calculer leur débit à partir de ces paramètres. Le débit binaire réel est stocké dans l'en-tête du fichier avec d'autres métadonnées, que vous pouvez vérifier à l'aide d'un programme externe comme MediaInfo :

$ mediainfo monophonic.mp3
General
Complete name                            : monophonic.mp3
Format                                   : MPEG Audio
File size                                : 159 KiB
Duration                                 : 4 s 48 ms
Overall bit rate mode                    : Constant
Overall bit rate                         : 320 kb/s
Writing library                          : LAME3.100
(...)

Le fichier généré contient une série de tonalités musicales basées sur les fréquences que vous avez fournies. Chaque ton est soutenu pendant une demi-seconde, ce qui donne une mélodie qui progresse à travers les notes do-re-mi-fa-sol-la-ti-do. Ces tons sont les notes du solfège, souvent utilisées pour enseigner la gamme musicale. Vous trouverez ci-dessous à quoi ils ressemblent lorsqu’ils sont tracés sous forme de forme d’onde. Vous pouvez cliquer sur le bouton de lecture pour écouter :

Notez que chaque tonalité s'arrête brusquement avant d'avoir la possibilité de disparaître complètement. Vous pouvez expérimenter avec une durée plus ou moins longue et ajuster le paramètre d'amortissement. Mais quels que soient vos efforts, vous ne pouvez produire que des sons monophoniques, sans possibilité de superposer plusieurs notes.

Dans la section suivante, vous apprendrez à synthétiser des sons plus complexes, vous rapprochant ainsi de la simulation d’une guitare à part entière.

Étape 3 : Simuler le jeu de plusieurs cordes de guitare

À ce stade, vous pouvez générer des fichiers audio composés de sons monophoniques. Cela signifie que dès que le son suivant commence à jouer, le précédent s'arrête, ce qui entraîne une série de tonalités discrètes. C'est parfait pour les sonneries de téléphone portable à l'ancienne ou les bandes sonores de jeux vidéo rétro. Cependant, lorsqu’un guitariste gratte plusieurs cordes à la fois, celles-ci produisent un accord dont les notes résonnent ensemble.

Dans cette section, vous allez peaufiner votre classe de synthétiseur pour produire des sons polyphoniques en permettant aux notes individuelles de se chevaucher et d'interférer les unes avec les autres.

Mélangez plusieurs notes dans un son polyphonique

Pour jouer plusieurs notes simultanément, vous pouvez mélanger les ondes acoustiques correspondantes. Allez-y et définissez une autre méthode dans votre classe de synthétiseur, qui sera chargée de superposer les échantillons de plusieurs sons les uns sur les autres :

from dataclasses import dataclass
from itertools import cycle
from typing import Iterator, Sequence

# ...

@dataclass(frozen=True)
class Synthesizer:
    # ...

    def overlay(self, sounds: Sequence[np.ndarray]) -> np.ndarray:
        return np.sum(sounds, axis=0)

Cette méthode prend une séquence de tableaux NumPy de taille égale comprenant les amplitudes de plusieurs sons à mélanger. La méthode renvoie ensuite la somme arithmétique élément par élément des ondes sonores d’entrée.

En supposant que vous ayez déjà supprimé la polarisation DC des sons individuels que vous souhaitez mixer, vous n’avez plus à vous en préoccuper. De plus, vous ne souhaitez pas normaliser les sons superposés à ce stade, car leur nombre peut varier considérablement au sein d'une même chanson. Le faire maintenant pourrait conduire à des niveaux de volume incohérents, rendant certains accords musicaux à peine audibles. Au lieu de cela, vous devez appliquer la normalisation avant d'écrire la chanson entière dans le fichier.

Supposons que vous vouliez simuler un artiste pinçant toutes les cordes d'une guitare en même temps. Voici comment procéder en utilisant votre nouvelle méthode :

>>> from pedalboard.io import AudioFile

>>> from digitar.processing import normalize
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time

>>> frequencies = [329.63, 246.94, 196.00, 146.83, 110.00, 82.41]
>>> duration = Time(seconds=3.5)
>>> damping = 0.499

>>> synthesizer = Synthesizer()
>>> sounds = [
...     synthesizer.vibrate(frequency, duration, damping)
...     for frequency in frequencies
... ]

>>> with AudioFile("polyphonic.mp3", "w", synthesizer.sampling_rate) as file:
...     file.write(normalize(synthesizer.overlay(sounds)))

Vous définissez les fréquences correspondant à l'accordage standard d'une guitare à six cordes et réglez la durée d'une note individuelle à trois secondes et demie. De plus, vous ajustez le coefficient d’amortissement à une valeur légèrement supérieure à celle d’avant pour le faire vibrer plus longtemps. Ensuite, vous synthétisez le son de chaque chaîne dans une compréhension de liste et les combinez à l'aide de votre méthode .overlay().

Ce sera la forme d’onde résultante du fichier audio que vous créerez après avoir exécuté le code répertorié ci-dessus :

C’est incontestablement une amélioration par rapport à la version monophonique. Cependant, le fichier synthétisé semble toujours un peu artificiel lorsque vous le jouez. En effet, avec une vraie guitare, les cordes ne sont jamais pincées précisément au même moment. Il y a toujours un léger délai entre chaque pincement de corde. Les interactions d'ondes qui en résultent créent des résonances complexes, ajoutant à la richesse et à l'authenticité du son.

Ensuite, vous allez introduire un délai réglable entre les coups suivants pour donner à votre son polyphonique une sensation plus réaliste. Grâce à cela, vous serez en mesure de discerner la direction de frappe !

Ajustez la vitesse de course pour contrôler le rythme

Lorsque vous touchez rapidement les cordes d'une guitare, le délai entre les pincements successifs est relativement court, ce qui rend le son global fort et net. À l’inverse, le délai augmente à mesure que vous pincez les cordes plus lentement et plus doucement. Vous pouvez pousser cette technique à l'extrême en jouant un arpège ou un accord brisé où vous jouez les notes les unes après les autres plutôt que simultanément.

Maintenant, modifiez votre méthode .overlay() pour qu'elle accepte un paramètre delay supplémentaire représentant l'intervalle de temps entre chaque trait :

# ...

@dataclass(frozen=True)
class Synthesizer:
    # ...

    def overlay(
        self, sounds: Sequence[np.ndarray], delay: Time
    ) -> np.ndarray:
        num_delay_samples = delay.get_num_samples(self.sampling_rate)
        num_samples = max(
            i * num_delay_samples + sound.size
            for i, sound in enumerate(sounds)
        )
        samples = np.zeros(num_samples, dtype=np.float64)
        for i, sound in enumerate(sounds):
            offset = i * num_delay_samples
            samples[offset : offset + sound.size] += sound
        return samples

En fonction de la fréquence d'échantillonnage actuelle de votre synthétiseur, vous convertissez le retard en secondes en nombre d'échantillons correspondant. Ensuite, vous trouvez le nombre total d’échantillons à allouer au tableau résultant, que vous initialisez avec des zéros. Enfin, vous parcourez les sons, en les ajoutant à votre tableau d'échantillons avec le décalage approprié.

Voici le même exemple que vous avez vu dans la section précédente. Cependant, vous disposez désormais d'un délai de quarante millisecondes entre les pincements individuels, et vous faites varier la durée de la vibration en fonction de sa fréquence :

>>> from pedalboard.io import AudioFile

>>> from digitar.processing import normalize
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time

>>> frequencies = [329.63, 246.94, 196.00, 146.83, 110.00, 82.41]
>>> delay = Time.from_milliseconds(40)
>>> damping = 0.499

>>> synthesizer = Synthesizer()
>>> sounds = [
...     synthesizer.vibrate(frequency, Time(3.5 + 0.25 * i), damping)
...     for i, frequency in enumerate(frequencies)
... ]

>>> with AudioFile("arpeggio.mp3", "w", synthesizer.sampling_rate) as file:
...     file.write(normalize(synthesizer.overlay(sounds, delay)))

Les billets avec une fréquence plus basse auront une durée légèrement plus longue que leurs homologues avec une fréquence plus élevée. Cela simule l'inertie des cordes réelles, qui ont tendance à vibrer plus longtemps si elles sont plus épaisses ou plus longues.

Vous trouverez ci-dessous la forme d'onde correspondante, qui semble avoir plus de variation et de complexité :

Si vous regardez attentivement cette forme d’onde, vous verrez les pics individuels au début, indiquant où commencent les notes suivantes. Ils sont équidistants, comme déterminé par votre paramètre de retard.

En modifiant le délai, vous pouvez ajuster la vitesse de frappe pour créer un rythme plus rapide et plus dynamique ou un son plus lent et plus doux. Vous utiliserez ce paramètre pour améliorer l’expressivité de votre instrument virtuel et imiter les phrasés musicaux qu’un guitariste pourrait naturellement utiliser.

Maintenant que vous contrôlez le timing de chaque note d’un accord, vous pouvez expérimenter davantage en modifiant l’ordre dans lequel vous les jouez.

Inversez la direction du grattage pour modifier le timbre

Les guitaristes varient souvent non seulement la vitesse mais aussi la direction de frappe pendant qu'ils jouent. En alternant les pleins et les hauts, ils peuvent mettre en valeur différentes cordes et changer le timbre d'un même accord. Les coups descendants ont tendance à paraître plus puissants et sont généralement plus forts parce que le médiator – ou votre doigt – frappe en premier les cordes les plus graves et les plus épaisses. À l’inverse, les coups vers le haut mettent souvent en valeur les cordes les plus hautes et les plus fines, produisant un son plus léger.

Vous pouvez exprimer à la fois la vitesse et la direction du grattage avec des types de données personnalisés. Créez un module Python nommé Stroke dans votre package digitar et définissez-y ces deux classes :

import enum
from dataclasses import dataclass
from typing import Self

from digitar.temporal import Time

class Direction(enum.Enum):
    DOWN = enum.auto()
    UP = enum.auto()

@dataclass(frozen=True)
class Velocity:
    direction: Direction
    delay: Time

    @classmethod
    def down(cls, delay: Time) -> Self:
        return cls(Direction.DOWN, delay)

    @classmethod
    def up(cls, delay: Time) -> Self:
        return cls(Direction.UP, delay)

La première classe est une énumération Python, qui attribue des valeurs uniques aux directions de trait mutuellement exclusives, qui sont au nombre de deux. La classe suivante, Velocity, utilise cette énumération comme membre, en la combinant avec le délai ou l'intervalle entre les pincements suivants.

Vous pouvez rapidement instancier des objets pour représenter des coups de guitare en appelant des méthodes de classe pratiques sur votre classe Velocity :

>>> from digitar.stroke import Direction, Velocity
>>> from digitar.temporal import Time

>>> slow = Time.from_milliseconds(40)
>>> fast = Time.from_milliseconds(20)

>>> Velocity.down(slow)
Velocity(direction=<Direction.DOWN: 1>, delay=Time(seconds=Decimal('0.04')))

>>> Velocity.up(fast)
Velocity(direction=<Direction.UP: 2>, delay=Time(seconds=Decimal('0.02')))

Le premier coup est lent et dirigé vers le bas, tandis que le second est plus rapide et dirigé vers le haut. Vous utiliserez ces nouveaux types de données dans le projet pour contrôler la sensation musicale de votre guitare numérique.

Mais il existe de nombreuses sortes de guitares dans la nature. Certains ont moins de cordes, d'autres sont plus gros ou plus petits et certains nécessitent un amplificateur électronique. En plus de cela, vous pouvez accorder chaque instrument sur des notes différentes. Ainsi, avant de pouvoir profiter correctement de la vitesse de frappe, vous devez créer un instrument virtuel et apprendre à le manipuler.

Étape 4 : Jouer des notes de musique sur la guitare virtuelle

À ce stade, vous pouvez produire des sons monophoniques et polyphoniques basés sur des fréquences spécifiques avec votre guitare numérique. Dans cette étape, vous modéliserez la relation entre ces fréquences et les notes de musique auxquelles elles correspondent. De plus, vous simulerez l’accordage des cordes de la guitare et l’interaction avec le manche pour créer une expérience de jeu réaliste.

Appuyez sur une corde vibrante pour changer sa hauteur

La plupart des guitares ont entre quatre et douze cordes, chacune capable de produire une variété de hauteurs. Lorsque vous pincez une corde à vide sans toucher le manche de la guitare, la corde commence à vibrer à sa fréquence fondamentale. Cependant, une fois que vous appuyez la corde contre l'une des bandes métalliques ou des frettes le long de la touche, vous raccourcissez effectivement la corde, modifiant ainsi sa fréquence de vibration lorsqu'elle est pincée.

Chaque frette de guitare représente une augmentation de la hauteur d'un seul demi-ton ou d'un demi-ton sur la gamme chromatique, la gamme standard de la musique occidentale. L'échelle chromatique divise chaque octave, ou un ensemble de huit notes de musique, en douze demi-tons également espacés, avec un rapport de la douzième racine de deux entre eux. Lorsque vous montez jusqu’au douzième demi-ton, vous doublez la fréquence de la note qui marque le début d’une octave.

Les distances entre frettes adjacentes dans un instrument à frettes suivent le même principe, reflétant la nature logarithmique de l'augmentation de fréquence à chaque pas. Au fur et à mesure que vous vous déplacez le long du manche et que vous appuyez sur les frettes successives, vous remarquerez que la hauteur de la corde augmente progressivement, d'un demi-ton à la fois.

Sur une guitare à six cordes typique, vous trouverez généralement une vingtaine de frettes ou plus, représentant plus d’une centaine de hauteurs ! Cependant, lorsque vous tenez compte des doublons dus au chevauchement des octaves, le nombre réel de hauteurs distinctes diminue. En réalité, vous pouvez jouer environ quatre octaves de notes de musique, soit une cinquantaine de hauteurs uniques. En revanche, la guitare virtuelle que vous vous apprêtez à construire n’a pas de telles limites !

En Python, vous pouvez implémenter un ajustement de hauteur basé sur un demi-ton comme ceci :

from dataclasses import dataclass
from typing import Self

from digitar.temporal import Hertz

@dataclass(frozen=True)
class Pitch:
    frequency: Hertz

    def adjust(self, num_semitones: int) -> Self:
        return Pitch(self.frequency * 2 ** (num_semitones / 12))

Une fois que vous avez créé une nouvelle hauteur, vous pouvez modifier la fréquence fondamentale correspondante en appelant .adjust() avec le nombre de demi-tons souhaité. Un nombre positif de demi-tons augmentera la fréquence, un nombre négatif la diminuera, tandis que zéro la maintiendra intacte. Notez que vous utilisez l'opérateur d'exponentiation de Python (**) pour calculer la douzième racine de deux, sur laquelle repose la formule.

Pour confirmer que votre code fonctionne comme prévu, vous pouvez exécuter le test suivant :

>>> from digitar.pitch import Pitch

>>> pitch = Pitch(frequency=110.0)
>>> semitones = [-12, 12, 24] + list(range(12))

>>> for num_semitones in sorted(semitones):
...     print(f"{num_semitones:>3}: {pitch.adjust(num_semitones)}")
...
-12: Pitch(frequency=55.0)
  0: Pitch(frequency=110.0)
  1: Pitch(frequency=116.54094037952248)
  2: Pitch(frequency=123.47082531403103)
  3: Pitch(frequency=130.8127826502993)
  4: Pitch(frequency=138.59131548843604)
  5: Pitch(frequency=146.8323839587038)
  6: Pitch(frequency=155.56349186104046)
  7: Pitch(frequency=164.81377845643496)
  8: Pitch(frequency=174.61411571650194)
  9: Pitch(frequency=184.9972113558172)
 10: Pitch(frequency=195.99771799087463)
 11: Pitch(frequency=207.65234878997256)
 12: Pitch(frequency=220.0)
 24: Pitch(frequency=440.0)

Vous commencez par définir une hauteur produite par une corde vibrant à 110 Hz, qui correspond à la note A de la deuxième octave. Ensuite, vous parcourez une liste de nombres de demi-tons pour ajuster la hauteur en conséquence.

Selon que le nombre donné est négatif ou positif, l'ajustement de la fréquence d'exactement douze demi-tons (une octave) réduit de moitié ou double la fréquence d'origine de cette hauteur. Tout ce qui se trouve entre les deux règle la fréquence sur le demi-ton correspondant dans cette octave.

Pouvoir ajuster la fréquence est utile, mais la classe Pitch vous oblige à penser en termes de hauteurs, de demi-tons et d'octaves, ce qui n'est pas des plus pratiques. Vous envelopperez le pitch dans une classe de niveau supérieur dans un nouveau module nommé instrument :

from dataclasses import dataclass

from digitar.pitch import Pitch

@dataclass(frozen=True)
class VibratingString:
    pitch: Pitch

    def press_fret(self, fret_number: int | None = None) -> Pitch:
        if fret_number is None:
            return self.pitch
        return self.pitch.adjust(fret_number)

Pour simuler le pincement d'une chaîne ouverte, transmettez None ou laissez le paramètre fret_number de côté lorsque vous appelez votre méthode .press_fret(). Ce faisant, vous restituerez la hauteur inchangée de la corde. Alternativement, vous pouvez passer zéro comme numéro de frette.

Et voici comment vous pouvez interagir avec votre nouvelle classe :

>>> from digitar.instrument import VibratingString
>>> from digitar.pitch import Pitch

>>> a2_string = VibratingString(Pitch(frequency=110))

>>> a2_string.pitch
Pitch(frequency=110)

>>> a2_string.press_fret(None)
Pitch(frequency=110)

>>> a2_string.press_fret(0)
Pitch(frequency=110.0)

>>> a2_string.press_fret(1)
Pitch(frequency=116.54094037952248)

>>> a2_string.press_fret(12)
Pitch(frequency=220.0)

Vous pouvez désormais traiter les hauteurs et les cordes de guitare indépendamment, ce qui vous permet d'attribuer une hauteur différente à la même corde si vous le souhaitez. Cette cartographie des hauteurs sur les cordes ouvertes est connue sous le nom d’accordage de guitare en musique. Les systèmes d’accordage nécessitent que vous compreniez une notation spécifique des notes de musique, que vous découvrirez dans la section suivante.

Lire les notes de musique à partir de la notation scientifique de la hauteur

Dans la notation scientifique de hauteur, chaque note de musique apparaît sous la forme d'une lettre suivie d'un symbole facultatif, tel qu'un dièse (♯) ou un bémol (♭) désignant des altérations, ainsi qu'un numéro d'octave. Le symbole pointu augmente la hauteur de la note d’un demi-ton, tandis que le symbole plat l’abaisse d’un demi-ton. Si vous omettez le numéro d’octave, alors zéro est implicitement supposé.

Il y a sept lettres dans cette notation, le C marquant les limites de chaque octave :

Semitone 1 2 3 4 5 6 7 8 9 10 11 12 13
Sharp C♯0 D♯0 F♯0 G♯0 A♯0  
Tone C0 D0 E0 F0 G0 A0 B0 C1
Flat D♭0 E♭0 G♭0 A♭0 B♭0  

Dans ce cas, vous regardez la première octave comprenant huit notes : C0, D0, E0, F 0, G0, A0, B0 et C1. Le système démarre à C0 ou juste C, ce qui correspond à environ 16,3516 Hz. Lorsque vous montez jusqu'à C1 à droite, qui commence également l'octave suivante, vous doublez cette fréquence.

Vous pouvez désormais déchiffrer la notation scientifique de la hauteur. Par exemple, A4 indique la note musicale A dans la quatrième octave, avec une fréquence de 440 Hz, qui est la référence de hauteur de concert. De même, C♯4 représente la note do dièse de la quatrième octave, située un demi-ton au-dessus du do médian sur un clavier de piano standard.

En Python, vous pouvez exploiter des expressions régulières pour traduire par programme cette notation en hauteurs numériques. Ajoutez la méthode de classe suivante à la classe Pitch dans le module pitch :

import re
from dataclasses import dataclass
from typing import Self

from digitar.temporal import Hertz

@dataclass(frozen=True)
class Pitch:
    frequency: Hertz

    @classmethod
    def from_scientific_notation(cls, notation: str) -> Self:
        if match := re.fullmatch(r"([A-G]#?)(-?\d+)?", notation):
            note = match.group(1)
            octave = int(match.group(2) or 0)
            semitones = "C C# D D# E F F# G G# A A# B".split()
            index = octave * 12 + semitones.index(note) - 57
            return cls(frequency=440.0 * 2 ** (index / 12))
        else:
            raise ValueError(
                f"Invalid scientific pitch notation: {notation}"
            )

    def adjust(self, num_semitones: int) -> Self:
        return Pitch(self.frequency * 2 ** (num_semitones / 12))

Cette méthode calcule la fréquence d'une note donnée en fonction de sa distance en demi-tons par rapport à A4. Notez qu’il s’agit d’une implémentation simplifiée, qui ne prend en compte que les notes pointues. Si vous devez représenter une note bémol, vous pouvez la réécrire en fonction de sa note dièse équivalente, à condition qu'elle existe. Par exemple, B♭ est identique à A♯.

Voici un exemple d’utilisation de votre nouvelle méthode de classe :

>>> from digitar.pitch import Pitch
>>> for note in "C", "C0", "A#", "C#4", "A4":
...     print(f"{note:>3}", Pitch.from_scientific_notation(note))
...
  C Pitch(frequency=16.351597831287414)
 C0 Pitch(frequency=16.351597831287414)
 A# Pitch(frequency=29.13523509488062)
C#4 Pitch(frequency=277.1826309768721)
 A4 Pitch(frequency=440.0)

Comme vous pouvez le constater, le code accepte et interprète quelques variantes de notation scientifique de hauteur. C'est parfait! Vous êtes maintenant prêt à régler votre guitare numérique.

Effectuer l'accordage des cordes de la guitare virtuelle

Dans le monde réel, les musiciens ajustent la tension des cordes de guitare en serrant ou desserrant les chevilles d'accordage respectives pour obtenir un son parfaitement accordé. Cela leur permet d’attribuer différents ensembles de notes ou de hauteurs de musique aux cordes de leur instrument. Ils réutiliseront occasionnellement la même hauteur pour deux cordes ou plus afin de créer un son plus complet.

En fonction du nombre de cordes d’une guitare, vous attribuerez les notes de musique différemment. Outre l'accordage standard, qui est le choix de notes le plus typique pour un instrument donné, vous pouvez appliquer plusieurs accordages de guitare alternatifs, même lorsque vous disposez du même nombre de cordes.

L'accordage traditionnel d'une guitare à six cordes, de la corde la plus fine (ton le plus aigu) à la plus épaisse (ton le plus grave), est le suivant :

String Note Frequency
1st E4 329.63 Hz
2nd B3 246.94 Hz
3rd G3 196.00 Hz
4th D3 146.83 Hz
5th A2 110.00 Hz
6th E2 82.41 Hz

Si vous êtes droitier, vous utiliserez généralement votre main droite pour gratter ou pincer les cordes près de la rosace pendant que votre main gauche frotte les notes sur le manche. Dans cette orientation, la première corde (E4) est la plus proche du bas, tandis que la sixième corde (E2) est la plus proche du haut.

Il est d'usage d'indiquer les accordages de guitare par ordre de fréquence croissant. Par exemple, l'accordage standard d'une guitare est généralement présenté comme : E2-A2-D 3-G3-B3- E4. Dans le même temps, certaines tablatures de guitare suivent la numérotation des cordes indiquée dans le tableau ci-dessus, ce qui inverse cet ordre. Par conséquent, la ligne supérieure d’une tablature de guitare à six cordes représente généralement la première corde (E4) et la ligne inférieure la sixième corde (E2).

Pour éviter toute confusion, vous respecterez les deux conventions. Ajoutez la classe suivante à votre module instrument afin de pouvoir représenter un accordage de cordes :

from dataclasses import dataclass
from typing import Self

from digitar.pitch import Pitch

# ...

@dataclass(frozen=True)
class StringTuning:
    strings: tuple[VibratingString, ...]

    @classmethod
    def from_notes(cls, *notes: str) -> Self:
        return cls(
            tuple(
                VibratingString(Pitch.from_scientific_notation(note))
                for note in reversed(notes)
            )
        )

Un objet de cette classe contient un tuple d'instances de VibratingString triées par le numéro de chaîne dans l'ordre croissant. Autrement dit, le premier élément du tuple correspond à la première chaîne (E4) et le dernier élément à la sixième chaîne (E2). Notez que le nombre de cordes peut être inférieur ou supérieur à six si vous devez représenter d'autres types d'instruments à cordes, comme un banjo, qui n'a que cinq cordes.

En pratique, vous allez créer de nouvelles instances de la classe StringTuning en appelant la méthode de classe .from_notes() et en transmettant un nombre variable de notes de musique en notation scientifique. Ce faisant, vous devez suivre l'ordre d'accordage des cordes, en commençant par la hauteur la plus basse. En effet, la méthode inverse les notes d'entrée pour correspondre à l'arrangement typique des cordes sur une tablature de guitare.

Voici comment utiliser la classe StringTuning pour représenter différents systèmes d'accordage pour différents instruments à cordes pincées :

>>> from digitar.instrument import StringTuning

>>> StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4")
StringTuning(
    strings=(
        VibratingString(pitch=Pitch(frequency=329.6275569128699)),
        VibratingString(pitch=Pitch(frequency=246.94165062806206)),
        VibratingString(pitch=Pitch(frequency=195.99771799087463)),
        VibratingString(pitch=Pitch(frequency=146.8323839587038)),
        VibratingString(pitch=Pitch(frequency=110.0)),
        VibratingString(pitch=Pitch(frequency=82.4068892282175)),
    )
)

>>> StringTuning.from_notes("E1", "A1", "D2", "G2")
StringTuning(
  strings=(
    VibratingString(pitch=Pitch(frequency=97.99885899543733)),
    VibratingString(pitch=Pitch(frequency=73.41619197935188)),
    VibratingString(pitch=Pitch(frequency=55.0)),
    VibratingString(pitch=Pitch(frequency=41.20344461410875)),
  )
)

Le premier objet représente l’accordage standard d’une guitare à six cordes, tandis que le second représente l’accordage d’une guitare basse à quatre cordes. Vous pouvez utiliser la même approche pour modéliser l’accordage d’autres instruments à cordes en fournissant les notes appropriées pour chaque corde.

Avec cela, vous pouvez obtenir l'effet de fretter la guitare avec vos doigts pour jouer un accord particulier :

>>> tuning = StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4")
>>> frets = (None, None, 2, None, 0, None)
>>> for string, fret_number in zip(tuning.strings, frets):
...     if fret_number is not None:
...         string.press_fret(fret_number)
...
Pitch(frequency=220.0)
Pitch(frequency=110.0)

Dans ce cas, vous utilisez l’accordage de guitare standard. Ensuite, vous simulez l'appui sur la deuxième case sur la troisième corde (G3) et la sortie de la cinquième corde (A2)< ouvrez en les grattant tous les deux. Vous ne caressez pas et ne touchez pas les chaînes restantes, comme indiqué par Aucun dans le tuple. La fonction zip() combine les chaînes et les numéros de frettes correspondants en paires sur lesquelles vous parcourez.

La troisième corde est accordée sur la note G3 ou 196 Hz. Mais, puisque vous l'appuyez sur la deuxième case, vous augmentez sa hauteur de deux demi-tons, ce qui donne une fréquence de 220 Hz. La cinquième corde est accordée sur A2 ou 110 Hz, que vous jouez ouverte ou sans fretting. Lorsque vous mélangez les deux fréquences, vous produisez un accord composé de notes A3 et A2, distantes d'une octave.

Ensuite, vous créerez un type de données personnalisé pour représenter plus facilement les accords musicaux.

Représenter les accords sur un instrument fretté

Auparavant, vous aviez défini un tuple simple pour exprimer les numéros de frettes dans un accord particulier. Vous pouvez être un peu plus explicite en étendant la classe tuple et en restreignant les types de valeurs qui y sont autorisées :

from typing import Self

class Chord(tuple[int | None, ...]):
    @classmethod
    def from_numbers(cls, *numbers: int | None) -> Self:
        return cls(numbers)

Avec les indices de type, vous déclarez que votre tuple ne doit contenir que des entiers représentant les numéros de frettes ou des valeurs vides (Aucun) indiquant une chaîne ouverte. Vous fournissez également une méthode de classe, .from_numbers(), vous permettant de créer une instance Chord en transmettant directement les numéros de frette. Cette méthode prend un nombre variable d'arguments, chacun pouvant être un entier ou Aucun.

Voici comment définir un accord de la section précédente de ce didacticiel à l'aide de la classe Chord :

>>> from digitar.chord import Chord

>>> Chord.from_numbers(None, None, 2, None, 0, None)
(None, None, 2, None, 0, None)

>>> Chord([None, None, 2, None, 0, None])
(None, None, 2, None, 0, None)

Lorsque vous créez une instance Chord à l'aide de la méthode de classe, vous transmettez les numéros de frette comme arguments. Vous pouvez également instancier la classe en passant un objet itérable de valeurs, tel qu'une liste, au constructeur. Cependant, il est généralement plus explicite d’utiliser la méthode .from_numbers().

En résumé, voici les points les plus importants à retenir :

  • La position de la valeur dans le tuple détermine le numéro de chaîne, donc le premier élément correspond à la hauteur la plus haute.
  • Une valeur vide (Aucun) signifie que vous ne pincez pas la chaîne du tout.
  • Zéro représente une corde ouverte, que vous pincez sans appuyer sur aucune frette.
  • D'autres entiers correspondent aux numéros de frettes d'un manche de guitare sur lequel vous appuyez.

Ce sont également des motifs de doigts sur les tablatures de guitare que vous exploiterez plus tard dans le didacticiel. Il est maintenant temps de définir un autre type de données personnalisé avec lequel vous représenterez différents types d’instruments à cordes pincées dans le code.

Modélisez n’importe quel instrument à cordes pincées

Lorsque vous réfléchissez aux principales propriétés qui influencent le son d’un instrument à cordes pincées, il s’agit du nombre de cordes, de leur accordage et du matériau dont elles sont constituées. Bien qu’il ne soit pas le seul, ce dernier aspect affecte la durée pendant laquelle la corde maintiendra sa vibration et le degré d’amortissement de l’énergie.

Vous pouvez facilement exprimer ces attributs en définissant une classe de données dans votre module instrument :

from dataclasses import dataclass
from typing import Self

from digitar.pitch import Pitch
from digitar.temporal import Time

# ...

@dataclass(frozen=True)
class PluckedStringInstrument:
    tuning: StringTuning
    vibration: Time
    damping: float = 0.5

    def __post_init__(self) -> None:
        if not (0 < self.damping <= 0.5):
            raise ValueError(
                "string damping must be in the range of (0, 0.5]"
            )

L'accordage des cordes détermine le nombre de cordes d'un instrument et quelles sont leurs fréquences fondamentales de vibration. Pour plus de simplicité, toutes les cordes d'un instrument partageront le même temps de vibration et le même coefficient d'amortissement, par défaut la moitié. Si vous souhaitez les remplacer individuellement, chaîne par chaîne, vous devrez alors modifier le code vous-même.

La méthode .__post_init__() vérifie si l'amortissement se situe dans la plage de valeurs acceptable.

Vous pouvez définir une propriété pratique dans votre classe pour connaître rapidement le nombre de cordes dans un instrument sans avoir recours à l'objet d'accord :

from dataclasses import dataclass
from functools import cached_property
from typing import Self

from digitar.pitch import Pitch
from digitar.temporal import Time

# ...

@dataclass(frozen=True)
class PluckedStringInstrument:
    # ...

    @cached_property
    def num_strings(self) -> int:
        return len(self.tuning.strings)

C'est une propriété mise en cache pour un accès plus efficace. Après avoir accédé à une telle propriété pour la première fois, Python se souvient de la valeur calculée, donc les accès ultérieurs ne la recalculeront pas puisque la valeur ne change pas pendant la durée de vie d'un objet.

Ensuite, vous pouvez ajouter des méthodes qui prendront une instance Chord, que vous avez construite précédemment, et la transformeront en un tuple de hauteurs que vous pourrez utiliser plus tard pour synthétiser un son polyphonique :

from dataclasses import dataclass
from functools import cache, cached_property
from typing import Self

from digitar.chord import Chord
from digitar.pitch import Pitch
from digitar.temporal import Time

# ...

@dataclass(frozen=True)
class PluckedStringInstrument:
    # ...

    @cache
    def downstroke(self, chord: Chord) -> tuple[Pitch, ...]:
        return tuple(reversed(self.upstroke(chord)))

    @cache
    def upstroke(self, chord: Chord) -> tuple[Pitch, ...]:
        if len(chord) != self.num_strings:
            raise ValueError(
                "chord and instrument must have the same string count"
            )
        return tuple(
            string.press_fret(fret_number)
            for string, fret_number in zip(self.tuning.strings, chord)
            if fret_number is not None
        )

Puisque l’ordre des numéros de frettes dans un accord correspond à l’ordre des cordes de guitare (de bas en haut), caresser un accord simule un mouvement ascendant. Votre méthode .upStroke() utilise une expression génératrice avec une expression conditionnelle, qui semble presque identique à la boucle que vous avez vue précédemment lorsque vous avez effectué le réglage des chaînes. La méthode .downStroke() délègue l'exécution à .upStroke(), intercepte le tuple résultant des objets Pitch et l'inverse.

Étant donné que la plupart des accords se répètent encore et encore selon des motifs similaires au sein d’une même chanson, vous ne souhaitez pas calculer chacun d’entre eux à chaque fois. Au lieu de cela, vous annotez les deux méthodes avec le décorateur @cache pour éviter les calculs redondants. En stockant les tuples calculés, Python renverra le résultat mis en cache lorsque les mêmes entrées se reproduiront.

Vous pouvez désormais modéliser différents types d’instruments à cordes pincées pour reproduire leurs caractéristiques acoustiques uniques. Voici quelques exemples utilisant l’accordage standard de chaque instrument :

>>> from digitar.instrument import PluckedStringInstrument, StringTuning
>>> from digitar.temporal import Time

>>> acoustic_guitar = PluckedStringInstrument(
...     tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
...     vibration=Time(seconds=10),
...     damping=0.498,
... )

>>> bass_guitar = PluckedStringInstrument(
...     tuning=StringTuning.from_notes("E1", "A1", "D2", "G2"),
...     vibration=Time(seconds=10),
...     damping=0.4965,
... )

>>> electric_guitar = PluckedStringInstrument(
...     tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
...     vibration=Time(seconds=0.09),
...     damping=0.475,
... )

>>> banjo = PluckedStringInstrument(
...     tuning=StringTuning.from_notes("G4", "D3", "G3", "B3", "D4"),
...     vibration=Time(seconds=2.5),
...     damping=0.4965,
... )

>>> ukulele = PluckedStringInstrument(
...     tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
...     vibration=Time(seconds=5.0),
...     damping=0.498,
... )

Pour l’instant, ce ne sont que des conteneurs abstraits pour des données logiquement liées. Avant de pouvoir profiter pleinement de ces instruments virtuels et les entendre réellement, vous devez les intégrer dans votre synthétiseur Karplus-Strong, ce que vous ferez ensuite.

Combinez le synthétiseur avec un instrument

Vous souhaitez paramétrer votre synthétiseur avec un instrument à cordes pincées afin de pouvoir synthétiser des sons caractéristiques de cet instrument particulier. Ouvrez maintenant le module synthesis dans votre projet Python et ajoutez un champ instrument à la classe Synthesizer :

from dataclasses import dataclass
from itertools import cycle
from typing import Sequence

import numpy as np

from digitar.burst import BurstGenerator, WhiteNoise
from digitar.instrument import PluckedStringInstrument
from digitar.processing import normalize, remove_dc
from digitar.temporal import Hertz, Time

AUDIO_CD_SAMPLING_RATE = 44100

@dataclass(frozen=True)
class Synthesizer:
    instrument: PluckedStringInstrument
    burst_generator: BurstGenerator = WhiteNoise()
    sampling_rate: int = AUDIO_CD_SAMPLING_RATE

    # ...

En utilisant les propriétés définies dans la classe PluckedStringInstrument, le synthétiseur peut générer des sons qui imitent le timbre et l'expression d'un instrument à cordes pincées, comme une guitare acoustique ou un banjo.

Maintenant que vous avez un instrument dans votre synthétiseur, vous pouvez utiliser ses cordes accordées pour jouer un accord avec la vitesse et la direction données.  :

# ...

from digitar.burst import BurstGenerator, WhiteNoise
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument
from digitar.processing import normalize, remove_dc
from digitar.stroke import Direction, Velocity
from digitar.temporal import Hertz, Time

AUDIO_CD_SAMPLING_RATE = 44100

@dataclass(frozen=True)
class Synthesizer:
    instrument: PluckedStringInstrument
    burst_generator: BurstGenerator = WhiteNoise()
    sampling_rate: int = AUDIO_CD_SAMPLING_RATE

    def strum_strings(
        self, chord: Chord, velocity: Velocity, vibration: Time | None = None
    ) -> np.ndarray:
        if vibration is None:
            vibration = self.instrument.vibration

        if velocity.direction is Direction.UP:
            stroke = self.instrument.upstroke
        else:
            stroke = self.instrument.downstroke

        sounds = tuple(
            self.vibrate(pitch.frequency, vibration, self.instrument.damping)
            for pitch in stroke(chord)
        )

        return self.overlay(sounds, velocity.delay)

    # ...

Votre nouvelle méthode .strum_strings() attend au minimum une instance Chord et une instance Velocity. Vous pouvez éventuellement transmettre la durée de vibration, mais si vous ne le faites pas, la méthode revient à la durée par défaut de l'instrument. En fonction de la direction de frappe souhaitée, il synthétise les hauteurs dans l'ordre croissant ou décroissant des cordes. Enfin, il les superpose avec le délai ou l'arpégiation requis.

Parce que .strum_strings() est devenu la seule partie de l'interface publique de votre classe, vous pouvez signaler que les deux autres méthodes, vibrate() et overlay( ), sont destinés à un usage interne uniquement. Une convention courante en Python pour désigner les méthodes non publiques consiste à préfixer leurs noms avec un seul trait de soulignement (_) :

# ...

@dataclass(frozen=True)
class Synthesizer:
    # ...

    def strum_strings(...) -> np.ndarray:
        # ...

        sounds = tuple(
            self._vibrate(pitch.frequency, vibration, self.instrument.damping)
            for pitch in stroke(chord)
        )

        return self._overlay(sounds, velocity.delay)

    def _vibrate(...) -> np.ndarray:
        # ...

    def _overlay(...) -> np.ndarray:
        # ...

Il est clair maintenant que ._vibrate() et ._overlay() sont des détails d'implémentation qui peuvent changer sans préavis, vous ne devez donc pas y accéder depuis une portée externe.

Votre synthétiseur est presque terminé, mais il manque un détail crucial. Si vous deviez synthétiser un morceau de musique complet, comme la bande originale de Diablo, alors plus de quatre-vingt-dix pour cent du temps de synthèse serait consacré à des calculs redondants. C’est parce que la plupart des chansons consistent en des motifs et des motifs répétitifs. Ce sont ces séquences d’accords répétées qui créent un rythme reconnaissable.

Pour réduire le temps total de synthèse de minutes à quelques secondes, vous pouvez intégrer une mise en cache des résultats intermédiaires. Idéalement, vous voudriez décorer toutes les méthodes de votre classe Synthesizer avec le décorateur @cache pour les calculer une fois pour chaque liste unique d'arguments. Cependant, la mise en cache nécessite que tous les arguments de la méthode puissent être hachés.

Même si vous avez utilisé avec diligence des objets immuables, qui peuvent également être hachés, les tableaux NumPy ne le sont pas. Par conséquent, vous ne pouvez pas mettre en cache les résultats de votre méthode ._overlay(), qui prend une séquence de tableaux comme argument. Au lieu de cela, vous pouvez mettre en cache les deux autres méthodes qui reposent uniquement sur des objets immuables :

from dataclasses import dataclass
from functools import cache
from itertools import cycle
from typing import Sequence

# ...

@dataclass(frozen=True)
class Synthesizer:
    # ...

    @cache
    def strum_strings(...) -> np.ndarray:
        # ...

    @cache
    def _vibrate(...) -> np.ndarray:
        # ...

    def _overlay(...) -> np.ndarray:
        # ...

Avec ce petit changement, vous échangez essentiellement le stockage contre la vitesse. Tant que votre ordinateur dispose de suffisamment de mémoire, cela ne prendra qu’une fraction du temps qu’il aurait autrement. Puisque les résultats sont stockés et récupérés, ils ne seront pas recalculés à chaque fois qu’ils seront demandés.

Que diriez-vous de jouer quelques accords sur certains de vos instruments ? Vous trouverez ci-dessous un court extrait de code qui joue un grattement vers le bas sur toutes les cordes à vide de trois instruments à cordes pincées différents que vous avez définis précédemment :

>>> from pedalboard.io import AudioFile

>>> from digitar.chord import Chord
>>> from digitar.instrument import PluckedStringInstrument, StringTuning
>>> from digitar.stroke import Direction, Velocity
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time

>>> instruments = {
...     "acoustic_guitar": PluckedStringInstrument(
...         tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
...         vibration=Time(seconds=10),
...         damping=0.498,
...     ),
...     "banjo": PluckedStringInstrument(
...         tuning=StringTuning.from_notes("G4", "D3", "G3", "B3", "D4"),
...         vibration=Time(seconds=2.5),
...         damping=0.4965,
...     ),
...     "ukulele": PluckedStringInstrument(
...         tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
...         vibration=Time(seconds=5.0),
...         damping=0.498,
...     ),
... }

>>> for name, instrument in instruments.items():
...     synthesizer = Synthesizer(instrument)
...     amplitudes = synthesizer.strum_strings(
...         Chord([0] * instrument.num_strings),
...         Velocity(Direction.DOWN, Time.from_milliseconds(40))
...     )
...     with AudioFile(f"{name}.mp3", "w", synthesizer.sampling_rate) as file:
...         file.write(amplitudes)

Ce code parcourt un dictionnaire de paires clé-valeur composé du nom de l'instrument et de l'instance PluckedStringInstrument correspondante. Lorsque vous lisez les fichiers audio résultants ci-dessous, vous reconnaîtrez le timbre distinctif de chaque instrument :

Bien. Vous avez toutes les pièces réunies et êtes prêt à jouer de la vraie musique sur votre guitare virtuelle !

Étape 5 : Composez des mélodies avec des motifs de grattage

À ce stade, vous pouvez synthétiser les notes et les accords individuels, qui sonnent comme si vous les aviez joués sur un instrument réel. De plus, vous pouvez simuler différents types d'instruments à cordes pincées et les accorder à votre guise. Dans cette partie du didacticiel, vous composerez des mélodies plus complexes à partir de ces éléments de base.

Allouer une piste audio à votre instrument

La musique est composée d'accords et de notes disposés le long d'une ligne de temps linéaire, intentionnellement espacées pour créer un rythme et une mélodie. Une seule chanson contient souvent plus d'une piste audio correspondant à différents instruments, tels que la guitare solo, la guitare basse et la batterie, ainsi qu'au chant.

Vous représenterez une piste audio avec votre première classe mutable dans ce projet pour permettre d'ajouter et de mélanger progressivement des sons dans l'ordre chronologique. Définissez un nouveau module nommé track contenant la classe AudioTrack suivante :

import numpy as np

from digitar.temporal import Hertz, Time

class AudioTrack:
    def __init__(self, sampling_rate: Hertz) -> None:
        self.sampling_rate = int(sampling_rate)
        self.samples = np.array([], dtype=np.float64)

    def __len__(self) -> int:
        return self.samples.size

    @property
    def duration(self) -> Time:
        return Time(seconds=len(self) / self.sampling_rate)

    def add(self, samples: np.ndarray) -> None:
        self.samples = np.append(self.samples, samples)

Une piste audio contient une séquence d'échantillons audio, ou plus précisément, les niveaux d'amplitude que vous encoderez sous forme d'échantillons avec un format de données choisi. Cependant, vous les utiliserez comme exemples pour simplifier les choses.

Pour créer une nouvelle instance de votre classe AudioTrack, vous devez fournir le taux d'échantillonnage ou la fréquence souhaité en hertz. Il vous permettra de calculer la durée actuelle de la piste en secondes, ainsi que d'ajouter de nouveaux échantillons à un décalage temporel spécifique. Pour l'instant, vous ne pouvez ajouter des échantillons qu'à la toute fin de votre piste existante, sans possibilité de les superposer plus tôt ou de les insérer plus tard.

Vous allez résoudre ce problème maintenant en implémentant une autre méthode dans votre classe :

# ...

class AudioTrack:
    # ...

    def add_at(self, instant: Time, samples: np.ndarray) -> None:
        samples_offset = round(instant.seconds * self.sampling_rate)
        if samples_offset == len(self):
            self.add(samples)
        elif samples_offset > len(self):
            self.add(np.zeros(samples_offset - len(self)))
            self.add(samples)
        else:
            end = samples_offset + len(samples)
            if end > len(self):
                self.add(np.zeros(end - len(self)))
            self.samples[samples_offset:end] += samples

Cette méthode, .add_at(), prend un instantané comme argument en plus de la séquence d'échantillons à ajouter. En fonction du taux d'échantillonnage de la piste, il calcule le décalage en termes de nombre d'échantillons audio. Si le décalage s'aligne sur la longueur actuelle de la piste audio, alors la méthode ajoute les échantillons par délégation à la méthode .add().

Sinon, la logique devient un peu plus complexe :

  • Espace : si le décalage dépasse la longueur actuelle de la piste, la méthode remplit l'espace avec des zéros avant d'ajouter les nouveaux échantillons comme auparavant.
  • Chevauchement complet : si le décalage se situe quelque part au milieu de la piste et que les nouveaux échantillons peuvent y tenir, alors la méthode superpose les nouveaux échantillons sur ceux existants à la position correcte.
  • Chevauchement partiel : si le décalage se situe quelque part au milieu de la piste mais que les nouveaux échantillons dépassent sa fin actuelle, la méthode mélange la partie qui se chevauche et ajoute les échantillons restants qui s'étendent au-delà de la longueur actuelle de la piste. .

La nouvelle méthode vous permet de placer précisément les sons dans une piste audio. Mais vous devez toujours suivre la progression du temps sur la chronologie. Vous allez créer un autre type de données personnalisé pour vous aider.

Suivez la progression musicale sur une chronologie

Le temps ne peut qu'avancer, vous modéliserez donc la chronologie comme une autre classe mutable avec une méthode spéciale pour avancer l'instant actuel. Ouvrez votre module temporel maintenant et ajoutez la définition de classe de données suivante :

# ...

@dataclass
class Timeline:
    instant: Time = Time(seconds=0)

    def __rshift__(self, seconds: Numeric | Time) -> Self:
        self.instant += seconds
        return self

Sauf indication contraire, une chronologie commence à zéro seconde par défaut. Grâce à l'immuabilité des objets Time, vous pouvez en utiliser un comme valeur par défaut pour l'attribut instant.

La méthode .__rshift__() fournit l'implémentation de l'opérateur de décalage à droite au niveau du bit (>>) pour votre classe. Dans ce cas, il s’agit d’une implémentation non standard, qui n’a rien à voir avec des opérations sur bits. Au lieu de cela, il avance la chronologie d'un nombre donné de secondes ou d'un autre objet Time. La méthode met à jour l'instance Timeline actuelle en place et se renvoie elle-même, permettant le chaînage de méthodes ou l'évaluation immédiate de la chronologie décalée.

Notez que le déplacement de la chronologie ajoute soit une valeur numérique, telle qu'un objet Decimal, soit une instance Time à une autre instance Time. Cet ajout ne fonctionnera pas immédiatement car Python ne sait pas comment ajouter deux objets de types de données personnalisés à l'aide de l'opérateur plus (+). Heureusement, vous pouvez lui indiquer comment gérer un tel ajout en implémentant la méthode .__add__() dans votre classe Time :

# ...

@dataclass(frozen=True)
class Time:
    # ...

    def __add__(self, seconds: Numeric | Self) -> Self:
        match seconds:
            case Time() as time:
                return Time(self.seconds + time.seconds)
            case int() | Decimal():
                return Time(self.seconds + seconds)
            case float():
                return Time(self.seconds + Decimal(str(seconds)))
            case Fraction():
                return Time(Fraction.from_decimal(self.seconds) + seconds)
            case _:
                raise TypeError(f"can't add '{type(seconds).__name__}'")

    def get_num_samples(self, sampling_rate: Hertz) -> int:
        return round(self.seconds * round(sampling_rate))

# ...

Lorsque vous fournissez un objet Time comme argument à .__add__(), la méthode calcule la somme des secondes décimales dans les deux instances et renvoie un nouveau Time avec les secondes résultantes. D'un autre côté, si l'argument est l'un des types numériques attendus, la méthode le convertit d'abord de manière appropriée. En cas de type non pris en charge, la méthode génère une exception avec un message d'erreur.

Consultez les exemples suivants pour comprendre comment utiliser la classe Timeline :

>>> from digitar.temporal import Time, Timeline

>>> Timeline()
Timeline(instant=Time(seconds=Decimal('0')))

>>> Timeline(instant=Time.from_milliseconds(100))
Timeline(instant=Time(seconds=Decimal('0.1')))

>>> Timeline() >> 0.1 >> 0.3 >> 0.5
Timeline(instant=Time(seconds=Decimal('0.9')))

>>> from digitar.temporal import Time, Timeline
>>> timeline = Timeline()
>>> for offset in 0.1, 0.3, 0.5:
...     timeline >> offset
...
Timeline(instant=Time(seconds=Decimal('0.1')))
Timeline(instant=Time(seconds=Decimal('0.4')))
Timeline(instant=Time(seconds=Decimal('0.9')))

>>> timeline.instant.seconds
Decimal('0.9')

Ces exemples présentent différentes manières d'utiliser l'opérateur au niveau du bit remplacé pour se déplacer dans le temps. En particulier, vous pouvez enchaîner plusieurs incréments de temps dans une expression pour faire avancer la chronologie de manière cumulative. Une chronologie est persistante, de sorte que toutes les modifications précédentes y sont conservées, vous permettant d'interroger l'instant actuel.

Avec une piste audio et une timeline, vous pouvez enfin composer votre première mélodie. Êtes-vous prêt à vous amuser ?

Répéter les accords dans des intervalles de temps espacés

Pour commencer, vous jouerez le refrain de la chanson à succès de Jason Mraz « I’m Yours » sur un ukulélé virtuel. L'exemple suivant est basé sur une excellente explication généreusement fournie par Adrian de la chaîne Learn And Play sur YouTube. Si vous souhaitez en savoir plus sur la façon de jouer cette chanson en particulier, consultez un didacticiel vidéo beaucoup plus complexe sur la chaîne sœur d'Adrian.

Le refrain de la chanson se compose de quatre accords dans la séquence suivante avec leurs doigtés correspondants pour un ukulélé :

  1. Do majeur : appuyez sur la troisième case de la première corde.
  2. G majeur : appuyez sur la deuxième case de la première corde, la troisième case sur la deuxième corde et la deuxième case sur la troisième corde.
  3. La mineur : appuyez sur la deuxième case de la quatrième corde.
  4. Fa majeur : appuyez sur la première case de la deuxième corde et la deuxième case sur la quatrième corde.

De plus, chaque accord doit être joué selon le modèle de grattage illustré ci-dessous, répété deux fois :

  1. Course descendante (lente)
  2. Course descendante (lente)
  3. Course ascendante (lente)
  4. Montée (rapide)
  5. Course descendante (rapide)
  6. Course ascendante (lente)

En d’autres termes, vous commencez par placer vos doigts sur le manche pour former l’accord souhaité, puis vous continuez à caresser les cordes selon le motif prescrit. Lorsque vous atteignez la fin de ce motif, vous rincez et répétez en le rejouant une fois de plus pour le même accord. Après avoir joué deux fois le motif pour un accord particulier, vous passez à l’accord suivant de la séquence.

Les traits suivants dans le motif sont espacés approximativement selon ces intervalles de temps en secondes :

           
0.65s 0.45s 0.75s 0.2s 0.4s 0.25s

Bien que les décalages d’accords spécifiques aient été estimés à l’oreille, ils sont suffisants pour cet exercice. Vous les utiliserez pour répartir les accords synthétisés sur une piste audio à l’aide d’une timeline.

En l'assemblant, vous pouvez créer un script Python nommé play_chorus.py qui reproduit le motif de grattage du refrain de la chanson. Pour garder les choses en ordre, envisagez de créer un nouveau sous-dossier dans le dossier racine de votre projet, où vous stockerez ces scripts. Par exemple, vous pouvez lui donner le nom demo/ :

from itertools import cycle
from typing import Iterator

from digitar.chord import Chord
from digitar.stroke import Velocity
from digitar.temporal import Time

def strumming_pattern() -> Iterator[tuple[float, Chord, Velocity]]:
    chords = (
        Chord.from_numbers(0, 0, 0, 3),
        Chord.from_numbers(0, 2, 3, 2),
        Chord.from_numbers(2, 0, 0, 0),
        Chord.from_numbers(2, 0, 1, 0),
    )

    fast = Time.from_milliseconds(10)
    slow = Time.from_milliseconds(25)

    strokes = [
        Velocity.down(slow),
        Velocity.down(slow),
        Velocity.up(slow),
        Velocity.up(fast),
        Velocity.down(fast),
        Velocity.up(slow),
    ]

    interval = cycle([0.65, 0.45, 0.75, 0.2, 0.4, 0.25])

    for chord in chords:
        for _ in range(2):  # Repeat each chord twice
            for stroke in strokes:
                yield next(interval), chord, stroke

La fonction strumming_pattern() ci-dessus renvoie un itérateur de triolets, composé de l'intervalle de temps en secondes, d'une instance Chord et d'un objet Velocity qui décrit un accident vasculaire cérébral. L'intervalle est un décalage de l'accord suivant sur la timeline par rapport à l'accord actuel.

Chaque accord indique les numéros de frettes sur lesquels appuyer sur les cordes respectives. N'oubliez pas que les chaînes sont comptées à partir du côté droit, donc le dernier élément du tuple de l'accord représente la première chaîne.

Il existe quatre types de coups au total. La course ascendante et la course descendante sont disponibles en deux versions : lente et rapide, qui diffèrent par le délai entre les plumes consécutives. Vous alternez entre ces traits pour simuler le rythme attendu.

Ensuite, vous pouvez définir un ukulélé virtuel et le connecter au synthétiseur :

from itertools import cycle
from typing import Iterator

from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import Time

def main() -> None:
    ukulele = PluckedStringInstrument(
        tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
        vibration=Time(seconds=5.0),
        damping=0.498,
    )
    synthesizer = Synthesizer(ukulele)

# ...

if __name__ == "__main__":
    main()

Suivant l'idiome nom-main de Python, vous définissez la fonction main() comme point d'entrée de votre script, et vous l'appelez au bas du fichier. Ensuite, vous réutilisez la définition PluckedStringInstrument que vous avez vue dans une section précédente, qui spécifie l'accordage standard d'un ukulélé.

L'étape suivante consiste à synthétiser les accords individuels (en fonction de la façon dont vous jouez sur vos cordes virtuelles) et à les ajouter à une piste audio au bon moment :

from itertools import cycle
from typing import Iterator

from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import Time, Timeline
from digitar.track import AudioTrack

def main() -> None:
    ukulele = PluckedStringInstrument(
        tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
        vibration=Time(seconds=5.0),
        damping=0.498,
    )
    synthesizer = Synthesizer(ukulele)
    audio_track = AudioTrack(synthesizer.sampling_rate)
    timeline = Timeline()
    for interval, chord, stroke in strumming_pattern():
        audio_samples = synthesizer.strum_strings(chord, stroke)
        audio_track.add_at(timeline.instant, audio_samples)
        timeline >> interval

# ...

En fonction de la fréquence d'échantillonnage du synthétiseur, vous créez une piste audio et une chronologie qui commence à zéro seconde. Vous parcourez ensuite le motif de grattage, synthétisez le son de ukulélé suivant et l'ajoutez à la piste audio à l'instant actuel. Enfin, vous avancez la chronologie en utilisant le décalage fourni.

Vous pouvez désormais sauvegarder les amplitudes retenues dans votre piste audio dans un fichier, en pensant à les normaliser pour éviter les écrêtages et autres distorsions :

from itertools import cycle
from typing import Iterator

from pedalboard.io import AudioFile

from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import Time, Timeline
from digitar.track import AudioTrack

def main() -> None:
    ukulele = PluckedStringInstrument(
        tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
        vibration=Time(seconds=5.0),
        damping=0.498,
    )
    synthesizer = Synthesizer(ukulele)
    audio_track = AudioTrack(synthesizer.sampling_rate)
    timeline = Timeline()
    for interval, chord, stroke in strumming_pattern():
        audio_samples = synthesizer.strum_strings(chord, stroke)
        audio_track.add_at(timeline.instant, audio_samples)
        timeline >> interval

    with AudioFile("chorus.mp3", "w", audio_track.sampling_rate) as file:
        file.write(normalize(audio_track.samples))

# ...

Lorsque vous exécutez ce script, vous obtenez un fichier audio nommé chorus.mp3, qui enregistre le motif de grattage et les accords de la chanson :

Donnez-vous une tape dans le dos bien méritée ! Vous venez de réaliser un synthétiseur pour instruments à cordes pincées. Cela fonctionne assez bien mais vous oblige à programmer manuellement les notes individuelles sur la timeline pour qu'elles correspondent au rythme. Cela peut être sujet aux erreurs et maladroit. De plus, vous ne pouvez pas modifier le tempo de la chanson ni le nombre de battements par minute.

Ensuite, vous adopterez une approche plus systématique pour organiser les notes et les accords de musique sur la timeline.

Divisez la chronologie en mesures de battements

La musique tourne autour du temps, qui est au cœur du rythme, du tempo et de la durée des notes individuelles d'une composition. Tout au long de l'histoire, les compositeurs ont trouvé pratique de diviser la chronologie en segments appelés mesures ou mesures, contenant généralement un nombre égal de temps.

Vous pouvez considérer le beat comme l'unité de temps de base dans une composition musicale. C’est une impulsion constante qui détermine le rythme. Le rythme reste généralement cohérent tout au long d'une chanson et vous pouvez le reconnaître intuitivement en tapant du pied ou en frappant dessus dans vos mains. Les musiciens comptent parfois délibérément les rythmes à voix haute ou dans leur tête pour maintenir le timing de leur performance.

Chaque mesure est associée à une signature rythmique composée de deux nombres empilés verticalement. Le chiffre du haut indique le nombre de battements de la mesure, et le chiffre du bas indique une valeur de note fractionnaire, qui représente la durée d'un battement relativement à la note entière. Par exemple, dans une signature rythmique ⁴⁄₄ (4 × ¼), il y a quatre temps par mesure, et la durée du temps est égale à une noire ou ¼- ème de la note entière.

Pour des raisons historiques et pour la commodité de l'interprète, la valeur de note dans une signature rythmique est presque toujours une puissance de deux, ce qui permet une simple subdivision des temps. Lorsque vous souhaitez jouer une note entre les temps principaux de votre mesure, plutôt que sur le temps, vous pouvez augmenter la résolution en utilisant des valeurs de note plus petites. Cependant, ils doivent suivre une série binaire :

Note Value Power of Two
Whole 1 20
Half ½ 2-1
Quarter ¼ 2-2
Eighth 2-3
Sixteenth ¹⁄₁₆ 2-4
Thirty-Second ¹⁄₃₂ 2-5

En pratique, les notes inférieures au seizième sont rarement utilisées. Vous pouvez également combiner quelques valeurs de notes standard pour former des notes pointées encore plus complexes. Par exemple, ¼ + ⅛ + ¹⁄₁₆ vous donne ⁷⁄₁₆, ce qui peut vous aider à créer des rythmes complexes.

Lorsque vous représentez vos notes en utilisant des quantités relatives au lieu de valeurs absolues, vous pouvez contrôler sans effort le tempo ou le rythme de l'ensemble de votre composition. Connaître les relations mutuelles entre les notes individuelles vous permet de déterminer combien de temps les jouer.

Supposons que vous ayez un morceau de musique en commun. Si vous réglez le tempo sur soixante-quinze battements par minute (BPM), alors chaque battement, qui se trouve être une noire dans cette signature rythmique, durera 0,8 seconde. Quatre temps, constituant une seule mesure, dureront 3,2 secondes. Vous pouvez multiplier cela par le nombre total de mesures de la composition pour trouver sa durée.

Pour représenter avec précision les durées fractionnaires des notes en secondes, vous allez implémenter une autre méthode spéciale dans votre classe Time :

# ...

@dataclass(frozen=True)
class Time:
    # ...

    def __mul__(self, seconds: Numeric) -> Self:
        match seconds:
            case int() | Decimal():
                return Time(self.seconds * seconds)
            case float():
                return Time(self.seconds * Decimal(str(seconds)))
            case Fraction():
                return Time(Fraction.from_decimal(self.seconds) * seconds)
            case _:
                raise TypeError(f"can't multiply by '{type(seconds).__name__}'")

    # ...

# ...

La méthode .__mul__() vous permet de surcharger l'opérateur de multiplication (*) dans votre classe. Dans ce cas, la multiplication d'une instance Time par une valeur numérique renvoie un nouvel objet Time avec les secondes décimales mises à jour.

@dataclass(frozen=True)
class Time:
    # ...

    def __radd__(self, seconds: Numeric | Self) -> Self:
        return self + seconds

    def __rmul__(self, seconds: Numeric) -> Self:
        return self * seconds

Les deux méthodes inversent l’ordre des opérandes. Ils vous permettront d'utiliser les opérateurs surchargés, que l'instance Time se trouve à gauche ou à droite de l'opérateur.

Grâce à la prise en charge du type de données Fraction dans votre méthode de multiplication, vous pouvez exprimer avec élégance la durée des notes et mesures de musique :

>>> from fractions import Fraction
>>> from digitar.temporal import Time

>>> beats_per_minute = 75
>>> beats_per_measure = 4
>>> note_value = Fraction(1, 4)

>>> beat = Time(seconds=60 / beats_per_minute)
>>> measure = beat * beats_per_measure

>>> beat
Time(seconds=Decimal('0.8'))

>>> measure
Time(seconds=Decimal('3.2'))

>>> whole_note = beat * note_value.denominator
>>> half_note = whole_note * Fraction(1, 2)
>>> quarter_note = whole_note * Fraction(1, 4)
>>> three_sixteenth_note = whole_note * (Fraction(1, 8) + Fraction(1, 16))

>>> three_sixteenth_note
Time(seconds=Decimal('0.6'))

Cet extrait de code montre comment calculer avec précision la durée de diverses notes de musique en termes de secondes. Cela commence par spécifier le tempo (75 BPM) et la signature rythmique ⁴⁄₄. Vous utilisez ces informations pour obtenir la durée d'un seul battement et d'une mesure en secondes. En fonction de la durée du temps et de la valeur de la note, vous dérivez ensuite la durée de la note entière et de ses fractions.

Votre classe Timeline existante ne comprend que les secondes lorsqu'il s'agit de suivre la progression temporelle. Dans la section suivante, vous l’étendrez pour prendre également en charge les mesures musicales auxquelles vous pourrez rapidement accéder.

Mettre en œuvre un calendrier de suivi des mesures

Lorsque vous lisez une notation musicale, telle qu'une tablature de guitare, vous devez organiser les notes sur une chronologie en utilisant des décalages relatifs dans la mesure actuelle pour garantir un timing et un rythme précis. Vous avez vu comment déterminer la durée d’une note et vous pouvez la placer sur une chronologie. Cependant, vous n’avez aucun moyen de trouver les limites de la mesure et de passer à la mesure suivante si celle en cours n’est pas encore entièrement remplie.

Allez-y et définissez une autre classe de données mutable qui étend votre classe de base Timeline avec deux champs supplémentaires, .measure et .last_measure_ended_at :

from dataclasses import dataclass, field

# ...

@dataclass
class MeasuredTimeline(Timeline):
    measure: Time = Time(seconds=0)
    last_measure_ended_at: Time = field(init=False, repr=False)

Une fois que vous héritez d'une autre classe de données comportant au moins un champ avec une valeur par défaut, vous devez déclarer également des valeurs par défaut dans votre sous-classe. En effet, les champs autres que ceux par défaut ne peuvent pas suivre ceux par défaut, même s'ils sont définis dans la superclasse. Ainsi, pour satisfaire aux exigences syntaxiques, vous spécifiez zéro seconde comme valeur par défaut pour le champ .measure, même si vous fournissez généralement votre propre valeur lors de la création de l'objet.

Alors que le premier attribut indique la durée de la mesure en cours, le deuxième attribut permet de savoir quand la dernière mesure s'est terminée. Sa valeur dépendant des champs .instant et .measure de la timeline, vous devez l'initialiser manuellement dans .__post_init__() :

# ...

@dataclass
class MeasuredTimeline(Timeline):
    measure: Time = Time(seconds=0)
    last_measure_ended_at: Time = field(init=False, repr=False)

    def __post_init__(self) -> None:
        if self.measure.seconds > 0 and self.instant.seconds > 0:
            periods = self.instant.seconds // self.measure.seconds
            self.last_measure_ended_at = Time(periods * self.measure.seconds)
        else:
            self.last_measure_ended_at = Time(seconds=0)

Si la taille de la mesure a été spécifiée et que la position actuelle sur la chronologie est supérieure à zéro seconde, vous calculez le nombre de mesures complètes qui se sont écoulées et définissez .last_measure_ended_at en conséquence. Sinon, vous le laissez à la valeur par défaut de zéro seconde.

Vous pouvez continuer à utiliser l'opérateur de décalage vers la droite au niveau du bit (>>) comme avant pour faire avancer l'attribut .instant de la timeline. Cependant, vous souhaitez également passer à la mesure suivante à tout moment, même si vous êtes encore au milieu d'une autre mesure. Pour ce faire, vous pouvez implémenter la méthode .__next__() dans votre classe comme suit :

 # ...

@dataclass
class MeasuredTimeline(Timeline):
    # ...

    def __next__(self) -> Self:
        if self.measure.seconds <= 0:
            raise ValueError("measure duration must be positive")
        self.last_measure_ended_at += self.measure
        self.instant = self.last_measure_ended_at
        return self

Avant d’essayer de mettre à jour les autres champs, assurez-vous que la durée en secondes de la mesure actuelle est positive. Lorsque c'est le cas, vous ajoutez la durée à l'attribut .last_measure_ended_at, marquant la fin de la mesure en cours. Ensuite, vous définissez l'attribut .instant de la timeline sur cette nouvelle valeur afin d'avancer jusqu'au début de la mesure suivante. Enfin, vous renvoyez votre objet MeasuredTimeline pour permettre le chaînage des méthodes et des opérateurs.

Après avoir créé une instance de la classe avec une taille de mesure non nulle, vous pouvez commencer à passer d'une mesure à l'autre :

>>> from digitar.temporal import MeasuredTimeline, Time

>>> timeline = MeasuredTimeline(measure=Time(seconds=3.2))

>>> timeline.instant
Time(seconds=Decimal('0'))

>>> (timeline >> Time(0.6) >> Time(0.8)).instant
Time(seconds=Decimal('1.4'))

>>> next(timeline).instant
Time(seconds=Decimal('3.2'))

>>> timeline.measure = Time(seconds=2.0)

>>> next(timeline).instant
Time(seconds=Decimal('5.2'))

Sauf indication contraire de votre part, l'objet MeasuredTimeline commence à zéro seconde, tout comme la chronologie normale. Vous pouvez utiliser l'opérateur de décalage à droite au niveau du bit comme d'habitude. De plus, en appelant la fonction intégrée next(), vous pouvez ignorer la partie restante de la mesure actuelle et passer au début de la mesure suivante. Lorsque vous décidez de modifier la taille de la mesure, cela est reflété dans les appels ultérieurs à next().

Maintenant que vous connaissez les mesures musicales, les rythmes et les notes fractionnaires, vous êtes prêt à synthétiser une composition basée sur une véritable tablature de guitare.

Apprenez à lire une tablature de guitare

Comme mentionné précédemment, la tablature de guitare, souvent abrégée en tablature de guitare, est une forme simplifiée de notation musicale destinée aux joueurs débutants et amateurs qui pourraient se sentir moins à l'aise avec les partitions traditionnelles. Dans le même temps, les musiciens professionnels n’hésitent pas à utiliser les tablatures de guitare en raison de leur commodité pour enseigner et partager des idées.

Étant donné que cette notation est spécifiquement conçue pour les instruments à cordes, une tablature de guitare contient des lignes horizontales représentant les cordes avec des numéros au-dessus d'elles indiquant sur quelles frettes appuyer. Selon le type d'instrument, le nombre de lignes peut varier, mais il y aura six lignes pour une guitare typique.

L’ordre des cordes de guitare dans une tablature n’est pas standardisé, recherchez donc toujours les étiquettes indiquant les numéros de cordes ou les lettres correspondant à leur accordage.

Vous pouvez rechercher des tablatures de guitare gratuites en ligne. Comme mentionné au début de ce didacticiel, Songsterr est un site Web communautaire hébergeant plus d'un million d'onglets. Il y a de fortes chances que vous y trouviez les tablatures de vos morceaux préférés. Dans le cadre de cet exemple, vous allez recréer la bande-son emblématique du jeu Diablo.

Jetez maintenant un œil au thème Tristram du jeu de Matt Uelmen sur Songsterr. La capture d'écran ci-dessous dévoile ses quatre premières mesures tout en annotant les éléments les plus importants de la tablature de guitare :

La tablature ci-dessus commence par des étiquettes de cordes correspondant à l'accordage standard de la guitare, la ⁴⁄₄ signature rythmique et un tempo de soixante-quinze battements par minute. . Chaque mesure est numérotée et séparée de ses voisines par une ligne verticale pour vous aider à vous orienter dans la lecture de la musique.

Les chiffres en gras qui apparaissent sur les lignes horizontales indiquent les frettes sur lesquelles vous devez appuyer sur les cordes correspondantes pour faire sonner l'accord souhaité. Enfin, les symboles sous chaque mesure représentent la durée fractionnée des notes et des silences (pauses) par rapport à la note entière.

Sur la base de ces connaissances, vous pouvez interpréter la tablature fournie et lui donner vie à l'aide du synthétiseur de guitare que vous avez implémenté. Dans un premier temps, vous coderez en dur la tablature de guitare Diablo dans un script Python en utilisant une approche programmatique.

Jouer à la tablature Diablo par programmation

Créez un nouveau script appelé play_diablo.py dans le dossier demo/ avec le contenu suivant :

from fractions import Fraction

from digitar.temporal import Time

BEATS_PER_MINUTE = 75
BEATS_PER_MEASURE = 4
NOTE_VALUE = Fraction(1, 4)

class MeasureTiming:
    BEAT = Time(seconds=60 / BEATS_PER_MINUTE)
    MEASURE = BEAT * BEATS_PER_MEASURE

class Note:
    WHOLE = MeasureTiming.BEAT * NOTE_VALUE.denominator
    SEVEN_SIXTEENTH = WHOLE * Fraction(7, 16)
    FIVE_SIXTEENTH = WHOLE * Fraction(5, 16)
    THREE_SIXTEENTH = WHOLE * Fraction(3, 16)
    ONE_EIGHTH = WHOLE * Fraction(1, 8)
    ONE_SIXTEENTH = WHOLE * Fraction(1, 16)
    ONE_THIRTY_SECOND = WHOLE * Fraction(1, 32)

class StrummingSpeed:
    SLOW = Time.from_milliseconds(40)
    FAST = Time.from_milliseconds(20)
    SUPER_FAST = Time.from_milliseconds(5)

Les constantes en surbrillance représentent les seuls paramètres d'entrée que vous pouvez modifier, tandis que les valeurs restantes sont dérivées de ces entrées. Ici, vous regroupez les valeurs logiquement liées sous des espaces de noms communs en les définissant comme attributs de classe. Les noms de classes respectifs vous indiquent leur objectif.

Ensuite, définissez votre guitare virtuelle, connectez-la au synthétiseur et préparez la piste audio ainsi que la timeline qui prend en compte les mesures de tabulation :

from fractions import Fraction

from pedalboard.io import AudioFile

from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack

# ...

def main() -> None:
    acoustic_guitar = PluckedStringInstrument(
        tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
        vibration=Time(seconds=10),
        damping=0.498,
    )
    synthesizer = Synthesizer(acoustic_guitar)
    audio_track = AudioTrack(synthesizer.sampling_rate)
    timeline = MeasuredTimeline(measure=MeasureTiming.MEASURE)
    save(audio_track, "diablo.mp3")

def save(audio_track: AudioTrack, filename: str) -> None:
    with AudioFile(filename, "w", audio_track.sampling_rate) as file:
        file.write(normalize(audio_track.samples))
    print(f"\nSaved file {filename!r}")

if __name__ == "__main__":
    main()

Vous réutilisez l'objet guitare acoustique des sections précédentes, qui applique l'accordage standard, et vous définissez une fonction d'assistance pour enregistrer l'audio résultant dans un fichier.

Ce que vous mettrez sur la timeline, ce sont des sons synthétisés décrits par l'instant actuel, les numéros de frettes sur lesquels appuyer et la vitesse de frappe, que vous pouvez modéliser comme une classe de données immuable :

from dataclasses import dataclass
from fractions import Fraction

from pedalboard.io import AudioFile

from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack

# ...

@dataclass(frozen=True)
class Stroke:
    instant: Time
    chord: Chord
    velocity: Velocity

# ...

Les objets de la classe Stroke représentent précisément ce que vous voyez sur la tablature de guitare fournie par Songsterr. Vous pouvez maintenant traduire chaque mesure en une séquence de traits à parcourir :

# ...

def main() -> None:
    acoustic_guitar = PluckedStringInstrument(
        tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
        vibration=Time(seconds=10),
        damping=0.498,
    )
    synthesizer = Synthesizer(acoustic_guitar)
    audio_track = AudioTrack(synthesizer.sampling_rate)
    timeline = MeasuredTimeline(measure=MeasureTiming.MEASURE)
    for measure in measures(timeline):
        for stroke in measure:
            audio_track.add_at(
                stroke.instant,
                synthesizer.strum_strings(stroke.chord, stroke.velocity),
            )
    save(audio_track, "diablo.mp3")

def measures(timeline: MeasuredTimeline) -> tuple[tuple[Stroke, ...], ...]:
    return (
        measure_01(timeline),
        measure_02(timeline),
    )

# ...

Tout d’abord, vous parcourez une séquence de mesures renvoyées par votre fonction measures(), que vous appelez avec la chronologie comme argument. Ensuite, vous parcourez chaque trait de la mesure actuelle, synthétisez l'accord correspondant et l'ajoutez à la piste au bon moment.

Votre tablature de guitare contient actuellement deux mesures, chacune calculée dans une fonction distincte, que vous pouvez définir maintenant :

# ...

def measure_01(timeline: MeasuredTimeline) -> tuple[Stroke, ...]:
    return (
        Stroke(
            timeline.instant,
            Chord.from_numbers(0, 0, 2, 2, 0, None),
            Velocity.down(StrummingSpeed.SLOW),
        ),
        Stroke(
            (timeline >> Note.THREE_SIXTEENTH).instant,
            Chord.from_numbers(None, 0, 2, None, None, None),
            Velocity.up(StrummingSpeed.FAST),
        ),
        Stroke(
            (timeline >> Note.ONE_EIGHTH).instant,
            Chord.from_numbers(0, 0, 2, 2, 0, None),
            Velocity.down(StrummingSpeed.SLOW),
        ),
    )

def measure_02(timeline: MeasuredTimeline) -> tuple[Stroke, ...]:
    return (
        Stroke(
            next(timeline).instant,
            Chord.from_numbers(0, 4, 2, 1, 0, None),
            Velocity.down(StrummingSpeed.SLOW),
        ),
        Stroke(
            (timeline >> Note.THREE_SIXTEENTH).instant,
            Chord.from_numbers(None, None, 2, None, None, None),
            Velocity.down(StrummingSpeed.SUPER_FAST),
        ),
        Stroke(
            (timeline >> Note.ONE_EIGHTH).instant,
            Chord.from_numbers(0, 4, 2, 1, 0, None),
            Velocity.down(StrummingSpeed.SLOW),
        ),
        Stroke(
            (timeline >> Note.SEVEN_SIXTEENTH).instant,
            Chord.from_numbers(7, None, None, None, None, None),
            Velocity.down(StrummingSpeed.SUPER_FAST),
        ),
    )

# ...

La tablature de guitare complète de Diablo comporte soixante-dix-huit mesures avec plus de mille coups au total. Par souci de concision, l'extrait de code ci-dessus ne montre que les deux premières mesures, ce qui devrait suffire à reconnaître le fameux thème. Même si cela suffit pour l'exemple, n'hésitez pas à mettre en œuvre les mesures suivantes basées sur l'onglet Songsterr.

Alternativement, vous pouvez copier le code source final des fonctions restantes à partir des matériaux bonus. Pour les obtenir, cliquez sur le lien ci-dessous :

Attention, le script complet play_diablo.py contient plusieurs milliers de lignes de code Python ! Par conséquent, vous trouverez peut-être plus pratique de continuer à travailler sur ce prototype minimal viable pour le moment.

Notez que chaque trait, à l'exception du tout premier, décale la chronologie d'une fraction de la note entière pour refléter la durée de l'accord précédent. Cela garantit un espacement correct entre les accords adjacents. De plus, la ligne en surbrillance déplace la chronologie au début de la mesure suivante dans l'onglet.

Au total, vous aurez besoin de six notes fractionnaires uniques pour la bande originale de Diablo. Lorsque vous connaissez la durée entière de la note en secondes, vous pouvez rapidement déduire les durées des notes restantes :

Note Seconds Fraction
Whole 3.2s 1
Seven-sixteenth 1.4s ⁷⁄₁₆ = (¹⁄₁₆ + ⅛ + ¼)
Five-sixteenth 1.0s ⁵⁄₁₆ = (¹⁄₁₆ + ¼)
Three-sixteenth 0.6s ³⁄₁₆ = (¹⁄₁₆ + ⅛)
One-eighth 0.4s
One-sixteenth 0.2s ¹⁄₁₆
One-thirty-second 0.1s ¹⁄₃₂

En supposant que la note entière a la même durée que la mesure entière, 3,2 secondes, alors une note d'une trente seconde équivaut à 0,1 seconde, et ainsi de suite. La division de la durée de la note entière permet de reconstituer le rythme de la bande sonore avec une grande précision.

Ne serait-il pas formidable de créer un lecteur universel capable de lire et de synthétiser n’importe quelle tablature de guitare au lieu de celle-ci en particulier ? Vous finirez par y arriver, mais avant cela, vous affinerez la synthèse pour la rendre encore plus authentique.

Étape 6 : Appliquer des effets spéciaux pour plus de réalisme

À ce stade, votre synthétiseur de guitare fait un assez bon travail de simulation d'un instrument réel, mais le son reste un peu dur et artificiel. Il existe de nombreuses façons d’améliorer le timbre de la guitare virtuelle, mais dans cette section, vous vous limiterez aux effets spéciaux fournis par la bibliothèque Pedalboard. Il permet d'enchaîner plusieurs effets, à la manière d'un véritable pédalier de guitare actionné au pied.

Boostez les basses et ajoutez un effet de réverbération

Une vraie guitare possède une caisse de résonance qui produit un son riche et vibrant vers les basses fréquences. Pour imiter cela dans votre guitare virtuelle et amplifier les basses, vous pouvez utiliser un égaliseur audio (EQ). De plus, en ajoutant un effet de réverbération, vous simulerez l’écho et la décroissance naturels qui se produisent dans un espace physique, donnant ainsi au son plus de profondeur et de réalisme.

Bien que Pedalboard n'inclue pas d'égaliseur dédié, vous pouvez combiner différents plugins audio pour obtenir l'effet souhaité. Modifiez votre script play_diablo.py en appliquant une réverbération, un filtre Low Shelf et un gain à la piste audio synthétisée :

from dataclasses import dataclass
from fractions import Fraction

import numpy as np
from pedalboard import Gain, LowShelfFilter, Pedalboard, Reverb
from pedalboard.io import AudioFile

from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack

# ...

def save(audio_track, filename):
    with AudioFile(filename, "w", audio_track.sampling_rate) as file:
        file.write(normalize(apply_effects(audio_track)))
    print(f"\nSaved file {filename!r}")

def apply_effects(audio_track: AudioTrack) -> np.ndarray:
    effects = Pedalboard([
        Reverb(),
        LowShelfFilter(cutoff_frequency_hz=440, gain_db=10, q=1),
        Gain(gain_db=6),
    ])
    return effects(audio_track.samples, audio_track.sampling_rate)

if __name__ == "__main__":
    main()

Tout d’abord, vous importez les plugins correspondants depuis la bibliothèque et les utilisez pour créer un pédalier virtuel. Une fois assemblé, vous l'appelez sur la piste audio et normalisez les échantillons obtenus avant de les enregistrer dans un fichier.

La réverbération s'appuie sur les paramètres par défaut, tandis que le filtre Low Shelf est réglé avec une fréquence de coupure de 440 Hz, un gain de 10 dB et un facteur Q de 1. Le gain est réglé pour augmenter le volume de 6 dB. Vous pouvez expérimenter différentes valeurs de paramètres pour adapter le son à votre goût ou pour mieux l'adapter à un genre musical particulier.

Lorsque vous exécutez à nouveau le script et lisez le fichier audio résultant, vous devriez entendre un son plus naturel. Votre guitare numérique commence à ressembler au son d'une guitare acoustique. Cependant, il existe un effet particulier qui peut faire une réelle différence, et vous allez l’explorer maintenant.

Appliquer un filtre de réverbération à convolution avec un IR

L'idée derrière une réverbération à convolution est de simuler la réverbération d'un espace physique à travers un filtre qui utilise une réponse impulsionnelle (IR). Une réponse impulsionnelle est un enregistrement des caractéristiques acoustiques d'un lieu réel, tel qu'une salle de concert, une église ou une petite pièce. Il s’agit généralement d’un son court, comme un claquement ou un éclatement de ballon, qui capture la façon dont un espace réagit à un spectre complet de fréquences.

Avec ce type spécial de réverbération, vous pouvez par exemple enregistrer des voix dans un studio et appliquer l'ambiance d'une grande cathédrale en post-production. Vous aurez l’impression que la performance a réellement été enregistrée à cet endroit précis. Consultez la bibliothèque Open AIR pour une collection de réponses impulsionnelles de haute qualité provenant de divers endroits du monde. Vous pouvez écouter et comparer les versions avant et après. La différence est remarquable !

Dans le contexte des guitares, les réponses impulsionnelles peuvent vous aider à émuler le son de différents amplificateurs de guitare ou même à modéliser le son d'instruments spécifiques, comme un banjo ou un ukulélé. Le filtre convolutionne votre signal non traité ou sec avec la réponse impulsionnelle, imprimant efficacement les caractéristiques acoustiques de l'instrument d'origine sur l'audio. Cela crée un effet très réaliste, ajoutant de la profondeur et du caractère à votre guitare numérique.

Ouvrez à nouveau le script play_diablo.py et insérez un filtre de convolution avec un chemin vers le fichier de réponse impulsionnelle d'une guitare acoustique :

from dataclasses import dataclass
from fractions import Fraction

import numpy as np
from pedalboard import Convolution, Gain, LowShelfFilter, Pedalboard, Reverb
from pedalboard.io import AudioFile

# ...

def apply_effects(audio_track: AudioTrack) -> np.ndarray:
    effects = Pedalboard([
        Reverb(),
        Convolution(impulse_response_filename="ir/acoustic.wav", mix=0.95),
        LowShelfFilter(cutoff_frequency_hz=440, gain_db=10, q=1),
        Gain(gain_db=6),
    ])
    return effects(audio_track.samples, audio_track.sampling_rate)

if __name__ == "__main__":
    main()

Il existe de nombreuses réponses impulsionnelles gratuites pour les guitares en ligne. Cependant, en trouver un de bonne qualité peut être un peu un défi. Les fichiers de réponses impulsionnelles utilisés dans ce didacticiel proviennent de ces sources :

Acoustic Guitar

Tay816 M251 SB1.wav

Bass Guitar

Saint Graal C800 SB1.wav

Electric Guitar

Rocksta Reactions Mesa Traditional D6 D 0 -18 -36.wav

Banjo

IR_5_string_banjo_dazzo_IR44k

Ukulele

AtlasV2.wav

Vous pouvez suivre les liens ci-dessus pour trouver les packs d’échantillons correspondants. Vous pouvez également télécharger les supports de ce didacticiel, qui incluent des fichiers de réponse impulsionnelle individuels bien nommés. Une fois que vous avez téléchargé ces fichiers, placez-les dans le sous-dossier ir/ où vous conservez vos scripts de démonstration :

digital-guitar/
│
├── demo/
│   ├── ir/
│   │   ├── acoustic.wav
│   │   ├── banjo.wav
│   │   ├── bass.wav
│   │   ├── electric.wav
│   │   └── ukulele.wav
│   │
│   ├── play_chorus.py
│   └── play_diablo.py
│
└── (...)

Vous pouvez maintenant mettre à jour votre autre script, play_chorus.py, en appliquant des effets similaires et en utilisant la réponse impulsionnelle correspondante pour améliorer le son synthétisé :

from itertools import cycle
from typing import Iterator

from pedalboard import Convolution, Gain, LowShelfFilter, Pedalboard, Reverb
from pedalboard.io import AudioFile

# ...

def main() -> None:
    ukulele = PluckedStringInstrument(
        tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
        vibration=Time(seconds=5.0),
        damping=0.498,
    )
    synthesizer = Synthesizer(ukulele)
    audio_track = AudioTrack(synthesizer.sampling_rate)
    timeline = Timeline()
    for interval, chord, stroke in strumming_pattern():
        audio_samples = synthesizer.strum_strings(chord, stroke)
        audio_track.add_at(timeline.instant, audio_samples)
        timeline >> interval
    effects = Pedalboard(
        [
            Reverb(),
            Convolution(impulse_response_filename="ir/ukulele.wav", mix=0.95),
            LowShelfFilter(cutoff_frequency_hz=440, gain_db=10, q=1),
            Gain(gain_db=15),
        ]
    )
    samples = effects(audio_track.samples, audio_track.sampling_rate)
    with AudioFile("chorus.mp3", "w", audio_track.sampling_rate) as file:
        file.write(normalize(samples))

# ...

Encore une fois, vous pouvez jouer avec ces paramètres ou même essayer différents plugins de la bibliothèque Pedalboard.

D'accord. Jusqu’à présent, vous avez modélisé une guitare acoustique et un ukulélé. Que diriez-vous de jouer d'une guitare électrique ou d'une guitare basse cette fois-ci ? Comme vous êtes sur le point de le constater, la simulation de ces instruments se résume principalement à choisir les bons effets dans la bibliothèque de plugins et à peaufiner l’accordage des cordes et le temps de vibration. Pour éviter la duplication et la répétition du code, vous conserverez désormais les accords dans un fichier séparé.

Étape 7 : Charger une tablature de guitare à partir d'un fichier

Il existe de nombreux formats de données de tablatures de guitare, allant de la simple tablature ASCII aux formats binaires plus complexes comme Power Tab ou Guitar Pro. Certains d’entre eux nécessitent un logiciel spécialisé ou propriétaire pour être lu. Dans cette section, vous allez concevoir votre propre format de fichier pour représenter les fonctionnalités les plus essentielles des onglets hébergés sur le site Web Songsterr. À la fin, vous compléterez le tout avec un lecteur de tablature dédié pour pouvoir entendre la musique !

Concevez le format de fichier pour vos tablatures de guitare

Avant d’écrire une seule ligne de code, prenez du recul et réfléchissez à la manière dont vous souhaitez utiliser votre nouveau format de tablature de guitare. En particulier, quel type d’informations souhaitez-vous que l’onglet inclue et comment comptez-vous les présenter ?

Vous trouverez ci-dessous les objectifs de conception suggérés pour votre format personnalisé, qui devraient être :

  • Lisible par l'homme : le format doit être lisible par les humains afin que vous puissiez modifier les onglets dans un éditeur de texte brut.
  • Intuitif : vous souhaitez que le format ait une syntaxe familière et une courbe d'apprentissage douce pour que vous vous sentiez chez vous le plus rapidement possible.
  • Concis : la plupart des chansons répètent les mêmes accords et modèles, le format doit donc les représenter efficacement pour éviter toute verbosité inutile.
  • Hiérarchique : le format doit avoir une structure hiérarchique, permettant une désérialisation pratique vers un dictionnaire Python.
  • Multi-Track : Un fichier à onglet unique doit vous permettre de stocker une ou plusieurs pistes correspondant à des instruments virtuels et de les mélanger dans diverses proportions.

Lorsque vous prenez en compte ces exigences, XML, JSON et YAML apparaissent comme les meilleurs candidats pour le format de données sous-jacent sur lequel vous pouvez construire. Tous sont basés sur du texte, largement connus et ont une structure hiérarchique, vous permettant d'y insérer plusieurs pistes. Cela dit, seul YAML coche toutes les cases, car on ne peut pas facilement éviter les répétitions avec les deux autres formats.

YAML est également un bon choix car il prend en charge les ancres et les alias, qui vous permettent de réutiliser des éléments répétés sans avoir à les réécrire. Cela peut vous éviter beaucoup de saisie, surtout dans le contexte des tablatures de guitare !

Jetez un œil à un extrait d’une tablature de guitare fictive ci-dessous, qui démontre certaines des fonctionnalités de votre format :

title: Hello, World!  # Optional
artist: John Doe  # Optional
tracks:
  acoustic:  # Arbitrary name
    url: https://www.songsterr.com/hello  # Optional
    weight: 0.8  # Optional (defaults to 1.0)
    instrument:
      tuning: [E2, A2, D3, G3, B3, E4]
      vibration: 5.5
      damping: 0.498  # Optional (defaults to 0.5)
      effects:  # Optional
      - Reverb
      - Convolution:
          impulse_response_filename: acoustic.wav
          mix: 0.95
    tablature:
      beats_per_minute: 75
      measures:
      - time_signature: 4/4
        notes:  # Optional (can be empty measure)
        - frets: [0, 0, 2, 2, 0, ~]
          offset: 1/8  # Optional (defaults to zero)
          upstroke: true  # Optional (defaults to false)
          arpeggio: 0.04  # Optional (defaults to 0.005)
          vibration: 3.5  # Optional (overrides instrument's defaults)
      - time_signature: 4/4
      - time_signature: 4/4
        notes: &loop
        - frets: &seven [~, ~, ~, ~, 7, ~]
        - frets: *seven
          offset: 1/4
        - frets: *seven
          offset: 1/4
        - frets: *seven
          offset: 1/4
      - time_signature: 4/4
        notes: *loop
      # ...
  electric:
    # ...
  ukulele:
    # ...

De nombreux attributs sont entièrement facultatifs et la plupart ont des valeurs par défaut raisonnables, notamment :

  • Poids : les pistes de votre onglet seront mélangées avec un poids de un, sauf si vous demandez explicitement un poids différent.
  • Amortissement : si vous ne spécifiez pas l'amortissement de l'instrument, il sera défini par défaut sur 0,5, ce qui représente une moyenne simple.
  • Notes : Vous pouvez sauter les notes pour signifier une mesure vide, ce qui est parfois judicieux lorsque vous souhaitez synchroniser quelques instruments.
  • Décalage : lorsque vous ne spécifiez pas de décalage, la note ou l'accord correspondant sera placé quelle que soit la position actuelle sur la timeline. Vous omettez généralement le décalage de la première note d’une mesure, à moins qu’il ne se produise sur le temps.
  • UpStroke : La plupart des coups sont dirigés vers le bas, vous ne devez donc définir cet attribut que lorsque vous souhaitez que l'accord soit gratté vers le haut.
  • Arpège : La vitesse du coup ou le délai entre les pincements individuels dans un accord prend par défaut cinq millisecondes, ce qui est assez rapide.
  • Vibration : vous n'avez besoin de définir la vibration de la note que si vous souhaitez remplacer la vibration des cordes par défaut définie dans l'instrument concerné.

Les effets optionnels d'un instrument représentent des plugins Pedalboard. Vous pouvez les enchaîner dans un ordre spécifique pour créer le résultat souhaité, ou vous pouvez les ignorer complètement. Chaque effet doit être soit le nom de classe d'un plugin, soit un mappage du nom de classe aux arguments du constructeur correspondant. Vous pouvez consulter la documentation de Pedalboard pour plus de détails sur la façon de configurer ces effets.

Chaque morceau possède sa propre tablature composée du tempo (exprimé en nombre de battements par seconde) et d'une liste de mesures. À son tour, chaque mesure fournit une signature rythmique et une liste de notes ou d'accords. Une seule note doit définir au moins les numéros de frettes sur lesquels appuyer, car le reste des attributs est facultatif. Cependant, la plupart des instances de note spécifieront également le décalage en termes de fraction de la note entière.

Les ancres et les alias sont deux des fonctionnalités les plus puissantes de YAML. Ils vous permettent de définir une valeur une fois et de la lier à une variable globale dans le document. Les noms de variables doivent commencer par le caractère esperluette (&) et vous pouvez les référencer en utilisant l'astérisque (*) au lieu de l'esperluette. Si vous avez effectué de la programmation en C, cela revient respectivement à prendre l'adresse d'une variable et à déréférencer un pointeur.

Dans l'exemple ci-dessus, vous déclarez deux variables globales ou ancres YAML :

  1. &seven : Représente les numéros de frettes, qui se répètent tout au long de la mesure.
  2. &loop : capture la mesure elle-même, vous permettant d'utiliser la même boucle plusieurs fois dans la composition

Cela permet non seulement d'économiser de l'espace et de la saisie, mais rend également le document plus facile à gérer. Si vous souhaitez modifier la séquence, il vous suffit de la mettre à jour à un seul endroit et la modification sera reflétée partout où vous avez utilisé l'alias.

Après avoir vu un exemple de tablature de guitare dans votre format de fichier basé sur YAML, vous pouvez maintenant le charger dans Python. Vous y parviendrez avec l’aide de la bibliothèque Pydantic.

Définir des modèles Pydantic à charger à partir de YAML

Créez un package frère nommé tablature à côté de digitar que vous avez créé auparavant. Lorsque vous faites cela, vous devriez vous retrouver avec la structure de dossiers suivante :

digital-guitar/
│
├── demo/
│   ├── ir/
│   │   └── (...)
│   │
│   ├── play_chorus.py
│   └── play_diablo.py
│
├── src/
│   ├── digitar/
│   │    ├── __init__.py
│   │    ├── burst.py
│   │    ├── chord.py
│   │    ├── instrument.py
│   │    ├── pitch.py
│   │    ├── processing.py
│   │    ├── stroke.py
│   │    ├── synthesis.py
│   │    ├── temporal.py
│   │    └── track.py
│   │
│   └── tablature/
│       └── __init__.py
│
├── tests/
│   └── __init__.py
│
├── pyproject.toml
└── README.md

Maintenant, créez un module Python nommé models et placez-le dans le nouveau package. Ce module contiendra les classes de modèles Pydantic pour votre format de données basé sur YAML. Commencez par modéliser l'élément racine du document, que vous appellerez Chanson :

from pathlib import Path
from typing import Optional, Self

import yaml
from pydantic import BaseModel

class Song(BaseModel):
    title: Optional[str] = None
    artist: Optional[str] = None
    tracks: dict[str, Track]

    @classmethod
    def from_file(cls, path: str | Path) -> Self:
        with Path(path).open(encoding="utf-8") as file:
            return cls(**yaml.safe_load(file))

L'élément racine du document possède deux attributs facultatifs, .title et .artist, ainsi qu'un dictionnaire obligatoire .tracks. Ce dernier mappe des noms de pistes arbitraires à des instances Track, que vous implémenterez dans un instant. La classe fournit également une méthode pour charger des documents YAML à partir d'un fichier indiqué par une chaîne ou une instance Path et les désérialiser dans l'objet modèle.

Parce que Python lit votre code source de haut en bas, vous devrez définir le Track avant votre modèle Song, qui en dépend :

from pathlib import Path
from typing import Optional, Self

import yaml
from pydantic import BaseModel, HttpUrl, NonNegativeFloat, model_validator

class Track(BaseModel):
    url: Optional[HttpUrl] = None
    weight: Optional[NonNegativeFloat] = 1.0
    instrument: Instrument
    tablature: Tablature

    @model_validator(mode="after")
    def check_frets(self) -> Self:
        num_strings = len(self.instrument.tuning)
        for measure in self.tablature.measures:
            for notes in measure.notes:
                if len(notes.frets) != num_strings:
                    raise ValueError("Incorrect number of frets")
        return self

class Song(BaseModel):
    # ...

Une instance Track se compose d'une paire d'attributs facultatifs, .url et .weight, et d'une paire d'attributs obligatoires, .instrument et .tablature. Le poids représente le volume relatif du morceau dans le mixage final. La méthode décorée, .check_frets(), valide si le nombre de frettes dans chaque mesure correspond au nombre de cordes dans l'instrument.

Le modèle Instrument reflète votre type digitar.PluckedStringInstrument, en l'augmentant avec une chaîne de plugins Pedalboard :

from pathlib import Path
from typing import Optional, Self

import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, PositiveFloat,
                      confloat, conlist, constr, model_validator)

DEFAULT_STRING_DAMPING: float = 0.5

class Instrument(BaseModel):
    tuning: conlist(constr(pattern=r"([A-G]#?)(-?\d+)?"), min_length=1)
    vibration: PositiveFloat
    damping: Optional[confloat(ge=0, le=0.5)] = DEFAULT_STRING_DAMPING
    effects: Optional[tuple[str | dict, ...]] = tuple()

class Track(BaseModel):
    # ...

class Song(BaseModel):
    # ...

L'attribut .tuning est une liste d'au moins un élément contraint au type de données chaîne correspondant à l'expression régulière d'une note de musique en notation scientifique de hauteur. Le .vibration représente la durée en secondes pendant laquelle les cordes de l'instrument doivent vibrer par défaut. Vous pouvez remplacer cette valeur par trait si nécessaire. Le .damping est une valeur à virgule flottante limitée à l'intervalle spécifié et par défaut à une valeur stockée dans une constante.

Votre prochain modèle, Tablature, se compose de seulement deux attributs :

from pathlib import Path
from typing import Optional, Self

import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, PositiveFloat,
                      PositiveInt, confloat, conlist, constr, model_validator)

DEFAULT_STRING_DAMPING: float = 0.5

class Tablature(BaseModel):
    beats_per_minute: PositiveInt
    measures: tuple[Measure, ...]

class Instrument(BaseModel):
    # ...

class Track(BaseModel):
    # ...

class Song(BaseModel):
    # ...

.beats_per_minute et .measures sont obligatoires. Le premier attribut est un entier positif indiquant le tempo de la chanson en battements par minute. Le deuxième attribut est un tuple contenant un ou plusieurs objets Measure, que vous pouvez implémenter maintenant :

from fractions import Fraction
from functools import cached_property
from pathlib import Path
from typing import Optional, Self

import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, PositiveFloat,
                      PositiveInt, confloat, conlist, constr, model_validator)

DEFAULT_STRING_DAMPING: float = 0.5

class Measure(BaseModel):
    time_signature: constr(pattern=r"\d+/\d+")
    notes: Optional[tuple[Note, ...]] = tuple()

    @cached_property
    def beats_per_measure(self) -> int:
        return int(self.time_signature.split("/")[0])

    @cached_property
    def note_value(self) -> Fraction:
        return Fraction(1, int(self.time_signature.split("/")[1]))

class Tablature(BaseModel):
    # ...

class Instrument(BaseModel):
    # ...

class Track(BaseModel):
    # ...

class Song(BaseModel):
    # ...

Chaque Mesure est autorisé à spécifier son propre .time_signature avec une notation fractionnaire, telle que 4/4. Le tuple .notes est facultatif car une mesure peut être vide. Les deux propriétés mises en cache extraient le nombre de temps dans une mesure et la valeur de note de la signature rythmique.

Enfin, vous pouvez noter votre dernier modèle représentant une note ou un accord à jouer sur la guitare virtuelle :

from fractions import Fraction
from functools import cached_property
from pathlib import Path
from typing import Optional, Self

import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, NonNegativeInt,
                      PositiveFloat, PositiveInt, confloat, conlist, constr,
                      model_validator)

DEFAULT_STRING_DAMPING: float = 0.5
DEFAULT_ARPEGGIO_SECONDS: float = 0.005

class Note(BaseModel):
    frets: conlist(NonNegativeInt | None, min_length=1)
    offset: Optional[constr(pattern=r"\d+/\d+")] = "0/1"
    upstroke: Optional[bool] = False
    arpeggio: Optional[NonNegativeFloat] = DEFAULT_ARPEGGIO_SECONDS
    vibration: Optional[PositiveFloat] = None

class Measure(BaseModel):
    # ...

class Tablature(BaseModel):
    # ...

class Instrument(BaseModel):
    # ...

class Track(BaseModel):
    # ...

class Song(BaseModel):
    # ...

Ce modèle n'a qu'un seul attribut obligatoire, .frets, qui est une liste de numéros de frettes limités à Aucun ou à des éléments entiers non négatifs. Le .offset d'une note doit être donné sous forme de fraction de la note entière, par exemple 1/8. Sinon, la valeur par défaut est zéro. Les attributs restants incluent .upStroke, .arpeggio et .vibration, qui décrivent comment jouer le trait.

Avec ces modèles, vous pouvez charger les exemples de tablatures de guitare fournis dans les supports. Par exemple, l'un des fichiers YAML inclus est basé sur une tablature Songsterr pour Foggy Mountain Breakdown d'Earl Scruggs, comprenant un banjo, une guitare acoustique et une guitare basse :

>>> from tablature.models import Song

>>> song = Song.from_file("demo/tabs/foggy-mountain-breakdown.yaml")
>>> sorted(song.tracks)
['acoustic', 'banjo', 'bass']

>>> banjo = song.tracks["banjo"].instrument
>>> banjo.tuning
['G4', 'D3', 'G3', 'B3', 'D4']

>>> banjo_tab = song.tracks["banjo"].tablature
>>> banjo_tab.measures[-1].notes
(
    Note(
        frets=[None, None, 0, None, None],
        offset='0/1',
        upstroke=False,
        arpeggio=0.005,
        vibration=None
    ),
    Note(
        frets=[0, None, None, None, 0],
        offset='1/2',
        upstroke=False,
        arpeggio=0.005,
        vibration=None
    )
)

Vous lisez un fichier YAML avec la tablature de guitare et le désérialisez dans une hiérarchie de modèles Pydantic. Ensuite, vous accédez à la piste associée à la tablature de banjo et affichez les notes dans sa dernière mesure.

Ensuite, vous construirez un lecteur capable de prendre ces modèles, de les traduire dans votre domaine de guitare numérique et de cracher un fichier audio synthétisé. Êtes-vous prêt à relever le défi ?

Implémenter le lecteur de tablatures de guitare

Définissez une section de scripts dans votre fichier pyproject.toml avec un point d'entrée vers votre projet Python, que vous exécuterez plus tard à partir de la ligne de commande :

# ...

[tool.poetry.scripts]
play-tab = "tablature.player:main"

Ceci définit la commande play-tab pointant vers un nouveau module nommé player dans le package tablature. Vous pouvez maintenant échafauder ce module en y implémentant ces quelques fonctions :

from argparse import ArgumentParser, Namespace
from pathlib import Path

from tablature import models

SAMPLING_RATE = 44100

def main() -> None:
    play(parse_args())

def parse_args() -> Namespace:
    parser = ArgumentParser()
    parser.add_argument("path", type=Path, help="tablature file (.yaml)")
    parser.add_argument("-o", "--output", type=Path, default=None)
    return parser.parse_args()

def play(args: Namespace) -> None:
    song = models.Song.from_file(args.path)

La fonction main() est ce que Poetry appellera lorsque vous invoquerez poetry run play-tab dans votre terminal. Cette fonction analyse les arguments de ligne de commande à l'aide de argparse et les transmet à la fonction play(), qui charge une chanson à partir du fichier YAML spécifié via votre modèle Pydantic.

Vous devez spécifier le chemin d'accès à la tablature de guitare comme argument de position et vous pouvez fournir le chemin d'accès au fichier audio de sortie en option. Si vous ne le faites pas, le fichier résultant partagera son nom de base avec votre fichier d'entrée.

Une fois l'onglet chargé dans Python, vous pouvez l'interpréter en synthétisant les pistes individuelles :

from argparse import ArgumentParser, Namespace
from pathlib import Path

import numpy as np
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack

from tablature import models

# ...

def play(args: Namespace) -> None:
    song = models.Song.from_file(args.path)
    tracks = [
        track.weight * synthesize(track)
        for track in song.tracks.values()
    ]

def synthesize(track: models.Track) -> np.ndarray:
    synthesizer = Synthesizer(
        instrument=PluckedStringInstrument(
            tuning=StringTuning.from_notes(*track.instrument.tuning),
            damping=track.instrument.damping,
            vibration=Time(track.instrument.vibration),
        ),
        sampling_rate=SAMPLING_RATE,
    )
    audio_track = AudioTrack(synthesizer.sampling_rate)
    timeline = MeasuredTimeline()
    read(track.tablature, synthesizer, audio_track, timeline)
    return apply_effects(audio_track, track.instrument)

Vous utilisez une compréhension de liste pour synthétiser chaque piste et multiplier le tableau d'échantillons NumPy résultant par le poids de la piste.

La fonction synthesize() crée un objet synthétiseur basé sur la définition de l'instrument dans la piste. Il lit ensuite la tablature correspondante et place des notes sur la timeline. Enfin, il applique des effets spéciaux avec Pedalboard avant de renvoyer les échantillons audio à l'appelant.

La fonction read() automatise les étapes manuelles que vous avez précédemment effectuées lorsque vous jouiez à l'onglet Diablo par programmation :

from argparse import ArgumentParser, Namespace
from fractions import Fraction
from pathlib import Path

import numpy as np
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack

from tablature import models

# ...

def read(
    tablature: models.Tablature,
    synthesizer: Synthesizer,
    audio_track: AudioTrack,
    timeline: MeasuredTimeline,
) -> None:
    beat = Time(seconds=60 / tablature.beats_per_minute)
    for measure in tablature.measures:
        timeline.measure = beat * measure.beats_per_measure
        whole_note = beat * measure.note_value.denominator
        for note in measure.notes:
            stroke = Velocity.up if note.upstroke else Velocity.down
            audio_track.add_at(
                (timeline >> (whole_note * Fraction(note.offset))).instant,
                synthesizer.strum_strings(
                    chord=Chord(note.frets),
                    velocity=stroke(delay=Time(note.arpeggio)),
                    vibration=(
                        Time(note.vibration) if note.vibration else None
                    ),
                ),
            )
        next(timeline)

Cela commence par trouver la durée du battement en secondes. Sur cette base, la fonction calcule la durée de la mesure actuelle et de la note entière dans l'onglet. Ensuite, il parcourt chaque note de la mesure, synthétise l'accord correspondant et l'ajoute à la piste audio au moment calculé. Après chaque itération, la fonction appelle next() sur la timeline afin de l'avancer à la mesure suivante.

Les trois fonctions suivantes fonctionnent en tandem pour importer et appliquer les plugins souhaités depuis la bibliothèque Pedalboard en fonction des déclarations du fichier YAML :

from argparse import ArgumentParser, Namespace
from fractions import Fraction
from pathlib import Path

import numpy as np
import pedalboard

# ...

def apply_effects(
    audio_track: AudioTrack, instrument: models.Instrument
) -> np.ndarray:
    effects = pedalboard.Pedalboard(get_plugins(instrument))
    return effects(audio_track.samples, audio_track.sampling_rate)

def get_plugins(instrument: models.Instrument) -> list[pedalboard.Plugin]:
    return [get_plugin(effect) for effect in instrument.effects]

def get_plugin(effect: str | dict) -> pedalboard.Plugin:
    match effect:
        case str() as class_name:
            return getattr(pedalboard, class_name)()
        case dict() as plugin_dict if len(plugin_dict) == 1:
            class_name, params = list(plugin_dict.items())[0]
            return getattr(pedalboard, class_name)(**params)

La première fonction applique les effets associés à un instrument spécifique à une piste audio. Il crée un objet Pedalboard à partir des plugins récupérés sur l'instrument de la piste. La dernière fonction renvoie une instance de plugin en fonction de son nom et l'initialise éventuellement avec les paramètres spécifiés dans le document de tablature.

Désormais, vous pouvez mixer vos pistes synthétisées et les enregistrer dans un fichier. Pour ce faire, vous devrez modifier la fonction play() :

from argparse import ArgumentParser, Namespace
from fractions import Fraction
from pathlib import Path

import numpy as np
import pedalboard
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
from pedalboard.io import AudioFile

from tablature import models

# ...

def play(args: Namespace) -> None:
    song = models.Song.from_file(args.path)
    samples = normalize(
        np.sum(
            pad_to_longest(
                [
                    track.weight * synthesize(track)
                    for track in song.tracks.values()
                ]
            ),
            axis=0,
        )
    )
    save(
        samples,
        args.output or Path.cwd() / args.path.with_suffix(".mp3").name,
    )

def pad_to_longest(tracks: list[np.ndarray]) -> list[np.ndarray]:
    max_length = max(array.size for array in tracks)
    return [
        np.pad(array, (0, max_length - array.size)) for array in tracks
    ]

def save(samples: np.ndarray, path: Path) -> None:
    with AudioFile(str(path), "w", SAMPLING_RATE) as file:
        file.write(samples)
    print(f"Saved file {path.absolute()}")

# ...

Étant donné que les pistes individuelles peuvent différer en longueur, vous les complétez pour vous assurer qu'elles ont toutes la même longueur avant d'ajouter leurs amplitudes avec np.sum() et de normaliser leurs valeurs. Enfin, vous enregistrez les échantillons audio dans un fichier en appelant votre fonction save().

Cependant, pour garantir que les chemins relatifs dans votre document YAML fonctionneront comme prévu, vous devez modifier temporairement le répertoire de travail actuel du script :

import os
from argparse import ArgumentParser, Namespace
from contextlib import contextmanager
from fractions import Fraction
from pathlib import Path

# ...

def play(args: Namespace) -> None:
    song = models.Song.from_file(args.path)
    with chdir(args.path.parent):
        samples = normalize(
            np.sum(
                pad_to_longest(
                    [
                        track.weight * synthesize(track)
                        for track in song.tracks.values()
                    ]
                ),
                axis=0,
            )
        )
    save(
        samples,
        args.output or Path.cwd() / args.path.with_suffix(".mp3").name,
    )

@contextmanager
def chdir(directory: Path) -> None:
    current_dir = os.getcwd()
    os.chdir(directory)
    try:
        yield
    finally:
        os.chdir(current_dir)

# ...

Vous spécifiez un gestionnaire de contexte basé sur des fonctions que vous appelez à l'aide de l'instruction with pour définir le répertoire de travail sur le dossier parent du fichier YAML. Sans cela, vous ne seriez pas en mesure de trouver et de charger les fichiers de réponse impulsionnelle pour le plugin de convolution du Pedalboard.

D'accord. Vous trouverez ci-dessous comment utiliser le script play-tab dans le terminal. N'oubliez pas de réinstaller votre projet Poetry pour que le point d'entrée défini dans pyproject.toml prenne effet :

$ poetry install
$ poetry run play-tab demo/tabs/foggy-mountain-breakdown.yaml -o foggy.mp3
Saved file /home/user/digital-guitar/foggy.mp3

Lorsque vous omettez l'option de nom de fichier de sortie (-o), le fichier résultant utilisera le même nom que votre fichier d'entrée mais avec une extension de fichier .mp3.

Voici comment sonnera l'échantillon de tablature composé de trois pistes d'instrument lorsque vous l'exécuterez sur votre synthétiseur :

Bien joué! Si vous êtes arrivé jusqu’ici, félicitations pour votre détermination et votre persévérance. J’espère que ce fut un voyage amusant et utile qui vous a aidé à apprendre quelque chose de nouveau.

Conclusion

Félicitations pour avoir terminé ce projet avancé ! Vous avez implémenté avec succès l'algorithme de synthèse de cordes pincées et un lecteur de tablature de guitare afin de pouvoir jouer de la musique réaliste en Python. Et en cours de route, vous avez acquis des connaissances significatives sur la théorie musicale sous-jacente. Peut-être avez-vous même eu envie de vous procurer une vraie guitare et de commencer à jouer. Qui sait ?

Dans ce didacticiel, vous avez :

  • Implémentation de l'algorithme de synthèse de cordes pincées Karplus-Strong
  • Imite différents types d'instruments à cordes et leurs accordages
  • Combinaison de plusieurs cordes vibrantes en accords polyphoniques
  • Techniques réalistes de picking de guitare et de grattage simulées
  • Utilisation des réponses impulsionnelles d'instruments réels pour reproduire leur timbre unique
  • Lisez les notes de musique à partir de la notation scientifique de hauteur et de la tablature de guitare

Vous trouverez le code source complet de ce projet, y compris des instantanés des étapes individuelles, des exemples de tablatures et des fichiers de réponses impulsionnelles dans les documents de support. Pour les obtenir, utilisez le lien ci-dessous :