Zum Hauptinhalt Zur Navigation

Retrosounds mit Raspberry Pi Pico: Auf dem Weg zur Pico-Konsole

Ein Mikrocontroller kann Musik fast ohne zusätzliche Hardware erzeugen. Inspiration geben alte Konsolen wie der Gameboy .
/ Johannes Hiltscher
8 Kommentare News folgen (öffnet im neuen Fenster)
Musiziert nach dem Vorbild alter Konsolen wie dem Gameboy: Der Raspberry Pi Pico. (Bild: Johannes Hiltscher/Golem.de)
Musiziert nach dem Vorbild alter Konsolen wie dem Gameboy: Der Raspberry Pi Pico. Bild: Johannes Hiltscher/Golem.de

Mal angenommen, jemand wollte eine Retro-Konsole mit dem Raspberry Pi Pico bauen. Was bräuchte er dafür? Als erstes natürlich eine Möglichkeit, ein Bild auszugeben (g+) . An zweiter Stelle kämen direkt Musik und Geräusche, sie sind für die Atmosphäre eines Spiels unverzichtbar. Ein Mikrocontroller wie der RP2040 hat ähnliche Einschränkungen wie alte Konsolen, allen voran: wenig Speicher.

Aber werfen wir erst einmal einen Blick darauf, wie Töne erzeugt werden können. An den großen Bruder Raspberry Pi oder andere Single Board Computer würde man einfach einen sogenannten Audio-Codec (für enCOder/DECoder) anschließen – wenn er nicht bereits auf der Platine sitzt. Der Kommunikationsstandard I 2 S, mit dem die meisten Codecs angesprochen werden, kann mit den programmierbaren Ein-/Ausgabe-Blöcken (Programmable IO, PIO) des RP2040 realisiert werden. In auf den SBCs genutzten SoCs ist eine solche Schnittstelle fest integriert.

Ein Audio Codec erzeugt aus digitalen, gesampelten (abgetasteten) Daten ein analoges Tonsignal. Die meisten Codecs erwarten ein Stereosignal mit 16 Bit pro Abtastpunkt und Kanal bei einer Abtastrate von 44,1 kHz oder 48 kHz. Davon passen in den 264 kByte großen Speicher des RP2040 etwa eineinhalb Sekunden. Den kleinen Speicher könnte der Mikrocontroller aber kompensieren, denn in einem anderen Punkt ist er der Hardware alter 8- und 16-Bit-Konsolen deutlich überlegen: Seine Rechenleistung ist dank zweier ARM Cortex-M0+-Kerne, die jeweils mit 133 MHz takten, deutlich höher.

Sind MP3s eine gute Idee?

Die Rechenleistung des RP2040 macht es möglich, komprimierte Musikformate wie MP3 wiederzugeben(öffnet im neuen Fenster) . So lassen sich deutlich längere Musikschnipsel in den für heutige Verhältnisse sehr kleinen Arbeitsspeicher packen. Damit könnten beliebige Musikaufnahmen ausgegeben werden, allerdings sprengen auch die schnell den auf 16 MByte beschränkten (externen) Flash-Speicher – zumindest, wenn noch Grafiken hinzu kommen. Dann würde eine SD-Karte erforderlich.

Aber es gibt eine gute Nachricht: In der 8- und 16-Bit-Ära waren 16 MByte Speicher für ein Spielmodul viel. Um Sounds zu erzeugen, haben die Konsolen einen Hardware-Synthesizer eingebaut. Mit dem lassen sich epische Soundtracks erzeugen, die lediglich wenige Kilobyte Speicher benötigen . Das bedeutet, dass sie im Flash-Speicher eines Pico-Boards abgelegt werden können, eine SD-Karte ist also nicht nötig. Zur genauen Funktion der Synthesizer und darauf, wie sie sich effizient nutzen lassen, später mehr. Zuerst einmal brauchen wir eine Möglichkeit, die Töne aus dem Mikrocontroller herauszubekommen. Das funktioniert allein mit der Hardware des RP2040 und einigen Bauteilen für wenige Cent.

