Erstellen Sie einen Gitarrensynthesizer: Spielen Sie Musiktabulatur in Python


Wollten Sie schon immer Musik komponieren, ohne teure Ausrüstung oder ein professionelles Studio? Vielleicht haben Sie schon einmal versucht, ein Musikinstrument zu spielen, fanden aber die erforderliche manuelle Geschicklichkeit zu entmutigend oder zu zeitaufwändig. Wenn ja, könnten Sie daran interessiert sein, die Leistungsfähigkeit von Python zu nutzen, um einen Gitarrensynthesizer zu erstellen. Mit ein paar relativ einfachen Schritten können Sie Ihren Computer in eine virtuelle Gitarre verwandeln, die jedes Lied spielen kann.

In diesem Tutorial werden Sie:

  • Implementieren Sie den Karplus-Strong-Algorithmus zur Synthese gezupfter Saiten
  • Imitieren Sie verschiedene Arten von Saiteninstrumenten und deren Stimmungen
  • Kombinieren Sie mehrere vibrierende Saiten zu polyphonen Akkorden
  • Simulieren Sie realistische Gitarrenpicking- und Strumming-Fingertechniken
  • Nutzen Sie Impulsantworten echter Instrumente, um deren einzigartige Klangfarbe nachzubilden
  • Lesen Sie Musiknoten aus der wissenschaftlichen Tonhöhennotation und der Gitarrentabulatur

Sie können jederzeit den vollständigen Quellcode des Gitarrensynthesizers sowie die Beispieltabulatur und andere Ressourcen herunterladen, die Sie in diesem Tutorial verwenden werden. Sie können sich als nützlich erweisen, wenn Sie den Code detaillierter erkunden oder sich einen Vorsprung verschaffen möchten. Um die Bonusmaterialien jetzt herunterzuladen, besuchen Sie den folgenden Link:

Demo: Gitarrensynthesizer in Python

In dieser Schritt-für-Schritt-Anleitung erstellen Sie einen Zupfinstrumenten-Synthesizer basierend auf dem Karplus-Strong-Algorithmus in Python. Unterwegs erstellen Sie ein Ensemble virtueller Instrumente, darunter eine Akustik-, Bass- und E-Gitarre sowie ein Banjo und eine Ukulele. Anschließend implementieren Sie einen benutzerdefinierten Gitarren-Tab-Reader, damit Sie Ihre Lieblingslieder abspielen können.

Am Ende dieses Tutorials werden Sie in der Lage sein, Musik aus Gitarrentabulaturen, kurz Gitarrentabulaturen, zu synthetisieren. Hierbei handelt es sich um eine vereinfachte Form der Notenschrift, mit der Sie Musik spielen können, ohne lernen zu müssen, wie man Standardnoten liest. Abschließend speichern Sie das Ergebnis in einer MP3-Datei zur Wiedergabe.

Nachfolgend finden Sie eine kurze Demonstration des Synthesizers, der die ikonischen Soundtracks klassischer Videospiele wie Doom und Diablo nachbildet. Klicken Sie auf die Wiedergabeschaltfläche, um die Beispielausgabe anzuhören:

Sobald Sie eine Gitarrentabulatur gefunden haben, die Ihnen gefällt, können Sie sie an Ihren Python-Gitarrensynthesizer anschließen und die Musik zum Leben erwecken. Beispielsweise ist die Songsterr-Website eine fantastische Ressource mit einer großen Auswahl an Songs, aus denen Sie wählen können.

Projektübersicht

Der Einfachheit halber wird das Projekt, das Sie erstellen möchten, zusammen mit seinen Abhängigkeiten von Drittanbietern von Poetry verwaltet. Das Projekt wird zwei Python-Pakete mit deutlich unterschiedlichen Verantwortungsbereichen enthalten:

  1. digitar: Für die Synthese des digitalen Gitarrensounds
  2. tabulatur: Zum Lesen und Interpretieren von Gitarrentabulaturen aus einer Datei

Außerdem entwerfen und implementieren Sie ein benutzerdefiniertes Datenformat, um Gitarrentabulaturen auf der Festplatte oder im Speicher zu speichern. Auf diese Weise können Sie Musik auf der Grundlage einer ziemlich standardmäßigen Tabulaturnotation abspielen, die Sie an verschiedenen Stellen im Internet finden. Ihr Projekt stellt außerdem ein Python-Skript bereit, um alles miteinander zu verknüpfen, sodass Sie die Tabulatoren mit einem einzigen Befehl direkt von Ihrem Terminal aus interpretieren können.

Jetzt können Sie sich mit den Details befassen, die Sie benötigen, um Ihre Entwicklungsumgebung einzurichten und mit dem Codieren zu beginnen.

Voraussetzungen

Obwohl Sie kein Musiker sein müssen, um diesem Tutorial folgen zu können, hilft Ihnen ein grundlegendes Verständnis musikalischer Konzepte wie Noten, Halbtöne, Oktaven und Akkorde dabei, die Informationen schneller zu erfassen. Es wäre auch schön, wenn Sie eine ungefähre Vorstellung davon hätten, wie Computer digitales Audio in Bezug auf Abtastrate, Bittiefe und Dateiformate wie WAV darstellen und verarbeiten.

Aber machen Sie sich keine Sorgen, wenn Sie mit diesen Ideen noch nicht vertraut sind! Sie werden in kleinen Schritten durch jeden Schritt mit klaren Erklärungen und Beispielen geführt. Selbst wenn Sie noch nie zuvor Musik synthetisiert haben, werden Sie am Ende dieses Tutorials über eine funktionierende Digitalgitarre oder Digitar verfügen.

Das Projekt, das Sie erstellen werden, wurde mit Python 3.12 getestet, sollte aber auch in früheren Python-Versionen bis hin zu Python 3.10 einwandfrei funktionieren. Falls Sie eine kurze Auffrischung benötigen, finden Sie hier eine Liste hilfreicher Ressourcen zu den wichtigsten Sprachfunktionen, die Sie auf Ihrer Reise zur digitalen Gitarre nutzen werden:

  • Zuweisungsausdrücke
  • Datenklassen
  • Aufzählungen
  • Protokolle (statisches Duck-Typing)
  • Strukturmustervergleich
  • Geben Sie Hinweise ein

Ansonsten verwenden Sie in Ihrem Projekt die folgenden Python-Pakete von Drittanbietern:

  • NumPy zur Vereinfachung und Beschleunigung der zugrunde liegenden Klangsynthese
  • Pedalboard zum Anwenden von Spezialeffekten ähnlich wie bei E-Gitarrenverstärkern
  • Pydantic und PyYAML zum Analysieren von Musiktabulaturen, die Fingerbewegungen auf einem Gitarrenhals darstellen

Sich damit vertraut zu machen wird auf jeden Fall hilfreich sein, aber Sie können es auch nebenbei lernen und dieses Projekt als Gelegenheit nutzen, Ihre Python-Kenntnisse zu üben und zu verbessern.

Schritt 1: Richten Sie das digitale Gitarrenprojekt ein

Der erste Schritt besteht darin, Ihre Entwicklungsumgebung vorzubereiten. Zunächst erstellen Sie ein neues Python-Projekt und installieren die erforderlichen Bibliotheken von Drittanbietern. Anschließend laden Sie es in einen Editor, wo Sie mit dem Schreiben des erforderlichen Codes für Ihren Gitarrensynthesizer fortfahren.

Erstellen Sie ein neues Projekt und installieren Sie Abhängigkeiten

Es gibt viele Möglichkeiten, Python-Projekte zu erstellen und zu verwalten. In diesem Tutorial verwenden Sie Poetry als praktisches Tool für das Abhängigkeitsmanagement. Wenn Sie es noch nicht getan haben, installieren Sie Poetry – zum Beispiel mit pipx – und starten Sie ein neues Projekt mit dem Ordnerlayout src/, um Ihren Code organisiert zu halten:

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

Dadurch entsteht die folgende Ordnerstruktur, die Platzhalterdateien mit den Metadaten und dem Quellcode Ihres Projekts enthält, die Sie später ausfüllen:

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

Ändern Sie dann das Verzeichnis in Ihr neues Projekt und fügen Sie einige Abhängigkeiten hinzu, auf die Sie später zurückgreifen werden:

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

Nachdem Sie diesen Befehl ausgeführt haben, erstellt Poetry eine isolierte virtuelle Umgebung an einem bestimmten Ort für Ihr Projekt und installiert die aufgelisteten Python-Pakete von Drittanbietern darin. Außerdem sollte im Stammordner Ihres Projekts eine neue Datei poetry.lock angezeigt werden.

Sie können jetzt den Ordner digital-guitar/ in der Python-IDE oder dem Code-Editor Ihrer Wahl öffnen. Wenn Sie Visual Studio Code oder PyCharm verwenden, erkennen beide Programme die von Poetry erstellte virtuelle Umgebung. Letzterer verknüpft es auch mit dem Projekt, sodass Sie sofort auf die installierten Pakete zugreifen können.

In VS Code müssen Sie möglicherweise die von Poetry verwaltete virtuelle Umgebung manuell auswählen. Rufen Sie dazu die Befehlspalette auf, geben Sie Python: Select Interpreter ein und wählen Sie den gewünschten Interpreter aus. Umgekehrt bestätigen Sie nach dem Öffnen des Ordners in PyCharm die Aufforderung, eine Poetry-Umgebung einzurichten. Der entsprechende Python-Interpreter erscheint in der unteren rechten Ecke des Fensters.

Wenn Sie ein eingefleischter Vim- oder Sublime Text-Benutzer sind, können Sie alternativ weiterhin Poetry in der Befehlszeile verwenden:

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

Der erste Befehl installiert Ihr Projekt zusammen mit seinen Abhängigkeiten, die in der Datei pyproject.toml definiert sind. Der zweite Befehl, den Sie später implementieren, führt ein Skript aus der zugehörigen virtuellen Umgebung aus, die von Poetry verwaltet wird. Beachten Sie, dass Sie diese Befehle trotzdem verwenden, unabhängig davon, welchen Code-Editor Sie wählen.

Nutzen Sie unveränderliche Datentypen in Ihrem Projekt

Mit wenigen Ausnahmen definieren Sie in diesem Projekt fast ausschließlich unveränderliche Datentypen. Unveränderliche Objekte sind solche, die Sie nach ihrer Erstellung nicht mehr ändern können. Auch wenn das zunächst einschränkend klingt, bringt es tatsächlich eine Reihe von Vorteilen mit sich. Daher ist es eine gute Idee, sich mit dem Konzept der Unveränderlichkeit und seinen Auswirkungen auf das Verhalten Ihres Programms vertraut zu machen, bevor Sie beginnen.

Erstens sind die meisten unveränderlichen Objekte in Python hashbar, was sie zu gültigen Wörterbuchschlüsseln macht. Später wird dies für das Zwischenspeichern von Argumentwerten unerlässlich sein, um wiederholte Berechnungen zu vermeiden. Auf lange Sicht wird es Ihnen helfen, die für die Klangsynthese benötigte Gesamtzeit zu verkürzen.

Ansonsten können Sie unveränderliche Objekte bedenkenlos als Standardargumentwerte verwenden, ohne sich über unbeabsichtigte Nebenwirkungen Gedanken machen zu müssen. Im Gegensatz dazu sind veränderbare Standardargumente eine der häufigsten Fallstricke in Python, die zu überraschenden und schwer nachvollziehbaren Fehlern führen können. Wenn Sie nach Möglichkeit bei unveränderlichen Typen bleiben, ersparen Sie sich eine Menge Kopfschmerzen.

Sie können sich unveränderliche Objekte auch als einfache Werte wie Ganzzahlen oder Zeichenfolgen vorstellen. Wenn Sie eine unveränderliche Variable einer anderen Variablen zuweisen, bindet die Zuweisung beide Referenzen an dasselbe Objekt im Speicher. Sobald Sie jedoch versuchen, den Status Ihres unveränderlichen Objekts über eine dieser Variablen zu ändern, erstellen Sie eine Kopie dieses Objekts und lassen das Original intakt. Dadurch wird Ihr Code vorhersehbarer und belastbarer.

Unveränderliche Objekte sind außerdem Thread-sicher und erleichtern das Nachdenken über Ihren Code. Aufgrund dieser Eigenschaften eignen sie sich besonders für das Paradigma der funktionalen Programmierung, aber Sie werden ihre Vorteile auch im objektorientierten Bereich genießen.

Jetzt ist es an der Zeit, diese Theorie in die Praxis umzusetzen, indem Sie Ihren ersten unveränderlichen Datentyp für dieses Gitarrensynthesizer-Projekt implementieren.

Stellen Sie Zeitpunkte, Dauer und Intervalle dar

Musik ist eine vergängliche Kunstform, die man nur für kurze Zeit genießen kann, wenn sie gespielt oder aufgeführt wird. Da Musik von Natur aus in der Zeit existiert, ist es entscheidend, dass Sie Zeitmomente, Dauer und Intervalle richtig darstellen können, wenn Sie etwas aufbauen möchten ein robuster Synthesizer.

Der Datentyp float von Python ist für musikalisches Timing aufgrund der im IEEE 754-Standard verankerten Darstellungs- und Rundungsfehler nicht präzise genug. Wenn Sie eine höhere Genauigkeit benötigen, empfiehlt es sich in Python, Gleitkommazahlen entweder durch den Datentyp Decimal oder Fraction zu ersetzen. Die direkte Verwendung dieser Typen kann jedoch umständlich sein und sie enthalten nicht die notwendigen Informationen über die beteiligten Zeiteinheiten.

Um diese Probleme zu lindern, implementieren Sie einige benutzerdefinierte Klassen, beginnend mit dem vielseitigen Datentyp Time. Erstellen Sie ein neues Python-Modul mit dem Namen temporal in Ihrem digitar-Paket und definieren Sie darin die folgende Datenklasse:

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)

Diese Klasse verfügt nur über ein Attribut, das die Anzahl der Sekunden zur Verbesserung der Genauigkeit als Decimal-Objekt darstellt. Sie können Instanzen Ihrer neuen Klasse erstellen, indem Sie die Sekunden über ihren Konstruktor bereitstellen oder eine Klassenmethode aufrufen, die Millisekunden erwartet und diese in Sekunden umwandelt, die in einen geeigneten Datentyp eingeschlossen sind.

from typing import TypeAlias

Numeric: TypeAlias = int | float | Decimal | Fraction

Wenn Sie alternativ eine noch frühere Version von Python verwenden, verwenden Sie einfach eine einfache Zuweisungsanweisung ohne Schlüsselwörter oder Anmerkungen.

Aufgrund der dynamischen Natur von Python erzwingt der vom Interpreter für Ihre Datenklasse generierte Standardkonstruktor keine Typhinweise, mit denen Sie Ihre Attribute annotiert haben. Mit anderen Worten: Der Interpreter überprüft nicht, ob die bereitgestellten Werte den erwarteten Typen entsprechen. Wenn Sie also in diesem Fall eine Ganzzahl oder eine Gleitkommazahl anstelle eines Decimal-Objekts übergeben, erstellen Sie versehentlich eine Instanz mit einem falschen Attributtyp.

Glücklicherweise können Sie dieses Problem verhindern, indem Sie Ihre eigene Initialisierungsmethode in der Klasse implementieren, die die von Python standardmäßig generierte Methode ersetzt:

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__}'"
                )