Pulsweitenmodulation als Analog-Wandler

Um die im Prozessor digital abgebildeten Töne in ein Spannungssignal zu wandeln und damit einem Lautsprecher Töne zu entlocken, brauchen wir einen Digital-Analog-Wandler(öffnet im neuen Fenster) (Digital-Analog-Converter, DAC). Er ist eine der zentralen Komponenten des Audio-Codecs. Mikrocontroller haben leider meist keinen eingebaut, so auch der RP2040. Aber eine andere Komponente kann als DAC zweckentfremdet werden: Ein Funktionsblock zur Pulsweitenmodulation (PWM)(öffnet im neuen Fenster) – und davon hat der RP2040 mit acht Stück mehr als genug. Bei allen Angaben zur Hardware beziehe ich mich auf das Datenblatt des RP2040 (PDF)(öffnet im neuen Fenster) , der gesamte Code zum Artikel(öffnet im neuen Fenster) findet sich in einem Git-Repository.

Betrachten wir aber zuerst einmal, wie die PWM zum DAC umfunktioniert werden kann und wie wir das Ganze dann mit Daten versorgen.

Analogwandlung mit PWM

Auch wer nicht mit Mikrocontrollern bastelt, hat mit hoher Wahrscheinlichkeit schon einmal von PWM gehört: Sie ist die gängigste Variante, um die Helligkeit von LEDs zu steuern. Damit kommt sie nicht nur in dimmbaren LED-Leuchten zum Einsatz, sondern auch, um die Helligkeit von Bildschirmen zu regulieren.

Die Funktionsweise der PWM(öffnet im neuen Fenster) ist ziemlich simpel und schnell erklärt: Anhand eines Taktsignals wird ein Zählerregister mit jedem Takt um eins erhöht, bis es einen vorgegebenen Maximalwert erreicht. Dann wird das Register auf 0 zurückgesetzt und der Vorgang beginnt erneut. Damit werden Pulse mit fester Frequenz erzeugt. Mittels eines Vergleichswerts wird das Verhältnis von 0- zu 1-Pegel während der Pulsperiode eingestellt.

Üblicherweise ist das so realisiert, dass der Ausgang der PWM-Einheit auf 1-Pegel liegt, solange der Wert des Zählerregisters kleiner als oder gleich dem Vergleichswert ist. Andernfalls liegt am Ausgang der 0-Pegel an und dieser Ausgang kann auf einen Ausgabe-Pin des Mikrocontrollers gelegt werden. Damit wird ein Rechtecksignal mit variablem sogenanntem Duty Cycle (auf Deutsch Tastgrad ) erzeugt.

Was ist ein Rechtecksignal?

Wie jedes andere Signal kann ein Rechtecksignal als Überlagerung von Sinusschwingungen dargestellt werden. Dazu kommt ein Gleichspannungsanteil, der dem Mittelwert der Spannung während einer Periode entspricht – also der Maximalspannung multipliziert mit dem Tastgrad. In der Animation unten findet er sich auf der Y-Achse, also bei 0 Hertz (Hz).

Die Sinuskomponenten treten bei einem Vielfachen der Pulsfrequenz auf – erzeugen wir beispielsweise ein PWM-Signal mit einer Frequenz von 1 kHz, enthält es auch Frequenzen von 2, 3, 4 kHz usw. Eine Ausnahme bildet ein Signal mit 50 Prozent Duty Cycle, hier treten nur die ungeraden Vielfachen der Pulsfrequenz(öffnet im neuen Fenster) auf. Der Anteil der jeweiligen Obertöne variiert mit dem Duty Cycle, wodurch unterschiedliche Klangcharakteristika entstehen.