Sie verwenden den strukturellen Mustervergleich, um den Typ des Arguments zu erkennen, das zur Laufzeit an Ihre Methode übergeben wird, und entsprechend abzuzweigen. Anschließend stellen Sie sicher, dass das Instanzattribut .seconds unabhängig vom Eingabetyp immer auf ein Decimal-Objekt festgelegt ist. Wenn Sie eine Decimal-Instanz an Ihren Konstruktor übergeben, müssen Sie nichts weiter tun. Andernfalls verwenden Sie die entsprechende Konvertierung oder lösen eine Ausnahme aus, um den Missbrauch des Konstruktors zu signalisieren.

Da Sie eine eingefrorene Datenklasse definiert haben, die ihre Instanzen unveränderlich macht, können Sie den Attributwert nicht direkt festlegen oder die integrierte Funktion setattr() für eine vorhandene Klasse aufrufen Objekt. Das würde gegen den Unveränderlichkeitsvertrag verstoßen. Wenn Sie jemals den Status einer eingefrorenen Datenklasseninstanz erzwingen müssen, können Sie auf einen Hack zurückgreifen, indem Sie explizit object.__setattr__() aufrufen, wie im Codeausschnitt oben.

Sie erinnern sich vielleicht, dass Datenklassen eine spezielle Methode für genau diese Art der Anpassung unterstützen. Der Vorteil des Überschreibens der Standardinitialisierermethode anstelle der Implementierung von .__post_init__() besteht jedoch darin, dass Sie die vollständige Kontrolle über den Objekterstellungsprozess übernehmen. Infolgedessen kann ein Objekt entweder existieren und sich in einem gültigen Zustand befinden oder überhaupt nicht existieren.

Schließlich können Sie eine praktische Methode implementieren, die Sie später verwenden werden, um eine Dauer in Sekunden in die entsprechende Anzahl von Audio-Samples zu übersetzen:

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))

Diese Methode verwendet eine Abtastrate in Hertz (Hz) als Argument, die die Anzahl der Abtastungen pro Sekunde darstellt. Das Multiplizieren der Dauer in Sekunden mit der Abtastrate in Hertz ergibt die erforderliche Anzahl an Abtastungen, die Sie runden können, um eine ganze Zahl zurückzugeben.

Hier ist eine kurze Python REPL-Sitzung, die zeigt, wie Sie Ihre neue Datenklasse nutzen können:

>>> 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

Der Unterstrich (_) in der REPL ist eine implizite Variable, die den Wert des zuletzt ausgewerteten Ausdrucks enthält. In diesem Fall bezieht es sich auf Ihre Time-Instanz, die zwei Millisekunden darstellt.

Wenn die Klasse Time vorhanden ist, können Sie mit dem nächsten Schritt fortfahren. Sie werden Ihre Zehen in die Physik einer vibrierenden Saite eintauchen und sehen, wie sie einen Klang erzeugt.

Schritt 2: Modellieren Sie die akustische Welle einer vibrierenden Saite

Letztendlich ist jedes Geräusch, das Sie hören, eine lokale Störung des Luftdrucks, die durch ein vibrierendes Objekt verursacht wird. Ganz gleich, ob es sich um Ihre Stimmbänder, eine Gitarrensaite oder einen Lautsprecher handelt, diese Vibrationen drücken und ziehen die Luftmoleküle um sie herum. Diese Bewegung breitet sich dann als akustische Welle durch die Luft aus, bis sie Ihr Trommelfell erreicht, das als Reaktion darauf vibriert.

In diesem Schritt werfen Sie einen genaueren Blick auf den Karplus-Strong-Synthesealgorithmus, der die Schwingung einer gezupften Saite modelliert. Anschließend implementieren Sie es mit NumPy in Python und erzeugen Ihren ersten synthetischen Klang, der dem einer gezupften Saite ähnelt.

Lernen Sie den Karplus-Strong-Algorithmus kennen

Der Karplus-Strong-Algorithmus ist angesichts der komplexen Klänge, die er erzeugen kann, überraschend einfach. Kurz gesagt: Es beginnt damit, dass ein sehr kurzer Puffer mit einem Ausbruch von zufälligem Rauschen oder einem anderen Signal mit hoher Energie oder vielen Frequenzkomponenten gefüllt wird. Dieses Geräusch entspricht der Anregung einer tatsächlichen Saite, die zunächst in mehreren inkohärenten Bewegungsmustern vibriert.

Diese scheinbar zufälligen Schwingungen werden nach und nach immer sinusförmiger, mit einer klaren sinusähnlichen Periode und Frequenz, die Sie als charakteristische Tonhöhe wahrnehmen. Während die Amplituden aller Schwingungen im Laufe der Zeit aufgrund der durch innere Reibung und Energieübertragung verursachten Energiedissipation schwächer werden, bleibt eine bestimmte Grundfrequenz stärker als die meisten Obertöne und Harmonischen, die schneller verklingen.

Der Karplus-Strong-Algorithmus wendet einen Tiefpassfilter auf das Signal an, um den Abfall höherer Frequenzen schneller als die Grundfrequenz zu simulieren. Dies geschieht durch die Berechnung eines gleitenden Durchschnitts zweier aufeinanderfolgender Amplitudenpegel im Puffer, der effektiv als reiner Faltungsfilter fungiert. Es beseitigt die kurzfristigen Schwankungen, während der längerfristige Trend beibehalten wird.

Zusätzlich speist der Algorithmus die gemittelten Werte zurück in den Puffer, um die Vibration zu verstärken und fortzusetzen, wenn auch mit allmählichem Energieverlust. Schauen Sie sich das Diagramm unten an, um sich ein besseres Bild davon zu machen, wie diese positive Feedbackschleife funktioniert:

Der Generator auf der linken Seite dient als Eingabe für den Algorithmus und liefert den ersten Rauschstoß. Typischerweise handelt es sich um weißes Rauschen mit einer gleichmäßigen Wahrscheinlichkeitsverteilung, sodass im Durchschnitt keine bestimmte Frequenz gegenüber einer anderen hervorgehoben wird. Die Analogie ähnelt weißem Licht, das alle Frequenzen des sichtbaren Spektrums in etwa gleicher Intensität enthält.

Der Generator schaltet sich ab, nachdem ein Ringpuffer, auch Verzögerungsleitung genannt, gefüllt ist, der das Signal um eine bestimmte Zeit verzögert, bevor es wieder in die Schleife eingespeist wird. Das phasenverschobene Signal aus der Vergangenheit wird dann mit dem aktuellen Signal gemischt. Stellen Sie sich das wie die Reflexion der Welle vor, die sich entlang der Saite in die entgegengesetzte Richtung ausbreitet.

Die Verzögerung bestimmt die Frequenz der Vibration der virtuellen Saite. Genau wie bei der Saitenlänge der Gitarre führt eine kürzere Verzögerung zu einer höheren Tonhöhe, während eine längere Verzögerung zu einer niedrigeren Tonhöhe führt. Sie können die erforderliche Größe des Puffers – bezogen auf die Anzahl der Audio-Samples – mithilfe der folgenden Formel berechnen:

Um die Anzahl der Abtastwerte D zu erhalten, multiplizieren Sie die Schwingungsperiode oder den Kehrwert der gewünschten Grundfrequenz F0 . , durch die Abtastfrequenz Ihres Signals, Fs. Teilen Sie einfach die Abtastfrequenz durch die Grundfrequenz.

Anschließend durchläuft das verzögerte Signal einen Tiefpassfilter, bevor es zum nächsten Sample aus dem Puffer hinzugefügt wird. Sie können sowohl den Filter als auch den Addierer implementieren, indem Sie einen gewichteten Durchschnitt auf beide Stichproben anwenden, solange deren Gewichte in der Summe eins oder weniger betragen. Andernfalls würden Sie das Signal verstärken, anstatt es zu dämpfen. Durch Anpassen der Gewichte können Sie das Abklingen oder die Dämpfung der Vibration Ihrer virtuellen Saite steuern.

Während das verarbeitete Signal den Puffer durchläuft, verliert es mehr Hochfrequenzanteil und stellt sich in ein Muster ein, das dem Klang einer gezupften Saite sehr ähnelt. Dank der Rückkopplungsschleife entsteht die Illusion einer vibrierenden Saite, die allmählich abklingt.

Schließlich sehen Sie ganz rechts im Diagramm die Ausgabe, bei der es sich um einen Lautsprecher oder eine Audiodatei handeln kann, in die Sie die resultierenden Audiobeispiele schreiben.

Wenn Sie die Wellenformen und ihre entsprechenden Frequenzspektren aus aufeinanderfolgenden Zyklen der Rückkopplungsschleife grafisch darstellen, werden Sie das folgende Muster beobachten:

Das obere Diagramm zeigt Amplitudenschwankungen im Zeitverlauf. Die Grafik direkt darunter zeigt den Frequenzinhalt des Signals zu bestimmten Zeitpunkten. Zunächst wird der Puffer mit Zufallsproben gefüllt, deren Häufigkeitsverteilung über das Spektrum ungefähr gleich ist. Mit der Zeit nimmt die Amplitude des Signals ab und die Schwingungsfrequenz beginnt sich auf ein bestimmtes Spektralband zu konzentrieren. Die Form der Wellenform ähnelt nun einer Sinuswelle.

Da Sie nun die Prinzipien des Karplus-Strong-Algorithmus verstehen, können Sie das erste Element des zuvor gezeigten Diagramms implementieren.

Verwenden Sie Zufallswerte als anfänglichen Rauschstoß

Es gibt viele Arten von Signalgeneratoren, aus denen Sie bei der Klangsynthese wählen können. Zu den beliebtesten gehören periodische Funktionen wie die Rechteckwelle, die Dreieckwelle und die Sägezahnwelle. Allerdings erzielen Sie im Karplus-Strong-Synthesealgorithmus aufgrund des reichhaltigen harmonischen Inhalts die besten Ergebnisse mit einer aperiodischen Funktion, wie z. B. Zufallsrauschen im Laufe der Zeit filtern.

Rauschen gibt es in verschiedenen Farben, zum Beispiel rosa oder weiß. Der Unterschied liegt in ihrer spektralen Leistungsdichte über die Frequenzen hinweg. Bei weißem Rauschen beispielsweise hat jedes Frequenzband ungefähr die gleiche Leistung. Es eignet sich also perfekt für einen ersten Geräuschstoß, da es ein breites Spektrum an Obertönen enthält, die Sie durch einen Filter formen können.

Um das Experimentieren mit den verschiedenen Arten von Signalgeneratoren zu ermöglichen, definieren Sie eine benutzerdefinierte Protokollklasse in einem neuen Python-Modul namens 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:
        ...

Der Zweck einer Protokollklasse besteht darin, das gewünschte Verhalten durch Methodensignaturen anzugeben, ohne diese Methoden zu implementieren. In Python verwenden Sie normalerweise Auslassungspunkte (...), um anzuzeigen, dass Sie den Methodenkörper absichtlich undefiniert gelassen haben. Daher verhält sich eine Protokollklasse wie eine Schnittstelle in Java, wobei konkrete Klassen, die diese bestimmte Schnittstelle implementieren, die zugrunde liegende Logik bereitstellen.

In diesem Fall haben Sie die spezielle Methode .__call__()deklariert, um Instanzen von Klassen, die dem Protokoll entsprechen, aufrufbar zu machen. Ihre Methode erwartet zwei Argumente:

  1. Die Anzahl der zu produzierenden Audiobeispiele
  2. Die Anzahl der Proben pro Sekunde

Darüber hinaus sollen Burst-Generatoren ein NumPy-Array von Amplitudenpegeln zurückgeben, bei denen es sich um Gleitkommazahlen handeln sollte, die auf ein Intervall zwischen minus eins und plus eins normalisiert sind. Durch eine solche Normalisierung wird die anschließende Audioverarbeitung komfortabler.

Ihre erste konkrete Generatorklasse wird weißes Rauschen erzeugen, da Sie bereits festgestellt haben, dass sie in diesem Zusammenhang am besten geeignet ist:

# ...

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

Auch wenn Ihre neue Klasse nicht von BurstGenerator erbt, entspricht sie dennoch dem zuvor definierten Protokoll, indem sie eine .__call__()-Methode mit der richtigen Signatur bereitstellt. Beachten Sie, dass die Methode die Abtastrate als zweites Argument verwendet, obwohl sie nirgendwo im Text darauf verweist. Dies ist erforderlich, um das Protokoll zu erfüllen.

Instanzen Ihrer WhiteNoise-Generatorklasse sind jetzt aufrufbar:

>>> 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

Die resultierenden Stichproben sind auf den Bereich zwischen -1 und 1 beschränkt, da die Minimal- und Maximalwerte sehr nahe an diesen Grenzen liegen. Außerdem liegt der Mittelwert nahe Null, da sich bei einer großen Anzahl von Stichproben die positiven und negativen Amplituden ausgleichen, was eine gleichmäßige Werteverteilung bestätigt.

Okay. Die nächste große Komponente im Diagramm des Karplus-Strong-Algorithmus ist die Rückkopplungsschleife selbst. Sie werden es jetzt in kleinere Stücke zerlegen.

Filtern Sie höhere Frequenzen mit einer Rückkopplungsschleife

Eine elegante Möglichkeit, eine Feedback-Schleife in Python zu simulieren, besteht darin, Generatorfunktionen miteinander zu verbinden und ihnen Werte zu senden. Sie können auch asynchrone Funktionen definieren und diese als kooperative Coroutinen einbinden, um einen ähnlichen Effekt zu erzielen. In diesem Tutorial verwenden Sie jedoch eine viel einfachere und etwas effizientere Implementierung, die auf Iteration basiert.

Erstellen Sie ein weiteres Modul mit dem Namen synthesize in Ihrem Python-Paket und definieren Sie den folgenden Klassenplatzhalter:

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

Diese eingefrorene Datenklasse besteht aus zwei optionalen Attributen, mit denen Sie die erwartete Burst-Generator-Implementierung und die Abtastrate angeben können. Wenn Sie diese Parameter beim Erstellen einer neuen Instanz der Klasse überspringen, verlassen Sie sich auf die Standardeinstellungen, die den Generator für weißes Rauschen mit einer als Python-Konstante definierten Abtastrate von 44,1 kHz verwenden.

Mithilfe des Standardbibliothekspakets itertools können Sie jetzt einen unendlichen Iterator implementieren, der cycle() den Puffer der Audio-Samples durchläuft. Der folgende Codeausschnitt spiegelt das Karplus-Strong-Diagramm wider, das Sie in einem früheren Abschnitt gesehen haben:

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

Sie definieren die Methode .vibrate(), die die Grundfrequenz, die Dauer und einen optionalen Dämpfungskoeffizienten der Vibration annimmt als Argumente. Wenn keine Angabe erfolgt, halbiert der Standardwert des Koeffizienten die Summe zweier benachbarter Stichproben bei jedem Zyklus, was der Berechnung eines gleitenden Durchschnitts entspricht. Es simuliert einen Energieverlust, wenn die Vibration nachlässt.

Bisher definiert Ihre Methode eine innere Funktion, die beim Aufruf einen Generatoriterator zurückgibt. Das resultierende Generatorobjekt weist mithilfe des bereitgestellten Burst-Generators einen Puffer zu und füllt ihn. Die Funktion tritt dann in eine Endlosschleife for ein, die im Round-Robin-Verfahren auf unbestimmte Zeit Werte aus dem Puffer liefert, da es keine Stoppbedingung gibt.