Fourier-Transformation eines PWM-Signals
Fourier-Transformation eines PWM-Signals (00:11)

So erzeugt PWM Töne

Mit einem PWM-Modul lassen sich auf zwei Arten Töne erzeugen. Die erste erzeugt Rechteckwellen mit der Frequenz des gewünschten Tons. Oft wird dabei ein Duty Cycle von 50 Prozent genutzt, das Signal ist also ebenso lang auf 1-Pegel wie auf 0-Pegel. Der Gameboy beispielsweise bietet aber auch andere Duty Cycles an, wodurch die Klangfarbe variiert werden kann. Wie oben beschrieben ist die Grundkomponente der Signale immer eine Sinusschwingung mit der vom PWM-Modul erzeugten Pulsfrequenz.

Indem die Pulsfrequenz verändert wird, können Töne unterschiedlicher Höhe erzeugt werden. Allerdings klingen Rechteckwellen aufgrund der vielen Obertöne recht unangenehm. Das lässt sich durch ein Filter etwas in den Griff bekommen – beim Gameboy funktioniert die Tonerzeugung genau so . Ob einem das Ergebnis gefällt, bleibt trotzdem Geschmacksfrage.

Grundkomponenten der Konsolen-Synthesizer

Auf dem gleichen Grundprinzip bauen aber die Synthesizer(öffnet im neuen Fenster) aller Konsolen(öffnet im neuen Fenster) auf: Sie verfügen über eine Auswahl an Grundschwingungen, auf deren Basis Musik und Effekte erzeugt werden. Bei komplexeren Konsolen als dem Gameboy ist die Auswahl an Schwingungen größer. Neben Rechtecksignalen sind das üblicherweise Dreieck- oder Sägezahnsignale, manche Konsolen bieten auch Sinuswellen an. Die verschiedenen Signalformen haben jeweils verschiedene akustische Charakteristika. Oft kommen noch andere Funktionen wie Frequenz- oder Amplitudenmodulation hinzu, um eine Grundschwingung zeitabhängig zu variieren.

Das mit einem PWM-Modul nachzubilden, erfordert allerdings etwas mehr Aufwand – und führt uns zurück zum gesampelten Sound und damit zur zweiten Variante, mittels PWM ein Tonsignal zu erzeugen. Das Modul wird hierbei genutzt, um eine variable Ausgangsspannung zu erzeugen. Damit wird gesampelter Sound nachgebaut, was wesentlich mehr Freiheitsgrade bei der Klangerzeugung bringt. Für diese Variante habe ich mich entschieden.

Samples abspielen mit PWM

Durch die Nutzung eines Tiefpassfilters lässt sich mit PWM ein Mittelwert zwischen den beiden Zuständen aus und an erzeugen. Idealisiert werden die Sinuskomponenten des Signals dabei entfernt. Bei LEDs wird das zum Dimmen genutzt, also um die Helligkeit zwischen 0 Prozent und 100 Prozent einzustellen. Erreicht wird das über die Anpassung des Duty Cycles – grob vereinfacht entspricht ein Duty Cycle von 25 Prozent einem Viertel der maximalen Helligkeit, 66 Prozent zwei Dritteln.

Der Tiefpassfilter ist dabei übrigens die Trägheit des menschlichen Auges. Gleiches lässt sich für Spannungen erreichen; um damit Töne erzeugen zu können, muss die PWM aber deutlich schneller laufen. Da die PWM-Module beim RP2040 mit dem Systemtakt – in diesem Beispiel 141,6 MHz – betrieben werden, lässt sich problemlos ein PWM-Signal im Kilohertz-Bereich erzeugen. Zusammen mit einem Filter aus einem Widerstand und einem Kondensator(öffnet im neuen Fenster) ergibt das ein durchaus akzeptables Analogsignal.