Sie verwenden den Walross-Operator (:=), um in jedem Zyklus gleichzeitig den aktuellen Amplitudenwert zu ermitteln und abzufangen. Bei der nächsten Iteration berechnen Sie den Durchschnitt der beiden benachbarten Werte, um den Dämpfungseffekt zu simulieren. Der Modulo-Operator (%) stellt sicher, dass der Index am Anfang des Puffers weiterläuft, sobald er das Ende erreicht, wodurch ein kreisförmiger Puffereffekt entsteht.

Um eine endliche Anzahl von Samples zu verbrauchen, die durch den Parameter duration bestimmt wird, können Sie Ihren feedback_loop() mit einem Aufruf der Funktion fromiter() von NumPy umschließen :

# ...

@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),
        )

Solange der Parameter duration eine Instanz der Datenklasse Time ist, die Sie zuvor definiert haben, können Sie die Anzahl der Sekunden in die entsprechende Anzahl von Audio-Samples umwandeln, indem Sie < aufrufen.get_num_samples(). Denken Sie daran, die richtige Abtastrate zu übergeben. Sie sollten auch float64 als Datentyp für die Elemente Ihres NumPy-Arrays angeben, um eine hohe Präzision sicherzustellen und unnötige Typkonvertierungen zu vermeiden.

Sie sind mit der Implementierung des Karplus-Strong-Synthesealgorithmus fast fertig, aber Ihr Code weist zwei kleinere Probleme auf, die Sie zunächst beheben müssen.

Entfernen Sie die DC-Vorspannung und normalisieren Sie Audio-Samples

Abhängig vom anfänglichen Burst und dem Dämpfungskoeffizienten erhalten Sie möglicherweise Werte außerhalb des erwarteten Amplitudenbereichs oder die Werte weichen von Null ab und führen zu einer Gleichstromvorspannung. Dies könnte zu hörbaren Klickgeräuschen oder anderen unangenehmen Artefakten führen. Um diese potenziellen Probleme zu beheben, entfernen Sie die Verzerrung, indem Sie den Mittelwert des Signals subtrahieren, und normalisieren anschließend die resultierenden Abtastwerte.

NumPy bietet für diese Aufgaben keine integrierten Funktionen, aber die Erstellung eigener Funktionen ist nicht allzu kompliziert. Erstellen Sie zunächst ein neues Modul namens processing in Ihrem Paket mit diesen beiden Funktionen:

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()

Beide Funktionen nutzen die Vektorisierungsfunktionen von NumPy. Der erste subtrahiert den Mittelwert von jedem Element und der zweite dividiert alle Stichproben durch den maximalen Absolutwert im Eingabearray.

Jetzt können Sie Ihre Hilfsfunktionen im Synthesizer importieren und aufrufen, bevor Sie das Array berechneter Audiobeispiele zurückgeben:

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),
                )
            )
        )

Obwohl die Reihenfolge möglicherweise keinen wesentlichen Unterschied macht, ist es üblich, die DC-Vorspannung vor der Normalisierung zu entfernen. Durch das Entfernen der Gleichstromkomponente wird sichergestellt, dass Ihr Signal um Null herum zentriert ist. Andernfalls könnte es immer noch eine Gleichstromkomponente geben, die sich auf den Gesamtumfang der Normalisierung auswirken könnte.

Großartig! Sie haben gerade den Karplus-Strong-Synthesealgorithmus in Python implementiert. Warum stellen Sie es nicht einmal auf die Probe, um die Ergebnisse zu hören?

Zupfen Sie die Saite, um monophone Klänge zu erzeugen

Genau genommen gibt Ihr Synthesizer ein NumPy-Array mit normalisierten Amplitudenpegeln anstelle von Audio-Samples zurück, die direkt dem digitalen Sound entsprechen. Gleichzeitig können Sie aus mehreren Datenformaten, Komprimierungsschemata und Kodierungen wählen, um zu bestimmen, wie Ihre Audiodaten gespeichert und übertragen werden.

Beispielsweise ist die lineare Pulse-Code-Modulation (LPCM) eine Standardkodierung in unkomprimierten WAV-Dateien, die typischerweise 16-Bit-Ganzzahlen mit Vorzeichen zur Darstellung von Audio-Samples verwenden. Andere Formate wie MP3 verwenden verlustbehaftete Komprimierungsalgorithmen, die die Dateigröße reduzieren, indem sie Informationen entfernen, die für das menschliche Ohr weniger wahrnehmbar sind. Diese Formate können je nach gewünschter Qualität und Dateigröße konstante oder variable Bitraten bieten.

Damit Sie sich nicht in technischen Details verlieren, verwenden Sie die Pedalboard-Bibliothek von Spotify, die diese einfachen Details für Sie erledigt. Sie liefern die normalisierten Amplitudenpegel von Ihrem Synthesizer und Pedalboard kodiert sie entsprechend Ihrem bevorzugten Datenformat:

>>> 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))

In diesem Fall speichern Sie die synthetisierten Sounds als MP3-Datei mit den Standardparametern der Bibliothek. Der obige Codeausschnitt erzeugt eine MP3-Datei mit einem Monokanal, abgetastet bei 44,1 kHz und einer konstanten Bitrate von 320 Kilobit pro Sekunde<, das ist die höchste Qualität, die von diesem Format unterstützt wird. Denken Sie daran, den Code in der virtuellen Umgebung Ihres Projekts auszuführen, um auf die erforderlichen Module zuzugreifen.

Um einige dieser Audioeigenschaften zu bestätigen, können Sie die Datei zum Lesen öffnen und einige ihrer Attribute überprüfen:

>>> 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'

Da MP3-Dateien komprimiert sind, können Sie deren Bitrate anhand dieser Parameter nicht berechnen. Die tatsächliche Bitrate wird zusammen mit anderen Metadaten im Header der Datei gespeichert, die Sie mit einem externen Programm wie MediaInfo überprüfen können:

$ 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
(...)

Die generierte Datei enthält eine Reihe von Musiktönen, die auf den von Ihnen angegebenen Frequenzen basieren. Jeder Ton wird eine halbe Sekunde lang gehalten, was zu einer Melodie führt, die sich durch die Noten do-re-mi-fa-sol-la-ti-do fortsetzt. Diese Töne sind die Solfeggio-Noten, die oft zum Erlernen der Tonleiter verwendet werden. Unten sehen Sie, wie sie aussehen, wenn sie als Wellenform dargestellt werden. Sie können auf die Wiedergabetaste klicken, um es anzuhören:

Beachten Sie, dass jeder Ton abrupt stoppt, bevor er vollständig ausgeblendet werden kann. Sie können mit einer längeren oder kürzeren Dauer experimentieren und den Dämpfungs-Parameter anpassen. Aber egal, wie sehr Sie sich auch anstrengen, Sie können nur monophone Klänge erzeugen, ohne die Möglichkeit, mehrere Noten zu überlagern.

Im nächsten Abschnitt erfahren Sie, wie Sie komplexere Klänge synthetisieren und so der Simulation einer vollwertigen Gitarre einen Schritt näher kommen.

Schritt 3: Simulieren Sie das Anschlagen mehrerer Gitarrensaiten

An dieser Stelle können Sie Audiodateien generieren, die aus monophonen Klängen bestehen. Das bedeutet, dass, sobald der nächste Ton zu spielen beginnt, der vorherige stoppt, was zu einer Reihe diskreter Töne führt. Für altmodische Handy-Klingeltöne oder Retro-Videospiel-Soundtracks ist das in Ordnung. Wenn ein Gitarrist jedoch mehrere Saiten gleichzeitig anschlägt, erzeugt er einen Akkord mit zusammenklingenden Tönen.

In diesem Abschnitt optimieren Sie Ihre Synthesizer-Klasse, um polyphone Klänge zu erzeugen, indem Sie die einzelnen Noten überlappen und miteinander interferieren lassen.

Mischen Sie mehrere Noten zu einem polyphonen Klang

Um mehrere Noten gleichzeitig zu spielen, können Sie die entsprechenden akustischen Wellen mischen. Definieren Sie in Ihrer Synthesizer-Klasse eine weitere Methode, die für die Überlagerung von Samples mehrerer Sounds verantwortlich ist:

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)

Diese Methode benötigt eine Folge gleich großer NumPy-Arrays, die die Amplituden mehrerer zu mischender Klänge umfassen. Die Methode gibt dann die elementweise arithmetische Summe der Eingangsschallwellen zurück.

Vorausgesetzt, Sie haben die DC-Vorspannung bereits von den einzelnen Sounds, die Sie mischen möchten, entfernt, müssen Sie sich darüber keine Sorgen mehr machen. Darüber hinaus möchten Sie die überlagerten Sounds zu diesem Zeitpunkt nicht normalisieren, da ihre Anzahl innerhalb eines einzelnen Songs stark variieren kann. Wenn Sie dies jetzt tun, kann es zu inkonsistenten Lautstärkepegeln kommen, sodass bestimmte Musikakkorde kaum noch hörbar sind. Stattdessen müssen Sie die Normalisierung anwenden, bevor Sie den gesamten Song in die Datei schreiben.

Angenommen, Sie möchten einen Musiker simulieren, der alle Saiten einer Gitarre gleichzeitig zupft. So können Sie dies mit Ihrer neuen Methode tun:

>>> 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)))

Sie definieren die Frequenzen entsprechend der Standardstimmung einer sechssaitigen Gitarre und stellen die Dauer einer einzelnen Note auf dreieinhalb Sekunden ein. Zusätzlich stellen Sie den Dämpfungskoeffizienten auf einen etwas größeren Wert als zuvor ein, damit er länger vibriert. Anschließend synthetisieren Sie den Klang jeder Saite in einem Listenverständnis und kombinieren sie mithilfe Ihrer .overlay()-Methode.

Dies ist die resultierende Wellenform der Audiodatei, die Sie erstellen, nachdem Sie den oben aufgeführten Code ausgeführt haben:

Es ist zweifellos eine Verbesserung gegenüber der monophonen Version. Allerdings klingt die synthetisierte Datei beim Abspielen immer noch etwas künstlich. Das liegt daran, dass bei einer echten Gitarre die Saiten nie genau im selben Moment gezupft werden. Es gibt immer eine leichte Verzögerung zwischen dem Zupfen der einzelnen Saiten. Die daraus resultierenden Welleninteraktionen erzeugen komplexe Resonanzen, die den Klang noch reichhaltiger und authentischer machen.

Als Nächstes führen Sie eine einstellbare Verzögerung zwischen den aufeinanderfolgenden Anschlägen ein, um Ihrem polyphonen Klang ein realistischeres Gefühl zu verleihen. Dadurch können Sie die Schlagrichtung erkennen!

Passen Sie die Schlaggeschwindigkeit an, um den Rhythmus zu steuern

Wenn Sie die Saiten einer Gitarre schnell anschlagen, ist die Verzögerung zwischen aufeinanderfolgenden Zupfversuchen relativ kurz, wodurch der Gesamtklang laut und scharf wird. Umgekehrt erhöht sich die Verzögerung, wenn Sie die Saiten langsamer und sanfter anschlagen. Sie können diese Technik auf die Spitze treiben, indem Sie ein Arpeggio oder einen gebrochenen Akkord spielen, bei dem Sie die Noten nacheinander und nicht gleichzeitig spielen.

Ändern Sie nun Ihre .overlay()-Methode so, dass sie einen zusätzlichen delay-Parameter akzeptiert, der das Zeitintervall zwischen den einzelnen Strichen darstellt:

# ...

@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

Basierend auf der aktuellen Sampling-Frequenz Ihres Synthesizers wandeln Sie die Verzögerung in Sekunden in die entsprechende Anzahl an Samples um. Anschließend ermitteln Sie die Gesamtzahl der Samples, die Sie dem resultierenden Array zuweisen möchten, das Sie mit Nullen initialisieren. Schließlich durchlaufen Sie die Sounds und fügen sie mit dem entsprechenden Offset Ihrem Sample-Array hinzu.

Hier ist das gleiche Beispiel, das Sie im vorherigen Abschnitt gesehen haben. Allerdings haben Sie jetzt eine Verzögerung von 40 Millisekunden zwischen den einzelnen Anschlägen und variieren die Vibrationsdauer je nach Frequenz:

>>> 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)))

Noten mit einer niedrigeren Frequenz haben eine etwas längere Dauer als ihre Gegenstücke mit höherer Frequenz. Dies simuliert die Trägheit echter Saiten, die tendenziell länger schwingen, wenn sie dicker oder länger sind.

Unten sehen Sie die entsprechende Wellenform, die offenbar mehr Variation und Komplexität aufweist:

Wenn Sie sich diese Wellenform genau ansehen, sehen Sie die einzelnen Spitzen am Anfang, die anzeigen, wo die nachfolgenden Noten beginnen. Sie haben den gleichen Abstand, wie durch Ihren Verzögerungsparameter bestimmt.

Durch Ändern der Verzögerung können Sie die Hubgeschwindigkeit anpassen, um einen schnelleren und dynamischeren Rhythmus oder einen langsameren, sanfteren Klang zu erzeugen. Sie verwenden diesen Parameter, um die Ausdruckskraft Ihres virtuellen Instruments zu verbessern und die musikalischen Phrasierungen nachzuahmen, die ein Gitarrist normalerweise verwenden würde.

Da Sie nun die Kontrolle über das Timing jeder Note in einem Akkord haben, können Sie weiter experimentieren, indem Sie die Reihenfolge ändern, in der Sie sie spielen.

Kehren Sie die Schlagrichtung um, um das Timbre zu ändern

Gitarristen variieren beim Spielen oft nicht nur die Geschwindigkeit, sondern auch die Schlagrichtung. Durch den Wechsel zwischen Ab- und Aufschlägen können sie verschiedene Saiten betonen und die Klangfarbe desselben Akkords verändern. Abwärtsschläge klingen tendenziell kraftvoller und sind normalerweise lauter, da das Plektrum – oder Ihr Finger – zuerst die tieferen, dickeren Saiten trifft. Umgekehrt betonen Aufschläge oft die höheren, dünneren Saiten und erzeugen so einen leichteren Klang.

Mit benutzerdefinierten Datentypen können Sie sowohl die Schlaggeschwindigkeit als auch die Schlagrichtung ausdrücken. Erstellen Sie ein Python-Modul mit dem Namen Stroke in Ihrem digitar-Paket und definieren Sie darin diese beiden Klassen:

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)

Die erste Klasse ist eine Python-Enumeration, die den sich gegenseitig ausschließenden Strichrichtungen, von denen es zwei gibt, eindeutige Werte zuweist. Die folgende Klasse, Velocity, verwendet diese Enumeration als Mitglied und kombiniert sie mit der Verzögerung oder dem Intervall zwischen den nachfolgenden Zupfvorgängen.

Sie können Objekte schnell instanziieren, um Gitarrenschläge darzustellen, indem Sie praktische Klassenmethoden für Ihre Velocity-Klasse aufrufen:

>>> 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')))

Der erste Schlag ist langsam und nach unten gerichtet, während der zweite schneller und nach oben gerichtet ist. Sie verwenden diese neuen Datentypen im Projekt, um das musikalische Gefühl Ihrer digitalen Gitarre zu steuern.

Aber es gibt viele Arten von Gitarren in freier Wildbahn. Manche haben weniger Saiten, andere sind größer oder kleiner und manche benötigen einen elektronischen Verstärker. Darüber hinaus können Sie jedes Instrument auf unterschiedliche Noten stimmen. Bevor Sie also die Schlaggeschwindigkeit richtig nutzen können, müssen Sie ein virtuelles Instrument bauen und lernen, damit umzugehen.

Schritt 4: Spielen Sie Musiknoten auf der virtuellen Gitarre

An dieser Stelle können Sie mit Ihrer digitalen Gitarre sowohl monophone als auch polyphone Klänge basierend auf bestimmten Frequenzen erzeugen. In diesem Schritt modellieren Sie die Beziehung zwischen diesen Frequenzen und den Musiknoten, denen sie entsprechen. Darüber hinaus simulieren Sie die Stimmung der Gitarrensaiten und die Interaktion mit dem Griffbrett, um ein realistisches Spielerlebnis zu schaffen.

Drücken Sie eine vibrierende Saite, um ihre Tonhöhe zu ändern

Die meisten Gitarren haben zwischen vier und zwölf Saiten, von denen jede unterschiedliche Tonhöhen erzeugen kann. Wenn Sie eine leere Saite zupfen, ohne den Gitarrenhals zu berühren, beginnt die Saite mit ihrer Grundfrequenz zu schwingen. Sobald Sie die Saite jedoch gegen einen der Metallstreifen oder Bünde entlang des Griffbretts drücken, verkürzen Sie die Saite effektiv und ändern dadurch ihre Schwingungsfrequenz beim Zupfen.

Jeder Gitarrenbund stellt eine Erhöhung der Tonhöhe um einen einzelnen Halbton oder einen Halbton auf der chromatischen Tonleiter dar – der Standardtonleiter in der westlichen Musik. Die chromatische Tonleiter unterteilt jede Oktave oder einen Satz von acht Musiknoten in zwölf gleichmäßig verteilte Halbtöne, mit einem Verhältnis der zwölften Wurzel von zwei dazwischen. Wenn Sie bis zum zwölften Halbton gehen, verdoppeln Sie die Frequenz der Note, die den Beginn einer Oktave markiert.

Die Abstände zwischen benachbarten Bünden in einem Bundinstrument folgen dem gleichen Prinzip und spiegeln die logarithmische Natur des Frequenzanstiegs bei jedem Schritt wider. Wenn Sie sich über das Griffbrett bewegen und auf die nächsten Bünde drücken, werden Sie feststellen, dass die Tonhöhe der Saite immer weiter ansteigt, jeweils einen Halbton.

Auf einer typischen sechssaitigen Gitarre finden Sie normalerweise etwa zwanzig oder mehr Bünde, was über hundert Tonhöhen entspricht! Wenn Sie jedoch die Duplikate aufgrund überlappender Oktaven berücksichtigen, verringert sich die tatsächliche Anzahl der unterschiedlichen Tonhöhen. In Wirklichkeit können Sie etwa vier Oktaven Noten spielen, was weniger als fünfzig einzigartigen Tonhöhen entspricht. Andererseits kennt die virtuelle Gitarre, die Sie bauen möchten, keine derartigen Grenzen!

In Python können Sie eine halbtonbasierte Tonhöhenanpassung wie folgt implementieren:

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))

Sobald Sie eine neue Tonhöhe erstellt haben, können Sie die entsprechende Grundfrequenz ändern, indem Sie .adjust() mit der gewünschten Anzahl von Halbtönen aufrufen. Eine positive Zahl von Halbtönen erhöht die Frequenz, eine negative Zahl verringert sie, während Null sie beibehält. Beachten Sie, dass Sie den Potenzierungsoperator (**) von Python verwenden, um die zwölfte Wurzel aus zwei zu berechnen, auf der die Formel basiert.

Um zu bestätigen, dass Ihr Code wie erwartet funktioniert, können Sie den folgenden Test ausführen:

>>> 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)

Sie beginnen mit der Definition einer Tonhöhe, die von einer mit 110 Hz schwingenden Saite erzeugt wird und der A-Note in der zweiten Oktave entspricht. Anschließend durchlaufen Sie eine Liste von Halbtonzahlen, um die Tonhöhe entsprechend anzupassen.

Abhängig davon, ob die angegebene Zahl negativ oder positiv ist, halbiert oder verdoppelt eine Anpassung der Frequenz um genau zwölf Halbtöne (eine Oktave) die ursprüngliche Frequenz dieser Tonhöhe. Alles dazwischen setzt die Frequenz auf den entsprechenden Halbton innerhalb dieser Oktave.

Die Möglichkeit, die Frequenz anzupassen, ist nützlich, aber die Klasse Pitch zwingt Sie dazu, in Tonhöhen, Halbtönen und Oktaven zu denken, was nicht besonders praktisch ist. Sie verpacken die Tonhöhe in einer übergeordneten Klasse in einem neuen Modul namens 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)

Um das Zupfen einer leeren Saite zu simulieren, übergeben Sie None oder lassen Sie den Parameter fret_number weg, wenn Sie Ihre Methode .press_fret() aufrufen. Dadurch erhalten Sie die unveränderte Tonhöhe der Saite zurück. Alternativ können Sie auch Null als Bundnummer übergeben.

Und so können Sie mit Ihrer neuen Klasse interagieren:

>>> 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)

Sie können jetzt Tonhöhen und Gitarrensaiten unabhängig voneinander behandeln, sodass Sie bei Bedarf derselben Saite eine andere Tonhöhe zuweisen können. Diese Zuordnung von Tonhöhen zu leeren Saiten wird in der Musik als Gitarrenstimmung bezeichnet. Stimmsysteme erfordern, dass Sie eine bestimmte Notation von Musiknoten verstehen, worüber Sie im nächsten Abschnitt erfahren werden.

Lesen Sie Musiknoten aus der wissenschaftlichen Tonhöhennotation

In der wissenschaftlichen Tonhöhennotation erscheint jede Musiknote als Buchstabe, gefolgt von einem optionalen Symbol, wie z. B. einem Kreuz (♯) oder einem B (♭) für Vorzeichen, sowie einer Oktavzahl. Das Kreuz-Symbol erhöht die Tonhöhe der Note um einen Halbton, während das B-Symbol sie um einen Halbton senkt. Wenn Sie die Oktavzahl weglassen, wird implizit Null angenommen.

Es gibt sieben Buchstaben in dieser Notation, wobei C die Grenzen jeder Oktave markiert:

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  

In diesem Fall sehen Sie die erste Oktave mit acht Noten: C0, D0, E0, F 0, G0, A0, B0 und C1. Das System beginnt bei C0 oder einfach C, was ungefähr 16,3516 Hz entspricht. Wenn Sie rechts ganz nach C1 gehen, wo auch die nächste Oktave beginnt, verdoppeln Sie diese Frequenz.

Sie können jetzt die wissenschaftliche Tonhöhennotation entschlüsseln. Beispielsweise gibt A4 die Musiknote A in der vierten Oktave mit einer Frequenz von 440 Hz an, was der Referenztonhöhe des Konzerttons entspricht. In ähnlicher Weise stellt C♯4 die Cis-Note in der vierten Oktave dar, die sich einen Halbton über dem mittleren C auf einer Standardklaviertastatur befindet.

In Python können Sie reguläre Ausdrücke nutzen, um diese Notation programmgesteuert in numerische Tonhöhen zu übersetzen. Fügen Sie der Klasse Pitch im Modul pitch die folgende Klassenmethode hinzu:

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))

Diese Methode berechnet die Frequenz für eine bestimmte Note basierend auf ihrem Abstand in Halbtönen von A4. Beachten Sie, dass es sich hierbei um eine vereinfachte Implementierung handelt, die nur scharfe Noten berücksichtigt. Wenn Sie eine flache Note darstellen müssen, können Sie sie in Form der entsprechenden hohen Note umschreiben, sofern diese existiert. Zum Beispiel ist B♭ dasselbe wie A♯.

Hier ist die Beispielverwendung Ihrer neuen Klassenmethode:

>>> 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)

Wie Sie sehen, akzeptiert und interpretiert der Code einige Varianten der wissenschaftlichen Tonhöhennotation. Das ist großartig! Jetzt können Sie Ihre digitale Gitarre stimmen.

Führen Sie die Saitenstimmung der virtuellen Gitarre durch

In der realen Welt passen Musiker die Spannung der Gitarrensaiten an, indem sie die jeweiligen Stimmwirbel anziehen oder lösen, um einen perfekt gestimmten Klang zu erzielen. Auf diese Weise können sie den Saiten ihres Instruments unterschiedliche Notensätze oder Tonhöhen zuweisen. Gelegentlich verwenden sie dieselbe Tonhöhe für zwei oder mehr Saiten, um einen volleren Klang zu erzeugen.

Abhängig von der Anzahl der Saiten einer Gitarre werden Sie die Noten unterschiedlich zuweisen. Abgesehen von der Standardstimmung, die die typischste Notenwahl für ein bestimmtes Instrument darstellt, können Sie mehrere alternative Gitarrenstimmungen anwenden, selbst wenn Ihnen die gleiche Anzahl an Saiten zur Verfügung steht.

Die traditionelle Stimmung einer sechssaitigen Gitarre, von der dünnsten Saite (höchste Tonhöhe) bis zur dicksten Saite (tiefste Tonhöhe), ist die folgende:

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

Wenn Sie Rechtshänder sind, klimpern oder zupfen Sie normalerweise mit der rechten Hand die Saiten in der Nähe des Schalllochs, während Sie mit der linken Hand die Noten am Hals spielen. In dieser Ausrichtung liegt die erste Saite (E4) am nächsten am unteren Ende, während die sechste Saite (E2) am nächsten an der Oberseite liegt.

Es ist üblich, Gitarrenstimmungen in aufsteigender Frequenzreihenfolge anzugeben. Beispielsweise wird die Standardgitarrenstimmung normalerweise wie folgt dargestellt: E2-A2-D 3-G3-B3- E4. Gleichzeitig folgen einige Gitarrentabulaturen der in der Tabelle oben dargestellten Saitennummerierung, wodurch diese Reihenfolge umgekehrt wird. Daher repräsentiert die obere Zeile einer sechssaitigen Gitarrentabulatur normalerweise die erste Saite (E4) und die untere Zeile die sechste Saite (E2).

Um Verwirrung zu vermeiden, respektieren Sie beide Konventionen. Fügen Sie die folgende Klasse zu Ihrem instrument-Modul hinzu, damit Sie eine Saitenstimmung darstellen können:

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)
            )
        )

Ein Objekt dieser Klasse enthält ein Tupel von VibratingString-Instanzen, sortiert nach der String-Nummer in aufsteigender Reihenfolge. Mit anderen Worten: Das erste Element im Tupel entspricht der ersten Zeichenfolge (E4) und das letzte Element der sechsten Zeichenfolge (E2). Beachten Sie, dass die Anzahl der Saiten kleiner oder größer als sechs sein kann, wenn Sie andere Arten von Saiteninstrumenten darstellen müssen, beispielsweise ein Banjo, das nur fünf Saiten hat.

In der Praxis erstellen Sie neue Instanzen der Klasse StringTuning, indem Sie die Klassenmethode .from_notes() aufrufen und eine variable Anzahl von Musiknoten in wissenschaftlicher Tonhöhennotation übergeben. Wenn Sie dies tun, müssen Sie die Reihenfolge der Saitenstimmung befolgen, beginnend mit der tiefsten Tonhöhe. Dies liegt daran, dass die Methode die eingegebenen Noten umkehrt, um sie an die typische Saitenanordnung auf einer Gitarrentabulatur anzupassen.

So können Sie die Klasse StringTuning verwenden, um verschiedene Stimmsysteme für verschiedene Zupfinstrumente darzustellen:

>>> 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)),
  )
)

Das erste Objekt stellt die Standardstimmung einer sechssaitigen Gitarre dar, während das zweite Objekt die Stimmung einer viersaitigen Bassgitarre darstellt. Sie können den gleichen Ansatz verwenden, um die Stimmung anderer Saiteninstrumente zu modellieren, indem Sie für jede Saite die entsprechenden Noten bereitstellen.

Damit können Sie den Effekt erzielen, die Gitarre mit den Fingern anzugreifen, um einen bestimmten Akkord zu spielen:

>>> 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)

In diesem Fall verwenden Sie die Standardgitarrenstimmung. Dann simulieren Sie das Drücken des zweiten Bundes auf der dritten Saite (G3) und das Belassen der fünften Saite (A2)< öffnen, während Sie beide anschlagen. Sie streichen oder greifen die restlichen Saiten nicht an, wie durch None im Tupel angezeigt. Die Funktion zip() kombiniert Saiten und die entsprechenden Bundnummern zu Paaren, die Sie durchlaufen.

Die dritte Saite ist auf den Ton G3 oder 196 Hz gestimmt. Da Sie ihn jedoch am zweiten Bund drücken, erhöhen Sie die Tonhöhe um zwei Halbtöne, was zu einer Frequenz von 220 Hz führt. Die fünfte Saite ist auf A2 oder 110 Hz gestimmt und kann offen oder ohne Bundstäbchen gespielt werden. Wenn Sie beide Frequenzen mischen, erzeugen Sie einen Akkord, der aus den Noten A3 und A2 besteht, die eine Oktave voneinander entfernt sind.

Als Nächstes erstellen Sie einen benutzerdefinierten Datentyp, um Musikakkorde einfacher darzustellen.

Stellen Sie Akkorde auf einem Saiteninstrument dar

Zuvor haben Sie ein einfaches Tupel definiert, um die Bundnummern in einem bestimmten Akkord auszudrücken. Sie können etwas expliziter vorgehen, indem Sie die Klasse tuple erweitern und die darin zulässigen Wertetypen einschränken:

from typing import Self

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

Mit Typhinweisen erklären Sie, dass Ihr Tupel nur Ganzzahlen enthalten soll, die die Bundnummern darstellen, oder leere Werte (None), die eine offene Zeichenfolge angeben. Sie stellen auch eine Klassenmethode bereit, .from_numbers(), mit der Sie eine Chord-Instanz erstellen können, indem Sie die Bundnummern direkt übergeben. Diese Methode benötigt eine variable Anzahl von Argumenten, von denen jedes eine Ganzzahl oder Keine sein kann.

So können Sie mit der Klasse Chord einen Akkord aus dem vorherigen Abschnitt dieses Tutorials definieren:

>>> 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)

Wenn Sie mit der Klassenmethode eine Chord-Instanz erstellen, übergeben Sie die Bundnummern als Argumente. Sie können die Klasse auch instanziieren, indem Sie ein iterierbares Werteobjekt, beispielsweise eine Liste, an den Konstruktor übergeben. Im Allgemeinen ist es jedoch expliziter, die Methode .from_numbers() zu verwenden.

Zusammenfassend sind dies die wichtigsten Punkte, die Sie beachten sollten:

  • Die Position des Werts im Tupel bestimmt die String-Nummer, sodass das erste Element der höchsten Tonhöhe entspricht.
  • Ein leerer Wert (None) bedeutet, dass Sie die Saite überhaupt nicht zupfen.
  • Null stellt eine offene Saite dar, die Sie zupfen, ohne einen Bund zu drücken.
  • Andere Ganzzahlen entsprechen den Bundnummern am Gitarrenhals, die Sie drücken.

Dies sind auch Fingermuster auf Gitarrentabulaturen, die Sie später im Tutorial nutzen werden. Jetzt ist es an der Zeit, einen weiteren benutzerdefinierten Datentyp zu definieren, mit dem Sie verschiedene Arten von Zupfinstrumenten im Code darstellen.

Modellieren Sie ein beliebiges Zupfinstrument