Wird der Duty Cycle des PWM-Signals variiert, lassen sich beliebige Signalformen erzeugen. Dazu benötigen wir digital abgetastete Tondaten – auch als Samples bezeichnet. Je nachdem wie schnell das Sample wiedergegeben wird, variiert die Tonhöhe. Zum Testen habe ich mir erst einmal Samples eines Sinus- und Dreiecksignals erzeugt.

Das erste Testprogramm

Für den ersten Test verwende ich ein Sample mit 16 Abstastwerten einer Sinusschwingung. Die einzelnen Abtastwerte werden vom DMA-Automat(öffnet im neuen Fenster) mit variabler Geschwindigkeit in das Vergleichsregister des verwendeten PWM-Moduls kopiert. Je schneller das passiert, desto höher der Ton.

Die Geschwindigkeit des DMA-Automaten kann auf zwei Arten gesteuert werden: Entweder indem einer der vier Timer des Automaten selbst verwendet oder ein weiteres PWM-Modul genutzt wird. Die DMA-Timer haben dabei einen Nachteil: Sie können den Systemtakt maximal um den Faktor 65.536 (2 16 ) verlangsamen. Damit sind, bei 16 Abtastwerten pro Periode der Sinusschwingung und 141,6 MHz Systemtakt, nur Töne mit 135 Hz oder mehr möglich.

Zum Testen reicht das – also werfen wir einen Blick auf den Code ( (öffnet im neuen Fenster)

ᐸttᐳpwm_snd_test.c

):

        
gpio_init(16);
gpio_set_dir(16, GPIO_OUT);
gpio_set_function(16, GPIO_FUNC_PWM);

pwm_set_clkdiv_int_frac(pwmSlice, 3, 0);
pwm_set_wrap(pwmSlice, 1023);
pwm_set_counter(pwmSlice, 0);

dmaChannelId = dma_claim_unused_channel(true);

dmaChannelConf = dma_channel_get_default_config(dmaChannelId);
channel_config_set_transfer_data_size(&dmaChannelConf, DMA_SIZE_16);
channel_config_set_read_increment(&dmaChannelConf, true);
channel_config_set_write_increment(&dmaChannelConf, false);
channel_config_set_ring(&dmaChannelConf, false, 5);

dmaTimer = dma_claim_unused_timer(true);

dma_timer_set_fraction(dmaTimer, 1, 40227);

channel_config_set_dreq(&dmaChannelConf, dma_get_timer_dreq(dmaTimer));

dma_channel_configure(dmaChannelId, &dmaChannelConf, &pwm_hw->slice[pwmSlice].cc, samples, 35200, true);

pwm_set_enabled(pwmSlice, true);

Die ersten drei Zeilen dienen dazu, GPIO-Pin 16 für die Ausgabe des PWM-Signals zu aktivieren. Danach wird das zugehörige PWM-Modul initialisiert (die Module können nur bestimmte Pins ansteuern). Zuerst wird der Taktteiler des Moduls auf drei gesetzt – das Zählerregister wird mit einem Drittel des Systemtakts hochgezählt. Der Maximalwert des Registers wird auf 1.023 gesetzt, ein Vergleichswert von 511 würde dann die Hälfte der Maximalspannung erzeugen. Die letzte Zeile des Blocks setzt noch das Zählerregister. Die PWM-Frequenz liegt damit bei etwa 46,1 kHz.

Dann folgt die Konfiguration eines DMA-Kanals, den ich mir von der Bibliothek mittels

ᐸttᐳdma_claim_unused_channel()

zuweisen lasse. Danach wird zuerst die Größe der einzelnen Datenblöcke auf 16 Bit gesetzt, dann das Verhalten der Lese- und Schreibadresse konfiguriert: Die Leseadresse wird nach jedem Datenblock erhöht, die Schreibadresse nicht – es wird immer in das Counter-Compare-Register des PWM-Moduls geschrieben.