Wenn Sie über die wichtigsten Eigenschaften nachdenken, die den Klang eines Zupfinstruments beeinflussen, sind dies die Anzahl der Saiten, ihre Stimmung und das Material, aus dem sie bestehen. Letzterer Aspekt ist zwar nicht der einzige, beeinflusst aber, wie lange die Saite ihre Schwingung aufrechterhält und wie stark die Energiedämpfung ist.

Sie können diese Attribute bequem ausdrücken, indem Sie eine Datenklasse in Ihrem instrument-Modul definieren:

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]"
            )

Die Saitenstimmung bestimmt, wie viele Saiten ein Instrument hat und welche Grundschwingungsfrequenzen sie haben. Der Einfachheit halber haben alle Saiten eines Instruments die gleiche Schwingungszeit und den gleichen Dämpfungskoeffizienten, der standardmäßig auf die Hälfte eingestellt ist. Wenn Sie sie einzeln String für String überschreiben möchten, müssen Sie den Code selbst anpassen.

Die Methode .__post_init__() überprüft, ob die Dämpfung im akzeptablen Wertebereich liegt.

Sie können in Ihrer Klasse eine praktische Eigenschaft definieren, um schnell die Anzahl der Saiten in einem Instrument herauszufinden, ohne nach dem Stimmobjekt greifen zu müssen:

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)

Es handelt sich um eine zwischengespeicherte Eigenschaft für einen effizienteren Zugriff. Nachdem Sie zum ersten Mal auf eine solche Eigenschaft zugegriffen haben, merkt sich Python den berechneten Wert, sodass er bei nachfolgenden Zugriffen nicht erneut berechnet wird, da sich der Wert während der Lebensdauer eines Objekts nicht ändert.

Als Nächstes können Sie Methoden hinzufügen, die eine Chord-Instanz, die Sie zuvor erstellt haben, in ein Tupel von Tonhöhen umwandeln, das Sie später zum Synthetisieren eines polyphonen Klangs verwenden können:

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
        )

Da die Reihenfolge der Bundnummern in einem Akkord mit der Reihenfolge der Gitarrensaiten (von unten nach oben) übereinstimmt, simuliert das Streichen eines Akkords einen Aufwärtsstrich. Ihre .upStroke()-Methode verwendet einen Generatorausdruck mit einem bedingten Ausdruck, der fast identisch mit der Schleife aussieht, die Sie zuvor gesehen haben, als Sie die Saitenstimmung durchgeführt haben. Die Methode .downStroke() delegiert die Ausführung an .upStroke(), fängt das resultierende Tupel der Pitch-Objekte ab und kehrt es um.

Da sich die meisten Akkorde in einem einzigen Song immer wieder in ähnlichen Mustern wiederholen, möchten Sie nicht jeden einzelnen Akkord jedes Mal neu berechnen. Stattdessen kommentieren Sie beide Methoden mit dem Dekorator @cache, um redundante Berechnungen zu vermeiden. Durch das Speichern der berechneten Tupel gibt Python das zwischengespeicherte Ergebnis zurück, wenn dieselben Eingaben erneut auftreten.

Sie können jetzt verschiedene Arten von Zupfinstrumenten modellieren, um deren einzigartige akustische Eigenschaften zu reproduzieren. Hier sind einige Beispiele mit der Standardstimmung jedes Instruments:

>>> 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,
... )

Im Moment sind sie nur abstrakte Container für logisch zusammengehörige Daten. Bevor Sie diese virtuellen Instrumente voll ausnutzen und tatsächlich hören können, müssen Sie sie in Ihren Karplus-Strong-Synthesizer integrieren, was Sie als Nächstes tun werden.

Kombinieren Sie den Synthesizer mit einem Instrument

Sie möchten Ihren Synthesizer mit einem Zupfinstrument parametrieren, sodass Sie Klänge synthetisieren können, die für dieses bestimmte Instrument charakteristisch sind. Öffnen Sie jetzt das Modul synthesize in Ihrem Python-Projekt und fügen Sie der Klasse Synthesizer ein Feld instrument hinzu:

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

    # ...

Mithilfe der in der Klasse PluckedStringInstrument definierten Eigenschaften kann der Synthesizer Klänge erzeugen, die die Klangfarbe und den Ausdruck eines Zupfinstruments wie einer Akustikgitarre oder eines Banjos nachahmen.

Da Sie nun ein Instrument in Ihrem Synthesizer haben, können Sie dessen gestimmte Saiten nutzen, um einen Akkord mit der angegebenen Geschwindigkeit und Richtung» zu spielen :

# ...

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)

    # ...

Ihre neue .strum_strings()-Methode erwartet mindestens eine Chord- und eine Velocity-Instanz. Sie können optional die Vibrationsdauer übergeben, wenn Sie dies jedoch nicht tun, greift die Methode auf die Standarddauer des Instruments zurück. Je nach gewünschter Anschlagsrichtung synthetisiert es die Tonhöhen in aufsteigender oder absteigender Saitenreihenfolge. Abschließend werden sie mit der erforderlichen Verzögerung oder Arpeggiation überlagert.

Da .strum_strings() der einzige Teil der öffentlichen Schnittstelle Ihrer Klasse geworden ist, können Sie signalisieren, dass die anderen beiden Methoden vibrate() und overlay( ) sind nur für den internen Gebrauch bestimmt. Eine in Python übliche Konvention zur Bezeichnung nicht öffentlicher Methoden besteht darin, ihren Namen einen einzelnen Unterstrich (_) voranzustellen:

# ...

@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:
        # ...

Es ist jetzt klar, dass ._vibrate() und ._overlay() Implementierungsdetails sind, die sich ohne Vorankündigung ändern können, sodass Sie nicht von einem externen Bereich aus darauf zugreifen sollten.

Ihr Synthesizer ist fast fertig, aber es fehlt ein entscheidendes Detail. Wenn Sie ein komplettes Musikstück wie den Original-Diablo-Soundtrack synthetisieren würden, würden über neunzig Prozent der Synthesezeit für redundante Berechnungen aufgewendet. Das liegt daran, dass die meisten Lieder aus sich wiederholenden Mustern und Motiven bestehen. Es sind diese wiederholten Akkordfolgen, die einen erkennbaren Rhythmus erzeugen.

Um die Gesamtsynthesezeit von Minuten auf Sekunden zu reduzieren, können Sie Caching der Zwischenergebnisse integrieren. Idealerweise möchten Sie alle Methoden in Ihrer Synthesizer-Klasse mit dem @cache-Dekorator dekorieren, um sie einmal für jede eindeutige Liste von Argumenten zu berechnen. Allerdings erfordert das Caching, dass alle Methodenargumente hashbar sind.

Während Sie fleißig unveränderliche Objekte verwendet haben, die zufällig auch hashbar sind, ist dies bei NumPy-Arrays nicht der Fall. Daher können Sie die Ergebnisse Ihrer Methode ._overlay(), die eine Folge von Arrays als Argument verwendet, nicht zwischenspeichern. Stattdessen können Sie die beiden anderen Methoden zwischenspeichern, die nur auf unveränderlichen Objekten basieren:

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:
        # ...

Mit dieser kleinen Änderung tauschen Sie im Wesentlichen Speicher gegen Geschwindigkeit. Solange Ihr Computer über genügend Arbeitsspeicher verfügt, dauert dies nur einen Bruchteil der Zeit, die sonst erforderlich wäre. Da die Ergebnisse gespeichert und abgerufen werden, werden sie nicht jedes Mal neu berechnet, wenn sie angefordert werden.

Wie wäre es, wenn Sie ein paar Akkorde auf einigen Ihrer Instrumente spielen? Unten finden Sie einen kurzen Codeausschnitt, der einen Abwärtsschlag auf allen leeren Saiten von drei verschiedenen Zupfinstrumenten spielt, die Sie zuvor definiert haben:

>>> 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)

Dieser Code durchläuft ein Wörterbuch von Schlüssel-Wert-Paaren, bestehend aus dem Namen des Instruments und der entsprechenden PluckedStringInstrument-Instanz. Wenn Sie die resultierenden Audiodateien unten abspielen, werden Sie die charakteristische Klangfarbe jedes Instruments erkennen:

In Ordnung. Sie haben alle Teile zusammen und sind bereit, echte Musik auf Ihrer virtuellen Gitarre zu spielen!

Schritt 5: Komponieren Sie Melodien mit Schlagmustern

An diesem Punkt können Sie die einzelnen Noten und Akkorde synthetisieren, die so klingen, als hätten Sie sie auf einem echten Instrument gespielt. Darüber hinaus können Sie verschiedene Arten von Zupfinstrumenten simulieren und diese nach Ihren Wünschen stimmen. In diesem Teil des Tutorials komponieren Sie aus diesen Bausteinen komplexere Melodien.

Weisen Sie Ihrem Instrument eine Audiospur zu

Musik besteht aus Akkorden und Noten, die entlang einer linearen Zeitachse angeordnet sind und absichtlich in Abständen angeordnet sind, um Rhythmus und Melodie zu erzeugen. Ein einzelner Song enthält oft mehr als eine Audiospur, die verschiedenen Instrumenten wie Leadgitarre, Bassgitarre und Schlagzeug sowie Gesang entspricht.

Sie stellen eine Audiospur mit Ihrer ersten mutable-Klasse in diesem Projekt dar, um das schrittweise Hinzufügen und Mischen von Sounds in chronologischer Reihenfolge zu ermöglichen. Definieren Sie ein neues Modul mit dem Namen track mit der folgenden Klasse AudioTrack:

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)

Eine Audiospur enthält eine Folge von Audio-Samples, oder genauer gesagt, die Amplitudenpegel, die Sie als Samples mit einem ausgewählten Datenformat kodieren. Der Einfachheit halber werden Sie sie jedoch als Beispiele bezeichnen.

Um eine neue Instanz Ihrer AudioTrack-Klasse zu erstellen, müssen Sie die gewünschte Abtastrate oder Frequenz in Hertz angeben. Damit können Sie die aktuelle Dauer des Tracks in Sekunden berechnen und mit einem bestimmten Zeitversatz neue Samples hinzufügen. Ab sofort können Sie Samples nur ganz am Ende Ihres bestehenden Tracks anhängen, ohne die Möglichkeit, sie früher zu überlagern oder später einzufügen.

Sie werden das jetzt beheben, indem Sie eine andere Methode in Ihrer Klasse implementieren:

# ...

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

Diese Methode, .add_at(), benötigt zusätzlich zur Sequenz der hinzuzufügenden Samples einen Zeitpunkt als Argument. Basierend auf der Sampling-Rate des Tracks berechnet es den Offset in Bezug auf die Anzahl der Audio-Samples. Wenn der Offset mit der aktuellen Länge der Audiospur übereinstimmt, hängt die Methode die Samples mittels Delegation an die Methode .add() an.

Ansonsten wird die Logik etwas komplizierter:

  • Lücke: Wenn der Versatz über die aktuelle Länge des Tracks hinausgeht, füllt die Methode die Lücke mit Nullen, bevor die neuen Samples wie zuvor angehängt werden.
  • Vollständige Überlappung: Wenn der Versatz irgendwo in der Mitte der Spur liegt und die neuen Samples hineinpassen, überlagert die Methode die neuen Samples über die vorhandenen an der richtigen Position.
  • Teilweise Überlappung: Wenn der Versatz irgendwo in der Mitte der Spur liegt, die neuen Samples aber über das aktuelle Ende hinausreichen, dann mischt die Methode den überlappenden Teil und hängt die verbleibenden Samples an, die über die aktuelle Spurlänge hinausgehen .

Mit der neuen Methode können Sie Sounds innerhalb einer Audiospur präzise platzieren. Dennoch müssen Sie den Zeitverlauf auf der Zeitleiste im Auge behalten. Um Ihnen dabei zu helfen, erstellen Sie einen weiteren benutzerdefinierten Datentyp.

Verfolgen Sie den Musikverlauf auf einer Zeitleiste

Die Zeit kann sich nur vorwärts bewegen, daher modellieren Sie die Zeitleiste als eine weitere veränderbare Klasse mit einer speziellen Methode zum Vorrücken des aktuellen Augenblicks. Öffnen Sie jetzt Ihr Modul temporal und fügen Sie die folgende Datenklassendefinition hinzu:

# ...

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

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

Sofern Sie nichts anderes angeben, beginnt eine Zeitleiste standardmäßig bei null Sekunden. Dank der Unveränderlichkeit von Time-Objekten können Sie eines davon als Standardwert für das instant-Attribut verwenden.

Die Methode .__rshift__() stellt die Implementierung des bitweisen Rechtsverschiebungsoperators (>>) für Ihre Klasse bereit. In diesem Fall handelt es sich um eine nicht standardmäßige Implementierung, die nichts mit Operationen an Bits zu tun hat. Stattdessen wird die Zeitleiste um eine bestimmte Anzahl von Sekunden oder ein anderes Time-Objekt vorwärts verschoben. Die Methode aktualisiert die aktuelle Timeline-Instanz an Ort und Stelle und gibt sich selbst zurück, was eine Methodenverkettung oder die sofortige Auswertung der verschobenen Zeitachse ermöglicht.

Beachten Sie, dass durch das Verschieben der Zeitleiste entweder ein numerischer Wert, z. B. ein Decimal-Objekt, oder eine Time-Instanz zu einer anderen Time-Instanz hinzugefügt wird. Dieser Zusatz funktioniert nicht sofort, da Python nicht weiß, wie man zwei Objekte benutzerdefinierter Datentypen mithilfe des Plus-Operators (+) hinzufügt. Glücklicherweise können Sie ihm sagen, wie mit einem solchen Zusatz umzugehen ist, indem Sie die Methode .__add__() in Ihrer Klasse Time implementieren:

# ...

@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))

# ...

Wenn Sie ein Time-Objekt als Argument für .__add__() bereitstellen, berechnet die Methode die Summe der Dezimalsekunden in beiden Fällen und gibt eine neue Time zurück Instanz mit den resultierenden Sekunden. Handelt es sich bei dem Argument hingegen um einen der erwarteten numerischen Typen, wird es von der Methode zunächst entsprechend konvertiert. Bei einem nicht unterstützten Typ löst die Methode eine Ausnahme mit einer Fehlermeldung aus.

Sehen Sie sich die folgenden Beispiele an, um zu verstehen, wie Sie die Klasse Timeline verwenden können:

>>> 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')

Diese Beispiele zeigen verschiedene Möglichkeiten, den überschriebenen bitweisen Operator zu verwenden, um sich durch die Zeit zu bewegen. Insbesondere können Sie mehrere Zeitinkremente in einem Ausdruck verketten, um die Zeitachse kumulativ voranzutreiben. Eine Zeitleiste ist dauerhaft, sodass alle vorherigen Änderungen darin beibehalten werden, sodass Sie den aktuellen Zeitpunkt abfragen können.

Mit einer Audiospur und einer Zeitleiste können Sie endlich Ihre erste Melodie komponieren. Sind Sie bereit, etwas Spaß zu haben?

Wiederholen Sie Akkorde in zeitlichen Abständen

Zu Beginn spielen Sie den Refrain aus Jason Mraz‘ Hit „I’m Yours“ auf einer virtuellen Ukulele. Das folgende Beispiel basiert auf einer hervorragenden Erklärung, die großzügig von Adrian vom Learn And Play-Kanal auf YouTube bereitgestellt wurde. Wenn Sie mehr darüber erfahren möchten, wie man dieses spezielle Lied spielt, schauen Sie sich ein viel ausführlicheres Video-Tutorial auf Adrians Schwesterkanal an.