Dann folgt eine der kleinen Komfortfunktionen des DMA-Automaten: Durch die Konfiguration eines Rings wird er angewiesen, bei der Leseadresse nur die niederwertigsten fünf Bit zu verwenden. So liest er immer dieselben 16 2-Byte-Werte. Damit das funktioniert, muss der Puffer, aus dem gelesen wird, allerdings an einer 32-Byte-Adresse ausgerichtet sein. Das funktioniert, indem in seine Definition

ᐸttᐳ__attribute__((aligned(32)))

eingefügt wird.

Danach lässt man sich einen der Timer des DMA-Automaten zuteilen. Der wird so konfiguriert, dass er die komplette Sinusschwingung mit (etwa) 220 Hz überträgt. Dann wird dieser Timer als Data-Request-Quelle für den DMA-Kanal eingestellt. Der Data Request ist ein weiteres Feature des Automaten, über ihn kann eine andere Hardware-Einheit die Geschwindigkeit der Datenübertragung steuern. Danach wird die Konfiguration des Kanals gesetzt, gleichzeitig noch Schreib- und Leseadresse, und die Anzahl der zu übertragenden Datenblöcke sowie der Kanal werden gleich noch mit aktiviert.

Zuletzt wird das PWM-Modul aktiviert, damit auch etwas zu hören ist. Der akustische Eindruck und das Oszilloskop sagen: funktioniert. Mit dem kleinen Lautsprecher mit Plastikmembran klingt es zwar sehr nach Geburtstagskarte, hat aber extremen 8-Bit-Charme. Also auf zu größeren Taten: polyphone Klänge mit mehreren Tonspuren!

Polyphonie: Mehrere Tonspuren

Für eine einzelne Tonspur ist alles recht einfach. Kompliziert wird es allerdings, wenn mehrere Spuren verwendet werden sollen – was wünschenswert ist, da mit einer einzelnen Spur selten interessante Musik entsteht. Würden wir dem Prinzip der einzelnen Tonspur folgen, bedeutete das: Jede Spur benötigt ein eigenes PWM-Modul.

Die Module haben nämlich einen großen Nachteil: Zwar haben sie theoretisch zwei Kanäle mit jeweils eigenem Vergleichswert – prinzipiell wäre es also möglich, zwei Spuren mit einem PWM-Modul zu realisieren. Praktisch können die Vergleichswerte allerdings nicht unabhängig geschrieben werden. Sie liegen zusammen in einem 32-Bit-Register. Den DMA-Automaten die Vergleichswerte unabhängig schreiben zu lassen, funktioniert nicht – bei einer 16-Bit-Schreiboperation werden beide Vergleichswerte mit dem geschriebenen Wert gefüllt.

Schlimmer noch: Für jede Tonspur brauchen wir einen Timer, der die Datenrate des DMA-Kanals reguliert. Je nach Design sind mit vier Tonspuren – so viele nutzen Gameboy und NES – bereits alle PWM-Module belegt. Mit etwas komplexerer Software lassen sich aber theoretisch beliebig viele Spuren auf nur ein Modul abbilden.

Die Spuren kommen in den Mixer

Das Grundproblem bei mehreren Tonspuren ist: Sie müssen zu einem Signal zusammengemischt werden. Das funktioniert entweder, wie oben beschrieben, mit einer Hardware-Einheit pro Spur, die analogen Signale werden dann einfach durch die Addition der Einzelspannungen gemischt. Alternativ können die digitalen Signale vor der Analogwandlung addiert werden.

Das bedeutet allerdings: Alle Signale müssen die gleiche Abtastrate haben. Da für jede Grundschwingung nur ein Sample vorliegt, ist eine Abtastratenkonvertierung(öffnet im neuen Fenster) (auf Englisch heißt das resampling ). Das bedeutet einigen Rechenaufwand, aber angesichts der Leistung der zwei Cortex-M0+-Kerne im RP2040 ist das vertretbar. Wichtig ist dabei der Hardware-Multiplizierer – der kann in jedem Takt zwei 32-Bit-Werte multiplizieren. Mit ein wenig Geschick lässt sich der Software-Mischer recht effizient realisieren.

Die Funktion des Mixers ist relativ simpel: Er berechnet für jede Tonspur anhand einer vorgegebenen Frequenz zuerst den auszugebenden Abtastwert. Den leitet er aus einem Sample ab, das frequenzunabhängig verwendet wird. Das bedeutet, dass die einzelnen Abtastpunkte des Samples entweder gestreckt werden – wenn eine Schwingung mit der Zielfrequenz mehr Abtastpunkte hätte als das Sample – oder dass Abtastpunkte ausgelassen werden, wenn die gewünschte Schwingung mit der Abtastfrequenz des Mixers an weniger Punkten abgetastet würde als das Sample.

Dazu enthält die Datenstruktur jeder Tonspur eine Variable, mit der die Position im Sample berechnet wird. Um die Tonqualität etwas zu optimieren, bildet man den Mittelwert zweier benachbarter Abtastwert (Interpolation). Das ist sinnvoll, da bei den wenigsten Zielfrequenzen die einzelnen Abtastwerte exakt getroffen werden; zusätzlich verringert es Signalverzerrungen. Aber hier erst einmal der Code (aus der Funktion

ᐸttᐳdoMixing()

in (öffnet im neuen Fenster)

ᐸttᐳpwm_retrosound.c

):

        
interpolation[0] = (channels[c].sampleOffset & 0xffff) < 12;
interpolation[1] = (0x10000 - (channels[c].sampleOffset & 0xffff)) < 12;

offset = channels[c].sampleOffset < 16;

sValues[0] = (channels[c].wavetable[offset & 0xf] * channels[c].volume) < 4;
sValues[1] = (channels[c].wavetable[(offset + 1) & 0xf] * channels[c].volume) < 4;

mixingBuffer += ((sValues[0] * interpolation[0]) < 4) + ((sValues[1] * interpolation[1]) < 4);

In den ersten beiden Zeilen wird für die zwei Werte, zwischen denen interpoliert wird, der Faktor berechnet, mit dem sie ins Ergebnis einfließen. Dazu dient die Variable

ᐸttᐳsampleOffset

als Festkommawert. Dabei wird ein fester Teil der Variable als Nachkommateil verwendet – hier die unteren 16 Bit. Damit lassen sich auch auf Systemen ohne Gleitkommaeinheit rationale Zahlen verwenden, und zwar mit sehr geringem Aufwand.

Komplexe Mischung mit wenig Aufwand dank festem Komma

Additionen und Subtraktionen bedeuten bei Festkomma-Arithmetik(öffnet im neuen Fenster) keinen zusätzlichen Aufwand, Multiplikationen sind allerdings etwas komplizierter. Da im Code oben der Wertebereich angepasst ist, wird nur eine zusätzliche Verschiebeoperation erforderlich. Sie entspricht einer Division durch eine Zweierpotenz. In den ersten beiden Zeilen wird der Nachkommateil der Variablen auf vier Bit reduziert, um das Ergebnis der Multiplikation in der letzten Zeile im Wertebereich einer 16-Bit-Variable zu halten. Damit sind 15 Interpolationsstufen zwischen den beiden Werten möglich.

Vor der Interpolation werden allerdings noch die Werte der beiden Samples, zwischen denen interpoliert wird, auf die (einstellbare) Lautstärke angepasst. Sie werden mit einem Wert zwischen 1 und 16 multipliziert und danach durch 16 geteilt. Dadurch lässt sich die Lautstärke in 16 Stufen anpassen. So kann eine einzelne Spur die maximal mögliche Lautstärke ausnutzen, bei mehreren aktiven Spuren kann deren Lautstärke jedoch verringert werden, um Übersteuern zu vermeiden.