Der Refrain des Liedes besteht aus vier Akkorden in der folgenden Reihenfolge mit den entsprechenden Griffmustern für eine Ukulele:

  1. C-Dur: Drücken Sie den dritten Bund auf der ersten Saite
  2. G-Dur: Drücken Sie den zweiten Bund auf der ersten Saite, den dritten Bund auf der zweiten Saite und den zweiten Bund auf der dritten Saite
  3. A-Moll: Drücken Sie den zweiten Bund auf der vierten Saite
  4. F-Dur: Drücken Sie den ersten Bund auf der zweiten Saite und den zweiten Bund auf der vierten Saite

Zusätzlich sollte jeder Akkord gemäß dem unten dargestellten Klimamuster gespielt und zweimal wiederholt werden:

  1. Abwärtshub (langsam)
  2. Abwärtshub (langsam)
  3. Aufwärtshub (langsam)
  4. Aufwärtshub (schnell)
  5. Abschlag (schnell)
  6. Aufwärtshub (langsam)

Mit anderen Worten: Sie legen zunächst Ihre Finger auf das Griffbrett, um den gewünschten Akkord zu formen, und streichen dann die Saiten im vorgeschriebenen Muster weiter. Wenn Sie das Ende dieses Musters erreicht haben, spülen Sie es aus und wiederholen es, indem Sie es für denselben Akkord noch einmal durchspielen. Nachdem Sie das Muster für einen bestimmten Akkord zweimal durchgespielt haben, fahren Sie mit dem nächsten Akkord in der Sequenz fort.

Die nachfolgenden Striche im Muster haben ungefähr die folgenden Zeitintervalle in Sekunden:

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

Obwohl die spezifischen Akkordversätze nach Gehör geschätzt wurden, sind sie für diese Übung gut genug. Sie verwenden sie, um die synthetisierten Akkorde mithilfe einer Zeitleiste über eine Audiospur zu verteilen.

Wenn Sie alles zusammensetzen, können Sie ein Python-Skript mit dem Namen play_chorus.py erstellen, das das Schlagmuster des Refrains des Liedes nachbildet. Um Ordnung zu schaffen, sollten Sie darüber nachdenken, im Stammordner Ihres Projekts einen neuen Unterordner zu erstellen, in dem Sie solche Skripte speichern. Sie können ihm beispielsweise den Namen demo/ geben:

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

Die obige Funktion strumming_pattern() gibt einen Iterator von Triolen zurück, der aus dem Zeitintervall in Sekunden, einer Chord-Instanz und einem Velocity-Objekt besteht beschreibt einen Schlaganfall. Das Intervall ist ein Versatz des nächsten Akkords auf der Zeitachse relativ zum aktuellen Akkord.

Jeder Akkord gibt die Bundnummern an, die auf den jeweiligen Saiten gedrückt werden müssen. Denken Sie daran, dass die Saiten von rechts gezählt werden, sodass das letzte Element im Tupel des Akkords die erste Saite darstellt.

Insgesamt gibt es vier Arten von Schlaganfällen. Sowohl den Aufschlag als auch den Abschlag gibt es in zwei Varianten: langsam und schnell, die sich in der Verzögerung zwischen den aufeinanderfolgenden Zupfschlägen unterscheiden. Sie wechseln zwischen diesen Strichen, um den erwarteten Rhythmus zu simulieren.

Als nächstes können Sie eine virtuelle Ukulele definieren und an den Synthesizer anschließen:

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()

Gemäß Pythons Name-Main-Idiom definieren Sie die Funktion main() als Einstiegspunkt in Ihr Skript und rufen sie am Ende der Datei auf. Anschließend verwenden Sie die PluckedStringInstrument-Definition wieder, die Sie in einem früheren Abschnitt gesehen haben und die die Standardstimmung einer Ukulele angibt.

Der nächste Schritt besteht darin, die einzelnen Akkorde – je nachdem, wie Sie Ihre virtuellen Saiten anstreichen – zu synthetisieren und im richtigen Moment einer Audiospur hinzuzufügen:

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

# ...

Basierend auf der Abtastrate des Synthesizers erstellen Sie eine Audiospur und eine Zeitleiste, die bei null Sekunden beginnt. Anschließend durchlaufen Sie das Schlagmuster, synthetisieren den nächsten Ukulele-Sound und fügen ihn zum aktuellen Zeitpunkt der Audiospur hinzu. Zuletzt verschieben Sie die Zeitleiste um den angegebenen Versatz.

Sie können nun die in Ihrer Audiospur erhaltenen Amplituden in einer Datei speichern und dabei daran denken, sie zu normalisieren, um Clipping und andere Verzerrungen zu vermeiden:

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))

# ...

Wenn Sie dieses Skript ausführen, erhalten Sie eine Audiodatei mit dem Namen chorus.mp3, die das Schlagmuster und die Akkorde des Songs aufzeichnet:

Gönnen Sie sich einen wohlverdienten Klaps auf die Schulter! Sie haben gerade einen Synthesizer für Zupfinstrumente gebaut. Es funktioniert einigermaßen gut, erfordert aber, dass Sie die einzelnen Noten auf der Timeline manuell planen, um sie an den Rhythmus anzupassen. Das kann fehleranfällig und umständlich sein. Außerdem können Sie das Tempo des Songs oder die Anzahl der Schläge pro Minute nicht ändern.

Als Nächstes gehen Sie systematischer vor, um die Musiknoten und Akkorde auf der Timeline anzuordnen.

Teilen Sie die Zeitleiste in Takte auf

Musik dreht sich um die Zeit, die für Rhythmus, Tempo und die Dauer der einzelnen Noten innerhalb einer Komposition von zentraler Bedeutung ist. Im Laufe der Geschichte fanden Komponisten es praktisch, die Zeitleiste in Abschnitte zu unterteilen, die als Takte oder Takte bekannt sind und normalerweise die gleiche Anzahl von Schlägen enthalten.

Sie können sich den Beat als die grundlegende Zeiteinheit einer Musikkomposition vorstellen. Es ist ein gleichmäßiger Puls, der den Rhythmus bestimmt. Der Takt bleibt in der Regel während eines Liedes konstant und Sie können ihn intuitiv erkennen, indem Sie mit den Füßen darauf klopfen oder in die Hände klatschen. Musiker zählen manchmal absichtlich Schläge laut oder im Kopf, um das Timing ihres Auftritts einzuhalten.

Jedem Takt ist eine Taktart zugeordnet, die aus zwei vertikal gestapelten Zahlen besteht. Die obere Zahl gibt an, wie viele Schläge der Takt enthält, und die untere Zahl gibt einen Bruchteil der Note an, der die Länge eines Schlags relativ zur gesamten Note darstellt. Beispielsweise gibt es in einem ⁴⁄₄-Takt (4 × ¼) vier Beats pro Takt und die Beat-Dauer entspricht einer Viertelnote oder ¼- der ganzen Note.

Aus historischen Gründen und aus Bequemlichkeit für den Interpreten ist der Notenwert in einer Taktart fast immer eine Zweierpotenz, was eine einfache Unterteilung der Taktschläge ermöglicht. Wenn Sie eine Note zwischen den Hauptschlägen Ihres Takts und nicht auf dem Taktschlag spielen möchten, können Sie die Auflösung erhöhen, indem Sie kleinere Notenwerte verwenden. Sie müssen jedoch einer binären Reihe folgen:

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

In der Praxis werden Noten, die kürzer als ein Sechzehntel sind, selten verwendet. Sie können auch einige der Standardnotenwerte kombinieren, um noch komplexere punktierte Noten zu bilden. Zum Beispiel ergibt ¼ + ⅛ + ¹⁄₁₆ ⁷⁄₁₆, was Ihnen beim Erstellen komplexer Rhythmen helfen kann.

Wenn Sie Ihre Notizen mit relativen Mengen statt mit absoluten Werten darstellen, können Sie mühelos das Tempo Ihrer gesamten Komposition steuern. Wenn Sie die gegenseitigen Beziehungen zwischen den einzelnen Noten kennen, können Sie die Spieldauer ermitteln.

Angenommen, Sie haben gemeinsam ein Musikstück. Wenn Sie das Tempo auf 75 Schläge pro Minute (BPM) einstellen, dauert jeder Schlag, der in dieser Taktart zufällig eine Viertelnote ist, 0,8 Sekunden. Vier Schläge, die einen einzelnen Takt bilden, dauern 3,2 Sekunden. Sie können dies mit der Gesamtzahl der Takte in der Komposition multiplizieren, um die Dauer zu ermitteln.

Um die Dauer von Bruchnoten in Sekunden genau darzustellen, implementieren Sie noch eine weitere spezielle Methode in Ihrer Time-Klasse:

# ...

@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__}'")

    # ...

# ...

Mit der Methode .__mul__() können Sie den Multiplikationsoperator (*) in Ihrer Klasse überladen. In diesem Fall gibt die Multiplikation einer Time-Instanz mit einem numerischen Wert ein neues Time-Objekt mit den aktualisierten Dezimalsekunden zurück.

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

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

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

Beide Methoden kehren die Reihenfolge der Operanden um. Sie ermöglichen Ihnen die Verwendung der überladenen Operatoren unabhängig davon, ob sich die Time-Instanz auf der linken oder rechten Seite des Operators befindet.

Dank der Unterstützung des Datentyps Fraction in Ihrer Multiplikationsmethode können Sie die Dauer von Musiknoten und Takten elegant ausdrücken:

>>> 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'))

Dieser Codeausschnitt zeigt, wie Sie die Dauer verschiedener Musiknoten in Sekunden präzise berechnen können. Es beginnt mit der Angabe des Tempos (75 BPM) und der Taktart ⁴⁄₄. Mithilfe dieser Informationen können Sie die Dauer eines einzelnen Schlags und eines Takts in Sekunden ermitteln. Basierend auf der Länge des Schlags und dem Notenwert leiten Sie dann die Dauer der gesamten Note und ihrer Bruchteile ab.

Ihre vorhandene Timeline-Klasse versteht nur Sekunden, wenn es darum geht, den Zeitverlauf zu verfolgen. Im nächsten Abschnitt erweitern Sie es, um auch musikalische Takte zu unterstützen, zu denen Sie schnell springen können.

Implementieren Sie eine Zeitleiste zur Maßnahmenverfolgung

Wenn Sie Musiknoten lesen, beispielsweise Gitarrentabulaturen, müssen Sie die Noten auf einer Zeitleiste mit relativen Versätzen innerhalb des aktuellen Takts anordnen, um genaues Timing und Rhythmus sicherzustellen. Sie haben gesehen, wie Sie die Dauer der Notiz bestimmen und sie auf einer Zeitleiste platzieren können. Sie haben jedoch keine Möglichkeit, die Taktgrenzen zu finden und zum nächsten Takt zu gelangen, wenn der aktuelle Takt noch nicht vollständig ausgefüllt ist.

Fahren Sie fort und definieren Sie eine weitere veränderbare Datenklasse, die Ihre Timeline-Basisklasse um zwei zusätzliche Felder erweitert: .measure und .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)

Sobald Sie von einer anderen Datenklasse erben, die mindestens ein Feld mit einem Standardwert hat, müssen Sie auch in Ihrer Unterklasse Standardwerte deklarieren. Das liegt daran, dass Nicht-Standardfelder nicht auf Standardfelder folgen können, selbst wenn sie in der Oberklasse definiert sind. Um die syntaktischen Anforderungen zu erfüllen, geben Sie also null Sekunden als Standardwert für das Feld .measure an, auch wenn Sie normalerweise während der Objekterstellung Ihren eigenen Wert angeben.

Während das erste Attribut die Dauer des aktuellen Takts angibt, verfolgt das zweite Attribut, wann der letzte Takt endete. Da sein Wert von den Feldern .instant und .measure der Zeitleiste abhängt, müssen Sie ihn manuell in .__post_init__() initialisieren:

# ...

@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)

Wenn die Taktgröße angegeben wurde und die aktuelle Position auf der Zeitachse größer als null Sekunden ist, berechnen Sie die Anzahl der verstrichenen vollständigen Takte und legen .last_measure_ended_at entsprechend fest. Andernfalls belassen Sie den Standardwert von null Sekunden.

Sie können weiterhin wie zuvor den bitweisen Rechtsverschiebungsoperator (>>) verwenden, um das Attribut .instant der Zeitleiste voranzutreiben. Sie möchten aber auch jederzeit zum nächsten Takt springen, auch wenn Sie sich noch mitten in einem anderen Takt befinden. Dazu können Sie die Methode .__next__() wie folgt in Ihrer Klasse implementieren:

 # ...

@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

Bevor Sie versuchen, die anderen Felder zu aktualisieren, stellen Sie sicher, dass die Dauer des aktuellen Takts in Sekunden positiv ist. Wenn dies der Fall ist, fügen Sie die Dauer zum Attribut .last_measure_ended_at hinzu und markieren so das Ende des aktuellen Takts. Anschließend setzen Sie das Attribut .instant der Zeitleiste auf diesen neuen Wert, um zum Beginn des nächsten Takts zu gelangen. Schließlich geben Sie Ihr MeasuredTimeline-Objekt zurück, um die Verkettung von Methoden und Operatoren zu ermöglichen.

Nachdem Sie eine Instanz der Klasse mit einer Maßgröße ungleich Null erstellt haben, können Sie mit dem Springen zwischen den Maßen beginnen:

>>> 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'))

Sofern Sie nichts anderes angeben, beginnt das Objekt MeasuredTimeline bei null Sekunden, genau wie die reguläre Zeitleiste. Sie können den bitweisen Rechtsverschiebungsoperator wie gewohnt verwenden. Darüber hinaus können Sie durch Aufrufen der integrierten Funktion next() den verbleibenden Teil des aktuellen Takts überspringen und zum Anfang des nächsten Takts wechseln. Wenn Sie sich entscheiden, die Größe der Kennzahl zu ändern, wird dies in nachfolgenden Aufrufen von next() widergespiegelt.

Nachdem Sie nun mit musikalischen Takten, Taktschlägen und Bruchnoten vertraut sind, können Sie eine Komposition basierend auf einer echten Gitarrentabulatur synthetisieren.

Erfahren Sie, wie man Gitarrentabulatur liest

Wie bereits erwähnt, handelt es sich bei der Gitarrentabulatur, oft auch als Gitarrentabulatur abgekürzt, um eine vereinfachte Form der Notenschrift, die sich an Anfänger und Hobbyspieler richtet, die sich mit traditionellen Noten möglicherweise nicht so wohl fühlen. Gleichzeitig scheuen professionelle Musiker nicht davor zurück, Gitarrentabulaturen zu verwenden, da sie praktischer für den Unterricht und den Austausch von Ideen sind.

Da diese Notation speziell für Streichinstrumente entwickelt wurde, enthält eine Gitarrentabulatur horizontale Linien, die die Saiten darstellen, mit Zahlen darüber, die angeben, welche Bünde gedrückt werden müssen. Abhängig von der Art des Instruments kann die Anzahl der Linien variieren, bei einer typischen Gitarre sind es jedoch sechs Linien.

Die Reihenfolge der Gitarrensaiten in einer Registerkarte ist nicht standardisiert. Achten Sie daher immer auf Beschriftungen, die die Saitennummern oder Buchstaben angeben, die ihrer Stimmung entsprechen.