Am Ende werden noch die Samples der einzelnen Spuren zusammengemischt. Alles landet in einem von zwei Puffern, die die DMA-Einheit abwechselnd zum PWM-Modul transportiert. Das sieht fast genau so aus wie im Testcode, allerdings löst der DMA-Kanal einen Interrupt aus, sobald er einen Puffer vollständig übertragen hat. Dadurch erfährt der Prozessor, dass der nächste Puffer geschickt werden muss, damit es munter weiter tönt.

Jetzt brauchen wir nur noch einen Weg, um mit möglichst wenig Aufwand Musik zu erzeugen.

Musik erzeugen mit Tracker

Um mit dem Soundmodul Musik zu erzeugen, muss aktuell noch jede Note einzeln ausgegeben werden. Das wird bei vier Kanälen (so viele habe ich aktuell implementiert) schnell ganz schön aufwendig. Außerdem ist es ungenau, da ein Timer regelmäßig den entsprechenden Code ausführen muss, der noch einmal Zeit braucht.

Idealerweise hätte unser Orchester einen Dirigenten, der dafür sorgt, dass alle Spuren gleich schnell spielen und gleichzeitig zum nächsten Ton wechseln. Vor dem Problem steht jeder Synthesizer und daher wurde schnell eine Lösung entwickelt: der Tracker(öffnet im neuen Fenster) . Er ist nichts anderes als ein digitales Notenblatt und ist unterteilt in einzelne Noten, die nacheinander durch den Synthesizer wiedergegeben werden. Dazu müssen noch die Abspielgeschwindigkeit (Beats per Minute, BPM) und die Notenwerte (ganze, halbe, viertel und so weiter) eingestellt werden.

In meinem Code erledigt das die Funktion

ᐸttᐳsetBeat()

. Sie berechnet, wie schnell die Mixerfunktion durch die Tracks – jede Spur hat ihren eigenen – gehen soll. Das Ergebnis ist wieder ein Festkommawert, um die vorgegebene Geschwindigkeit möglichst genau einzuhalten. Hier wird zwar nicht interpoliert (das wäre etwas schwierig), allerdings kann nach einer zu spät begonnenen Note die nächste etwas vorgezogen werden. Dadurch addieren Fehler sich nicht auf.

Das digitale Notenblatt

Der Tracker ist ebenfalls in der Funktion

ᐸttᐳdoMixing()

implementiert, aus der wir bereits einen Ausschnitt gesehen haben. Jede Tonspur speichert einen eigenen Track, und der ist nichts weiter als ein Array aus 8-Bit-Werten. Dazu gibt es je eine Variable, die die aktuelle Position im Track und dessen Länge speichert.

Aktuell ist der Tracker nur für die gleichstufige Stimmung mit Kammerton A = 440 Hz ausgelegt, also die Tonfrequenzen, auf die die meisten Instrumente gestimmt sind. Jedes Byte ist aufgeteilt in je vier Bit für Oktave und Ton, zusätzlich gibt es ein Pausensymbol, das aus einer ungültigen Kombination besteht. Der Tracker-Code ist etwa ebenso lang wie der des Mixers:

        
if(samplesProduced > (trackPtrCounter < 8)) {
  trackPtrCounter -= (samplesProduced < 8);
  trackPtrCounter += trackPtrUpdateDeltaSamples;
  samplesProduced = 0;

  incrementTrackPtr = true;
}

for(uint c = 0; c < N_SOUND_CHANNELS; ++c) {
  if((channels[c].cs == PLAYING) && (channels[c].trackLength > 0)) {
    if(incrementTrackPtr) {
      ++channels[c].trackPtr;

      if(channels[c].trackPtr >= channels[c].trackLength)
        channels[c].trackPtr = 0;
    }
  }

  note = channels[c].track[channels[c].trackPtr];

  if(note != PAUSE) {
    // hier kommt der Mixer-Code
  }
}

Am Anfang steht eine Abfrage, die prüft, ob der Tracker zur nächsten Note springen muss. Dazu wird berechnet, wie viele Samples zwischen zwei Noten vergehen (

ᐸttᐳtrackPtrUpdateDeltaSamples

) – als Festkommawert mit acht Nachkommabits. Eine Variable zählt, wie viele Abtastwerte der Mixer seit dem letzten Notenwechsel erzeugt hat (

ᐸttᐳsamplesProduced

) und eine weitere Festkommavariable (

ᐸttᐳtrackPrCounter

) speichert, nach wie vielen Samples der nächste Notenwechsel erfolgen soll.

Wechselt die Note, wird das erst einmal nur in einer Variable festgehalten. Den eigentlichen Wechsel nimmt der Code für die einzelnen Kanäle in der for-Schleife vor. Er sorgt auch dafür, dass am Ende des Tracks an dessen Anfang zurückgesprungen wird – einmal gestartete Tracks laufen, bis sie gestoppt werden. Nachdem die aktuelle Note aus dem Track gelesen wurde, folgt der Mixer-Code – es sei denn, es wurde eine Pause eingefügt.

Damit ist der Code vollständig – auch wenn hier nur ein kleiner Ausschnitt gezeigt wurde. Zeit, ihn auszuprobieren.

Testprogramm und Schlussbemerkungen

Zum Ausprobieren von Tracker und Mixer habe ich einen beliebten Kinderliedklassiker mit fetzigem Disco-Beat neu interpretiert. Das Programm (öffnet im neuen Fenster)

ᐸttᐳpwm_tracker_test.c

legt zuerst zwei Arrays an, die als Tracks verwendet werden. Eines speichert die Melodie, das andere den Basslauf.

Die beiden Tracks sind unterschiedlich lang, außerdem verwenden die beiden Tonspuren unterschiedliche Schwingungen: Ein Sinus für die Melodie, ein Dreieckssignal für den Bass. Die Tracks werden mit Hilfe einer Konstante (

ᐸttᐳPAUSE

) und eines Präprozessormakros, das die Noten erzeugt, zusammengebaut. Das eigentliche Programm ist sehr übersichtlich:

        
retrosound_init(0, 16);

setBeat(120, QUARTER);

setTrack(0, trackLen, track[0]);
setTrack(1, track2Len, track2);

setWaveform(0, SINE);
setWaveform(1, TRIANGLE);

setChannelVolume(0, 7);
setChannelVolume(1, 7);

playChannel(0);
playChannel(1);

Es muss lediglich den Sound-Code initialisieren, Takt und Notenwerte setzen, beiden Tonspuren einen Track zuweisen, die zu verwendende Schwingung auswählen, die Lautstärke anpassen (sehr wichtig, sonst übersteuert der Ausgang) und dann beide Kanäle starten.

Das Ergebnis klingt sehr 8-bittig, aber mit ein wenig Optimierung dürfte sich das noch verbessern lassen. Und wenn nicht, hänge ich einfach einen Audio-Codec dran – dafür wäre nämlich mit dem Mixer alles vorhanden. Der sampelt in der aktuellen Implementierung übrigens mit 48 kHz, seine Ausgabe könnte also direkt in jeden gängigen Codec eingespeist werden.

Das Beste aus zwei Welten

Obwohl der Code den Anforderungen aktueller Audio-Hardware genügt, ist er – zumindest was den Speicherbedarf angeht – genau so effizient wie alte Konsolen. Das Testprogramm für den Tracker benötigt gerade einmal 200 Byte für etwa zehn Sekunden Musikinformation. Möglich wird das durch Sampling.

Grundsätzlich ist es auch möglich, beliebige Samples einzubauen – damit ließen sich dann auch aufgenommene Geräusche wiedergeben. Der Code dafür ist zwar noch nicht fertig, aber das Ziel erreicht: Der Pico könnte ein Spiel musikalisch untermalen.


Relevante Themen