Sie können online nach kostenlosen Gitarren-Tabs suchen. Wie zu Beginn dieses Tutorials erwähnt, ist Songsterr eine von der Community betriebene Website, die über eine Million Tabs hostet. Wahrscheinlich finden Sie dort die Tabs für Ihre Lieblingsmusik. Als Teil dieses Beispiels erstellen Sie den ikonischen Soundtrack aus dem Spiel Diablo neu.

Werfen Sie jetzt einen Blick auf das Tristram-Thema des Spiels von Matt Uelmen auf Songsterr. Der Screenshot unten zeigt die ersten vier Takte und kommentiert gleichzeitig die wichtigsten Elemente der Gitarrentabulatur:

Die obige Tabulatur beginnt mit Saitenbezeichnungen, die der Standardstimmung der Gitarre, dem ⁴⁄₄ Takt und einem Tempo von 75 Schlägen pro Minute entsprechen >. Jeder Takt ist nummeriert und durch eine vertikale Linie von seinen Nachbarn getrennt, um Ihnen die Orientierung beim Durchlesen der Noten zu erleichtern.

Die fett gedruckten Zahlen, die auf den horizontalen Linien erscheinen, geben die Bünde an, die Sie auf die entsprechenden Saiten drücken müssen, um den gewünschten Akkord zu erklingen. Schließlich stellen die Symbole unter jedem Takt die gebrochene Dauer von Noten und Pausen (Pausen) im Verhältnis zur gesamten Note dar.

Basierend auf diesem Wissen können Sie die bereitgestellte Tabulatur interpretieren und ihr mit Hilfe des von Ihnen implementierten Gitarrensynthesizers Leben einhauchen. Zunächst codieren Sie die Diablo-Gitarrentabulatur mithilfe eines programmatischen Ansatzes in einem Python-Skript fest.

Spielen Sie die Diablo-Tabulatur programmatisch

Erstellen Sie ein neues Skript namens play_diablo.py im Ordner demo/ mit folgendem Inhalt:

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)

Die hervorgehobenen Konstanten stellen die einzigen Eingabeparameter dar, die Sie ändern können, während die übrigen Werte von diesen Eingaben abgeleitet werden. Hier gruppieren Sie logisch zusammengehörige Werte unter gemeinsamen Namensräumen, indem Sie sie als Klassenattribute definieren. Die jeweiligen Klassennamen verraten Ihnen ihren Zweck.

Definieren Sie als Nächstes Ihre virtuelle Gitarre, schließen Sie sie an den Synthesizer an und bereiten Sie die Audiospur zusammen mit der Zeitleiste vor, die die Tabulaturtakte berücksichtigt:

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()

Sie verwenden das Akustikgitarrenobjekt aus den vorherigen Abschnitten wieder, das die Standardstimmung anwendet, und definieren eine Hilfsfunktion, um das resultierende Audio in einer Datei zu speichern.

Was Sie auf die Zeitleiste setzen, sind synthetisierte Klänge, die durch den aktuellen Zeitpunkt, die zu drückenden Bundnummern und die Anschlagsgeschwindigkeit beschrieben werden und die Sie als unveränderliche Datenklasse modellieren können:

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

# ...

Objekte der Klasse Stroke repräsentieren genau das, was Sie auf der von Songsterr bereitgestellten Gitarrentabulatur sehen. Sie können jetzt jeden Takt in eine Folge von Strichen übersetzen, um ihn in einer Schleife zu durchlaufen:

# ...

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),
    )

# ...

Zuerst durchlaufen Sie eine Folge von Kennzahlen, die von Ihrer Funktion measures() zurückgegeben werden, die Sie mit der Zeitleiste als Argument aufrufen. Anschließend durchlaufen Sie jeden Strich innerhalb des aktuellen Takts, synthetisieren den entsprechenden Akkord und fügen ihn im richtigen Moment der Spur hinzu.

Ihr Gitarrentabulatur enthält derzeit zwei Takte, die jeweils in einer separaten Funktion berechnet werden, die Sie jetzt definieren können:

# ...

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),
        ),
    )

# ...

Die komplette Tabulatur der Diablo-Gitarre umfasst achtundsiebzig Takte mit insgesamt über tausend Takten. Der Kürze halber zeigt der obige Codeausschnitt nur die ersten beiden Takte, die ausreichen sollten, um das berühmte Thema zu erkennen. Während es für das Beispiel ausreicht, können Sie die nachfolgenden Maßnahmen auch auf der Registerkarte „Songsterr“ umsetzen.

Alternativ können Sie den endgültigen Quellcode der restlichen Funktionen aus den Bonusmaterialien kopieren. Um sie zu erhalten, klicken Sie auf den folgenden Link:

Beachten Sie, dass das vollständige play_diablo.py-Skript mehrere tausend Zeilen Python-Code enthält! Daher wäre es für Sie möglicherweise bequemer, vorerst weiter an diesem minimal brauchbaren Prototypen zu arbeiten.

Beachten Sie, dass jeder Strich, mit Ausnahme des allerersten, die Zeitleiste um einen Bruchteil der gesamten Note verschiebt, um die Dauer des vorherigen Akkords widerzuspiegeln. Dadurch wird der richtige Abstand zwischen benachbarten Gurten gewährleistet. Darüber hinaus verschiebt die hervorgehobene Linie die Zeitleiste an den Anfang des nächsten Takts in der Registerkarte.

Insgesamt gibt es sechs einzigartige Bruchnoten, die Sie für den Diablo-Soundtrack benötigen. Wenn Sie die Dauer der gesamten Note in Sekunden kennen, können Sie schnell auf die Dauer der übrigen Noten schließen:

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 ¹⁄₃₂

Unter der Annahme, dass die gesamte Note die gleiche Dauer wie der gesamte Takt hat, also 3,2 Sekunden, beträgt die Dauer einer 30-Sekunden-Note 0,1 Sekunden und so weiter. Durch die Aufteilung der gesamten Notendauer können Sie den Rhythmus des Soundtracks mit großer Genauigkeit zusammensetzen.

Wäre es nicht großartig, einen universellen Player zu entwickeln, der jede Gitarrentabulatur lesen und synthetisieren kann, anstatt nur diese bestimmte? Irgendwann werden Sie es schaffen, aber vorher werden Sie die Synthese verfeinern, damit sie noch authentischer klingt.

Schritt 6: Wenden Sie Spezialeffekte für mehr Realismus an

Zu diesem Zeitpunkt simuliert Ihr Gitarrensynthesizer ein echtes Instrument ziemlich gut, klingt aber immer noch etwas rau und künstlich. Es gibt viele Möglichkeiten, die Klangfarbe der virtuellen Gitarre zu verbessern. In diesem Abschnitt beschränken Sie sich jedoch auf die Spezialeffekte, die die Pedalboard-Bibliothek bietet. Damit können Sie mehrere Effekte miteinander verketten, genau wie bei einem echten Gitarrenpedal, das mit dem Fuß bedient wird.

Verstärken Sie den Bass und fügen Sie einen Nachhalleffekt hinzu

Eine echte Gitarre verfügt über einen Resonanzkörper, der in den tieferen Frequenzen einen satten und lebendigen Klang erzeugt. Um dies in Ihrer virtuellen Gitarre nachzuahmen und den Bass zu verstärken, können Sie einen Audio-Equalizer (EQ) verwenden. Darüber hinaus simulieren Sie durch Hinzufügen eines Nachhalleffekts das natürliche Echo und Abklingen, das in einem physischen Raum auftritt, und verleihen dem Klang mehr Tiefe und Realismus.

Während Pedalboard keinen speziellen Equalizer enthält, können Sie verschiedene Audio-Plugins kombinieren, um den gewünschten Effekt zu erzielen. Ändern Sie Ihr play_diablo.py-Skript, indem Sie einen Hall, einen Low-Shelf-Filter und eine Verstärkung auf die synthetisierte Audiospur anwenden:

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()

Zunächst importiert man die entsprechenden Plugins aus der Bibliothek und baut daraus ein virtuelles Pedalboard. Sobald es zusammengestellt ist, rufen Sie es auf der Audiospur auf und normalisieren die resultierenden Samples, bevor Sie sie in einer Datei speichern.

Der Reverb basiert auf den Standardeinstellungen, während der Low-Shelf-Filter auf eine Grenzfrequenz von 440 Hz, eine Verstärkung von 10 dB und einen Q-Faktor von 1 eingestellt ist. Der Gain ist so eingestellt, dass er die Lautstärke um 6 dB erhöht. Sie können mit verschiedenen Parameterwerten experimentieren, um den Klang Ihren Wünschen anzupassen oder besser zu einem bestimmten Musikgenre zu passen.

Wenn Sie das Skript erneut ausführen und die resultierende Audiodatei abspielen, sollten Sie einen natürlicheren Klang hören. Ihre digitale Gitarre ähnelt allmählich dem Klang einer Akustikgitarre. Es gibt jedoch einen bestimmten Effekt, der einen echten Unterschied machen kann, und den Sie jetzt erkunden werden.

Wenden Sie einen Faltungshallfilter mit einem IR an

Die Idee hinter einem Faltungshall besteht darin, den Nachhall eines physischen Raums durch einen Filter zu simulieren, der eine Impulsantwort (IR) verwendet. Eine Impulsantwort ist eine Aufzeichnung der akustischen Eigenschaften eines realen Ortes, beispielsweise eines Konzertsaals, einer Kirche oder eines kleinen Raums. Normalerweise handelt es sich dabei um einen kurzen Ton wie ein Klatschen oder das Platzen eines Ballons, der einfängt, wie ein Raum auf das gesamte Frequenzspektrum reagiert.

Mit dieser speziellen Art von Hall können Sie beispielsweise Gesang im Studio aufnehmen und in der Postproduktion die Atmosphäre einer großen Kathedrale anwenden. Sie werden den Eindruck gewinnen, dass die Aufführung tatsächlich genau an diesem Ort aufgezeichnet wurde. In der Open AIR-Bibliothek finden Sie eine Sammlung hochwertiger Impulsantworten aus verschiedenen Orten auf der ganzen Welt. Sie können die Vorher- und Nachher-Versionen anhören und vergleichen. Der Unterschied ist bemerkenswert!

Im Zusammenhang mit Gitarren können Impulsantworten dabei helfen, den Klang verschiedener Gitarrenverstärker zu emulieren oder sogar den Klang bestimmter Instrumente wie eines Banjos oder einer Ukulele zu modellieren. Der Filter faltet Ihr unverarbeitetes oder trockenes Signal mit der Impulsantwort und prägt so effektiv die akustischen Eigenschaften des Originalinstruments in das Audio ein. Dadurch entsteht ein äußerst realistischer Effekt, der Ihrer digitalen Gitarre Tiefe und Charakter verleiht.

Öffnen Sie das Skript play_diablo.py erneut und fügen Sie einen Faltungsfilter mit einem Pfad zur Impulsantwortdatei einer Akustikgitarre ein:

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()

Es gibt viele kostenlose Impulsantworten für Gitarren online. Es kann jedoch eine kleine Herausforderung sein, eines von guter Qualität zu finden. Die in diesem Tutorial verwendeten Impulsantwortdateien stammen aus folgenden Quellen:

Acoustic Guitar

Tay816 M251 SB1.wav

Bass Guitar

Holy Grail 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

Über die Links oben gelangen Sie zu den entsprechenden Musterpaketen. Alternativ können Sie die unterstützenden Materialien zu diesem Tutorial herunterladen, die bequem benannte einzelne Impulsantwortdateien enthalten. Sobald Sie diese Dateien heruntergeladen haben, platzieren Sie sie im Unterordner ir/, in dem Sie Ihre Demo-Skripte aufbewahren:

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

Sie können jetzt Ihr anderes Skript, play_chorus.py, aktualisieren, indem Sie ähnliche Effekte anwenden und die entsprechende Impulsantwort verwenden, um den synthetisierten Klang zu verbessern:

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))

# ...

Auch hier können Sie mit diesen Parametern herumspielen oder sogar verschiedene Plugins aus der Pedalboard-Bibliothek ausprobieren.

Okay. Bisher haben Sie eine Akustikgitarre und eine Ukulele modelliert. Wie wäre es dieses Mal mit dem Spielen einer E-Gitarre oder einer Bassgitarre? Wie Sie gleich sehen werden, läuft die Simulation dieser Instrumente hauptsächlich darauf hinaus, die richtigen Effekte aus der Plugin-Bibliothek auszuwählen und die Saitenstimmung und Vibrationszeit zu optimieren. Um Codeduplizierungen und -wiederholungen zu vermeiden, bewahren Sie die Akkorde von nun an in einer separaten Datei auf.

Schritt 7: Gitarrentabulatur aus einer Datei laden

Es gibt zahlreiche Datenformate für Gitarrentabulaturen, die von einfachen ASCII-Tabulatorformaten bis hin zu komplexeren Binärformaten wie Power Tab oder Guitar Pro reichen. Einige von ihnen erfordern zum Lesen spezielle oder proprietäre Software. In diesem Abschnitt entwerfen Sie Ihr eigenes Dateiformat, um die wichtigsten Funktionen der auf der Songsterr-Website gehosteten Tabs darzustellen. Am Ende runden Sie das Ganze mit einem speziellen Tabulatur-Player ab, damit Sie die Musik hören können!

Entwerfen Sie das Dateiformat für Ihre Gitarrentabulaturen

Machen Sie vor dem Schreiben einer einzelnen Codezeile einen Schritt zurück und überlegen Sie, wie Sie Ihr neues Gitarrentabulaturformat verwenden möchten. Welche Art von Informationen soll die Registerkarte insbesondere enthalten und wie wollen Sie diese präsentieren?

Nachfolgend finden Sie die empfohlenen Designziele für Ihr benutzerdefiniertes Format:

  • Für Menschen lesbar: Das Format muss für Menschen lesbar sein, damit Sie die Tabs in einem Nur-Text-Editor bearbeiten können.
  • Intuitiv: Sie möchten, dass das Format eine vertraute Syntax und eine sanfte Lernkurve aufweist, damit Sie sich so schnell wie möglich zu Hause fühlen.
  • Prägnant: Die meisten Songs wiederholen durchgehend dieselben Akkorde und Muster, daher sollte das Format sie effizient darstellen, um unnötige Ausführlichkeit zu vermeiden.
  • Hierarchisch: Das Format sollte eine hierarchische Struktur haben, die eine bequeme Deserialisierung in ein Python-Wörterbuch ermöglicht.
  • Mehrspurig: Eine einzelne Tab-Datei sollte es Ihnen ermöglichen, eine oder mehrere Spuren zu speichern, die virtuellen Instrumenten entsprechen, und sie in verschiedenen Proportionen zu mischen.

Wenn Sie diese Anforderungen berücksichtigen, erweisen sich XML, JSON und YAML als die besten Kandidaten für das zugrunde liegende Datenformat, auf dem Sie aufbauen können. Alle sind textbasiert, allgemein bekannt und haben eine hierarchische Struktur, sodass Sie mehrere Titel darin einfügen können. Allerdings erfüllt nur YAML alle Anforderungen, da sich Wiederholungen bei den anderen beiden Formaten nicht ohne weiteres vermeiden lassen.

YAML ist auch eine gute Wahl, da es Anker und Aliase unterstützt, mit denen Sie wiederholte Elemente wiederverwenden können, ohne sie neu schreiben zu müssen. Das kann Ihnen eine Menge Tipparbeit ersparen, insbesondere im Zusammenhang mit Gitarrentabulaturen!

Schauen Sie sich unten einen Auszug einer fiktiven Gitarrentabulatur an, die einige der Funktionen Ihres Formats demonstriert:

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:
    # ...

Viele der Attribute sind völlig optional und die meisten verfügen über sinnvolle Standardwerte, darunter:

  • Gewicht: Die Titel in Ihrem Tab werden mit einem Gewicht von eins gemischt, es sei denn, Sie wünschen ausdrücklich ein anderes Gewicht.
  • Dämpfung: Wenn Sie die Dämpfung des Instruments nicht angeben, wird standardmäßig 0,5 verwendet, was einem einfachen Durchschnitt entspricht.
  • Notizen: Sie können die Noten überspringen, um einen leeren Takt anzuzeigen. Dies ist manchmal sinnvoll, wenn Sie mehrere Instrumente synchronisieren möchten.
  • Offset: Wenn Sie keinen Offset angeben, wird die entsprechende Note oder der entsprechende Akkord an der aktuellen Position auf der Timeline platziert. Normalerweise lassen Sie den Versatz der ersten Note in einem Takt weg, es sei denn, er tritt nicht auf dem Taktschlag auf.
  • Aufschlag: Die meisten Anschläge sind nach unten gerichtet, daher müssen Sie dieses Attribut nur dann festlegen, wenn der Akkord nach oben angeschlagen werden soll.
  • Arpeggio: Die Geschwindigkeit des Anschlags bzw. die Verzögerung zwischen den einzelnen Anschlägen in einem Akkord beträgt standardmäßig fünf Millisekunden, was ziemlich schnell ist.
  • Vibration: Sie müssen die Vibration der Note immer nur dann einstellen, wenn Sie die im jeweiligen Instrument definierte Standardvibration der Saite überschreiben möchten.

Die optionalen Effekte eines Instruments stellen Pedalboard-Plugins dar. Sie können sie in einer bestimmten Reihenfolge aneinanderreihen, um das gewünschte Ergebnis zu erzielen, oder Sie können sie ganz überspringen. Jeder Effekt muss entweder der Klassenname eines Plugins oder eine Zuordnung des Klassennamens zu den entsprechenden Konstruktorargumenten sein. Weitere Informationen zur Konfiguration dieser Effekte finden Sie in der Dokumentation von Pedalboard.

Jeder Titel hat seine eigene Tabulatur, die aus dem Tempo – ausgedrückt als Anzahl der Beats pro Sekunde – und einer Liste von Takten besteht. Jeder Takt wiederum liefert eine Taktart und eine Liste von Noten oder Akkorden. Eine einzelne Note muss mindestens die zu drückenden Bundnummern definieren, da die übrigen Attribute optional sind. Allerdings geben die meisten Noteninstanzen den Versatz auch als Bruchteil der gesamten Note an.

Anker und Aliase sind zwei der mächtigsten Funktionen von YAML. Sie ermöglichen es Ihnen, einen Wert einmal zu definieren und ihn an eine globale Variable im Dokument zu binden. Variablennamen müssen mit dem kaufmännischen Und-Zeichen (&) beginnen, und Sie können auf sie verweisen, indem Sie anstelle des kaufmännischen Und-Zeichens das Sternchen (*) verwenden. Wenn Sie C-Programmierung durchgeführt haben, ist dies analog zum Erfassen der Adresse einer Variablen und zum Dereferenzieren eines Zeigers.

Im obigen Beispiel deklarieren Sie zwei globale Variablen oder YAML-Anker:

  1. &seven: Stellt die Bundnummern dar, die sich im gesamten Takt wiederholen
  2. &loop: Erfasst den Takt selbst, sodass Sie dieselbe Schleife mehrmals in der Komposition verwenden können

Dies spart nicht nur Platz und Tipparbeit, sondern macht das Dokument auch wartungsfreundlicher. Wenn Sie die Reihenfolge ändern möchten, müssen Sie sie nur an einer Stelle aktualisieren und die Änderung wird überall dort angezeigt, wo Sie den Alias verwendet haben.

Nachdem Sie ein Beispiel für eine Gitarrentabulatur in Ihrem YAML-basierten Dateiformat gesehen haben, können Sie es jetzt in Python laden. Dies erreichen Sie mithilfe der Pydantic-Bibliothek.

Definieren Sie Pydantic-Modelle zum Laden aus YAML

Erstellen Sie neben digitar, das Sie zuvor erstellt haben, ein Geschwisterpaket mit dem Namen tablature. Wenn Sie dies tun, sollten Sie die folgende Ordnerstruktur erhalten:

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

Erstellen Sie nun ein Python-Modul mit dem Namen models und platzieren Sie es im neuen Paket. Dieses Modul enthält die Pydantic-Modellklassen für Ihr YAML-basiertes Datenformat. Beginnen Sie mit der Modellierung des Stammelements des Dokuments, das Sie Song nennen:

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))

Das Stammelement des Dokuments verfügt über zwei optionale Attribute, .title und .artist, sowie ein obligatorisches .tracks-Wörterbuch. Letzteres ordnet beliebige Track-Namen Track-Instanzen zu, die Sie gleich implementieren werden. Die Klasse bietet außerdem eine Methode zum Laden von YAML-Dokumenten aus einer Datei, die entweder durch eine Zeichenfolge oder eine Path-Instanz angegeben wird, und zum Deserialisieren dieser Dokumente in das Modellobjekt.

Da Python Ihren Quellcode von oben nach unten liest, müssen Sie den Track vor Ihrem Song-Modell definieren, das davon abhängt:

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):
    # ...

Eine Track-Instanz besteht aus einem Paar optionaler Attribute, .url und .weight, und einem Paar erforderlicher Attribute, .instrument und .tabulature. Die Gewichtung stellt die relative Lautstärke des Tracks im endgültigen Mix dar. Die dekorierte Methode .check_frets() überprüft, ob die Anzahl der Bünde in jedem Takt mit der Anzahl der Saiten im Instrument übereinstimmt.

Das Instrument-Modell spiegelt Ihren digitar.PluckedStringInstrument-Typ wider und erweitert ihn um eine Kette von Pedalboard-Plugins:

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):
    # ...

Das Attribut .tuning ist eine Liste von mindestens einem Element, das auf den String-Datentyp beschränkt ist und dem regulären Ausdruck einer Musiknote in der wissenschaftlichen Tonhöhennotation entspricht. Die .vibration gibt an, wie lange in Sekunden die Saiten des Instruments standardmäßig vibrieren sollen. Sie können diesen Wert pro Strich bei Bedarf überschreiben. Der .damping ist ein Gleitkommawert, der auf das angegebene Intervall beschränkt ist und standardmäßig einen in einer Konstanten gespeicherten Wert verwendet.

Ihr nächstes Modell, Tabulatur, besteht nur aus zwei Attributen:

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):
    # ...

Sowohl .beats_per_minute als auch .measures sind obligatorisch. Das erste Attribut ist eine positive Ganzzahl, die das Tempo des Songs in Beats pro Minute angibt. Das zweite Attribut ist ein Tupel mit einem oder mehreren Measure-Objekten, die Sie jetzt implementieren können:

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):
    # ...

Jede Maßzahl darf ihre eigene .time_signature mit einer Bruchschreibweise angeben, z. B. 4/4. Das Tupel .notes ist optional, da eine Kennzahl leer sein kann. Die beiden zwischengespeicherten Eigenschaften extrahieren die Anzahl der Schläge innerhalb eines Takts und den Notenwert aus der Taktart.

Schließlich können Sie Ihr letztes Modell aufschreiben, das eine Note oder einen Akkord darstellt, die Sie auf der virtuellen Gitarre spielen möchten:

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):
    # ...

Dieses Modell verfügt nur über ein erforderliches Attribut, .frets, bei dem es sich um eine Liste von Bundnummern handelt, die entweder auf None oder nicht negative Ganzzahlelemente beschränkt sind. Der .offset einer Notiz muss als Bruchteil der gesamten Notiz angegeben werden, z. B. 1/8. Andernfalls ist der Standardwert Null. Zu den übrigen Attributen gehören .upstroke, .arpeggio und .vibration, die beschreiben, wie der Schlag gespielt wird.

Mit diesen Modellen können Sie die in den Begleitmaterialien bereitgestellten Beispiele für Gitarrentabulaturen laden. Eine der enthaltenen YAML-Dateien basiert beispielsweise auf einem Songsterr-Tab für „Foggy Mountain Breakdown“ von Earl Scruggs mit einem Banjo, einer Akustikgitarre und einer Bassgitarre:

>>> 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
    )
)

Sie lesen eine YAML-Datei mit der Gitarrentabulatur und deserialisieren sie in eine Hierarchie von Pydantic-Modellen. Anschließend greifen Sie auf die mit der Banjo-Tabulatur verknüpfte Spur zu und zeigen die Noten im letzten Takt an.

Als Nächstes bauen Sie einen Player, der diese Modelle aufnimmt, sie in Ihre digitale Gitarrendomäne übersetzt und eine synthetisierte Audiodatei ausspuckt. Sind Sie bereit für die Herausforderung?

Implementieren Sie den Gitarren-Tabulatur-Reader

Definieren Sie einen Skriptabschnitt in Ihrer Datei pyproject.toml mit einem Einstiegspunkt zu Ihrem Python-Projekt, das Sie später über die Befehlszeile ausführen:

# ...

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

Dies definiert den Befehl play-tab, der auf ein neues Modul namens player im Paket tabulature verweist. Sie können dieses Modul jetzt aufbauen, indem Sie die folgenden wenigen Funktionen darin implementieren:

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)

Die Funktion main() ist das, was Poetry aufruft, wenn Sie poetry run play-tab in Ihrem Terminal aufrufen. Diese Funktion analysiert Befehlszeilenargumente mit argparse und übergibt sie an die Funktion play(), die einen Song aus der angegebenen YAML-Datei über Ihr Pydantic-Modell lädt.

Sie müssen den Pfad zur Gitarrentabulatur als Positionsargument angeben und können optional den Pfad zur Ausgabe-Audiodatei angeben. Wenn Sie dies nicht tun, teilt die resultierende Datei ihren Basisnamen mit Ihrer Eingabedatei.

Sobald Sie die Registerkarte in Python geladen haben, können Sie sie interpretieren, indem Sie die einzelnen Spuren synthetisieren:

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)

Sie verwenden ein Listenverständnis, um jeden Track zu synthetisieren und das resultierende NumPy-Array von Samples mit der Gewichtung des Tracks zu multiplizieren.

Die Funktion synthesize() erstellt ein Synthesizer-Objekt basierend auf der Instrumentendefinition in der Spur. Anschließend liest es die entsprechende Tabulatur vor und platziert Notizen auf der Zeitleiste. Schließlich werden Spezialeffekte mit Pedalboard angewendet, bevor die Audiobeispiele an den Anrufer zurückgegeben werden.

Die Funktion read() automatisiert die manuellen Schritte, die Sie zuvor ausgeführt haben, als Sie den Diablo-Tab programmgesteuert gespielt haben:

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)

Zunächst wird die Beat-Dauer in Sekunden ermittelt. Darauf aufbauend berechnet die Funktion die Dauer des aktuellen Takts und der gesamten Note im Tab. Als nächstes durchläuft es jede Note im Takt, synthetisiert den entsprechenden Akkord und fügt ihn zur berechneten Zeit der Audiospur hinzu. Nach jeder Iteration ruft die Funktion next() auf der Zeitleiste auf, um zum nächsten Takt vorzurücken.

Die folgenden drei Funktionen arbeiten zusammen, um die gewünschten Plugins aus der Pedalboard-Bibliothek basierend auf den Deklarationen in der YAML-Datei zu importieren und anzuwenden:

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)

Die erste Funktion wendet die mit einem bestimmten Instrument verbundenen Effekte auf eine Audiospur an. Es erstellt ein Pedalboard-Objekt aus den Plugins, die vom Instrument der Spur abgerufen wurden. Die letzte Funktion gibt eine Plugin-Instanz basierend auf ihrem Namen zurück und initialisiert sie optional mit den im Tabulaturdokument angegebenen Parametern.

Jetzt können Sie Ihre synthetisierten Tracks mischen und in einer Datei speichern. Dazu müssen Sie die Funktion play() ändern:

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()}")

# ...

Da die einzelnen Spuren unterschiedlich lang sein können, füllen Sie sie auf, um sicherzustellen, dass sie alle die gleiche Länge haben, bevor Sie ihre Amplituden mit np.sum() addieren und ihre Werte normalisieren. Zuletzt speichern Sie die Audiobeispiele in einer Datei, indem Sie Ihre Funktion save() aufrufen.

Um jedoch sicherzustellen, dass relative Pfade in Ihrem YAML-Dokument wie erwartet funktionieren, sollten Sie das aktuelle Arbeitsverzeichnis des Skripts vorübergehend ändern:

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)

# ...

Sie geben einen funktionsbasierten Kontextmanager an, den Sie mit der Anweisung with aufrufen, um das Arbeitsverzeichnis auf den übergeordneten Ordner der YAML-Datei festzulegen. Ohne sie wären Sie nicht in der Lage, die Impulsantwortdateien für das Faltungs-Plugin des Pedalboards zu finden und zu laden.

Okay. Nachfolgend erfahren Sie, wie Sie das Skript play-tab im Terminal verwenden können. Vergessen Sie nicht, Ihr Poetry-Projekt neu zu installieren, damit der in pyproject.toml definierte Einstiegspunkt wirksam wird:

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

Wenn Sie die Option für den Ausgabedateinamen (-o) weglassen, verwendet die resultierende Datei denselben Namen wie Ihre Eingabedatei, jedoch mit der Dateierweiterung .mp3.

So klingt die Beispieltabulatur bestehend aus drei Instrumentenspuren, wenn Sie sie durch Ihren Synthesizer laufen lassen:

Gut gemacht! Wenn Sie es bis hierher geschafft haben, dann gebührt Ihnen ein großes Lob für Ihre Entschlossenheit und Ausdauer. Hoffentlich war dies eine unterhaltsame und lohnenswerte Reise, die Ihnen geholfen hat, etwas Neues zu lernen.

Abschluss

Herzlichen Glückwunsch zum Abschluss dieses fortgeschrittenen Projekts! Sie haben den Synthesealgorithmus für gezupfte Saiten und einen Gitarrentabulaturleser erfolgreich implementiert, sodass Sie realistische Musik in Python spielen können. Und nebenbei haben Sie wichtige Einblicke in die zugrunde liegende Musiktheorie gewonnen. Vielleicht haben Sie sich sogar dazu inspiriert gefühlt, eine echte Gitarre in die Hand zu nehmen und mit dem Spielen zu beginnen. Wer weiß?

In diesem Tutorial haben Sie:

  • Implementierung des Karplus-Strong-Algorithmus zur Synthese gezupfter Saiten
  • Imitierte verschiedene Arten von Saiteninstrumenten und deren Stimmungen
  • Kombiniert mehrere vibrierende Saiten zu polyphonen Akkorden
  • Simulierte realistische Gitarrenpicking- und Strumming-Fingertechniken
  • Verwendet Impulsantworten echter Instrumente, um deren einzigartiges Timbre nachzubilden
  • Lesen Sie Musiknoten aus der wissenschaftlichen Tonhöhennotation und der Gitarrentabulatur

Den vollständigen Quellcode für dieses Projekt, einschließlich Schnappschüssen der einzelnen Schritte, Beispieltabulaturen und Impulsantwortdateien, finden Sie in den Begleitmaterialien. Um sie zu erhalten, verwenden Sie den folgenden Link: