Übersicht

Projektarchitektur · Hardware-Belegung · Datenfluss

Das Projekt ist ein joystickgesteuerter Wellenform- und Ton-Generator auf einem ATmega32 (8 MHz Takt). Mit dem Joystick stellst du Tonhöhe, Lautstärke und Wellenform ein, siehst die Wellenform live auf einem OLED-Display (SH1106) und hörst den passenden Ton über zwei Lautsprecher.

Hardware-Map
Bauteil ATmega32-Pin Funktion
Joystick X-Achse PA0 / ADC0 analoge Spannung 0–5 V
Joystick Y-Achse PA1 / ADC1 analoge Spannung 0–5 V
Joystick-Taster (SW) PA2 digital, Low-aktiv (interner Pull-up)
OLED RES PB3 Reset des Display-Controllers
OLED D/C PB4 Data/Command-Umschaltung
OLED DIN (MOSI) PB5 SPI-Daten
OLED CLK (SCK) PB7 SPI-Takt
OLED CS GND fest auf Masse, kein eigener Pin
Lautsprecher 1 PD5 / OC1A Timer1-Ausgang (“Master”)
Lautsprecher 2 PD4 / OC1B Timer1-Ausgang (parallel, “Mirror”)
USART TX Debug-Ausgabe 9600 Baud, 8N1
Die vier Module
main.c
Startet alle Module und führt die 100-ms-Hauptschleife aus; erkennt Änderungen am Joystick.
controller.c
Liest Joystick (ADC) und Taster aus, liefert eine Richtung 0–5.
audio.c
Erzeugt den Ton über Timer1 (Hardware-PWM, Fast-PWM Modus 14).
display.c
Zeichnet Status + Wellenform auf das OLED und baut das gemeinsame Daten-Array.
Ablauf / Datenfluss
[Eingabe] Joystick / Taster ADC + GPIO controller.c controller_status() Rückgabewert Wert verändert? nein main.c tmpcontroller 100 ms warten ja display.c update_display() schreibt frequency[7][11][3] One-Hot-Array — die Nachricht von Display an Audio liest audio.c update_audio() stellt Timer1 ➤ Lautsprecher LEGENDE Klickbar → Tab Info / Datenspeicher Entscheidung
Genau EINE Zelle des Arrays frequency[Frequenz][Amplitude][Wellenform] ist ungleich 0 („One-Hot“). Ihr Wert ist die Tonhöhe in 1/10 Hz (z. B. 4400 = 440,0 Hz). Die drei Array-Indizes codieren Tonhöhen-Slot (0–6), Lautstärke (0–10) und Wellenform (0 = Sinus, 1 = Rechteck, 2 = Sägezahn).

main.c Programmstart & Hauptschleife

Einstiegspunkt · Setup-Aufrufe · 100-ms-Loop · Änderungserkennung

★ Die 3 wahrscheinlichsten Prüfungsfragen
Wozu dient tmpcontroller (Startwert −1) und was bewirkt die Änderungserkennung?
Speichert die zuletzt erkannte Richtung. Nur wenn der neue controller-Wert davon abweicht, werden update_display/update_audio aufgerufen — sonst nur 100 ms warten. −1 ist kein gültiger Wert (gültig 0–5) und erzwingt so das erste Update.
Warum wird update_display vor update_audio aufgerufen?
Beide teilen sich das frequency-Array: Display schreibt den neuen Zustand hinein, Audio liest ihn. Umgekehrt spielte der Ton den alten Zustand.
Bekommen die Module das frequency-Array als Kopie oder als Original?
Als Original. Ein Array zerfällt beim Aufruf zu einem Zeiger (Adresse); update_display ändert damit direkt das Array aus main(), es wird nichts kopiert.

Diese Datei ist der Dirigent des Projekts. Sie schreibt selbst fast keine Hardware an, sondern ruft die drei Module in der richtigen Reihenfolge auf und sorgt mit einer Endlosschleife dafür, dass das System dauerhaft auf den Joystick reagiert.

Hardware-Kontext: ATmega32, 8 MHz. Ziel: Du sollst nicht nur wissen dass die Schleife läuft, sondern warum sie genau so aufgebaut ist — besonders die Änderungserkennung.

1. Die Grundidee in einem Satz

Kernprinzip „Lies regelmäßig den Joystick aus. Nur wenn sich die Eingabe geändert hat, aktualisiere Display und Ton." Alles andere in main.c ist Drumherum (Setup + eine Debug-Ausgabe).

2. Includes und globale Variablen (Kopf der Datei)

#define DELAY 100

int tmpcontroller = -1;
int frequency[AUDIO_NUM_FREQ][AUDIO_NUM_AMP][AUDIO_NUM_WAVE];
ElementBedeutung
DELAY 100Die Schleife wartet am Ende 100 ms. Das ergibt eine Abtastrate von ca. 10×/Sekunde — schnell genug, dass sich der Joystick „sofort" anfühlt, langsam genug, dass das Display nicht flackert.
tmpcontroller = -1Merkt sich die zuletzt erkannte Joystick-Richtung. Startwert −1, weil controller_status() nur Werte 0–5 liefert. So ist die allererste Messung garantiert „anders" → das System initialisiert sich im ersten Durchlauf selbst.
frequency[7][11][3]Das gemeinsame Daten-Array (global, daher automatisch mit 0 vorbelegt). Es ist die „Nachricht" von Display an Audio. Genau eine Zelle ist ≠ 0 (One-Hot); ihr Wert ist die Tonhöhe in 1/10 Hz.
Warum global? Sowohl update_display() (schreibt) als auch update_audio() (liest) brauchen dasselbe Array. Es einmal global anzulegen ist der einfachste Weg, es zwischen den Modulen zu teilen.

3. main() — Setup-Phase

int main() {
  setup_audio();
  setup_controller();
  setup_display();
  ...

Drei einmalige Initialisierungen, jede in ihrem eigenen Modul:

1. setup_audio() — Lautsprecher-Pins als Ausgang, Timer1 für die Tonerzeugung vorbereiten.
2. setup_controller() — USART (Debug), ADC im Free-Running-Modus, Joystick-Taster, und sei() (globale Interrupts ein). Ab hier laufen die ADC- und USART-Interrupts.
3. setup_display() — SPI + OLED initialisieren und das Startbild zeichnen.

Prüfungsdetail Die globalen Interrupts werden in setup_controller() per sei() freigegeben. Ein zusätzlicher usartSetup()-Aufruf in main() wäre überflüssig — USART wird bereits in setup_controller() eingerichtet. Die Reihenfolge der drei Setups ist unkritisch, solange alle vor der Schleife laufen.

4. Die Hauptschleife while(1) — Schritt für Schritt

4.1 Diagnose-Werte lesen (Debug)

int8_t x = adcLastRead(0);              // X-Achse, skaliert [-16, +16]
int8_t y = adcLastRead(1);              // Y-Achse, skaliert [-16, +16]
int8_t z = (PINA & (1 << 2)) ? 0 : 1;   // Taster: PA2, Low-aktiv
int controller = controller_status();   // fertige Richtung 0–5

x, y, z werden hier nur für die Debug-Ausgabe roh ausgelesen. Die eigentliche Auswertung steckt in controller_status(), das daraus eine einzelne Zahl macht:

0 = Mitte · 1 = Oben · 2 = Unten · 3 = Links · 4 = Rechts · 5 = Taster

Low-aktiv z = (PINA & (1<<2)) ? 0 : 1 ist Low-aktiv: Ist Bit 2 (PA2) gesetzt (5 V, Pull-up) → Taster nicht gedrückt → z=0. Liegt PA2 auf 0 V (gedrückt) → z=1.

4.2 Debug-Zeile über die serielle Schnittstelle senden

char buf[40];
snprintf(buf, sizeof(buf), "x=%d y=%d z=%d s=%d\r\n", x, y, z, controller);
usartWriteString(buf);

snprintf baut sicher (mit Längenbegrenzung) einen String wie x=3 y=-12 z=0 s=2 und schickt ihn per USART (9600 Baud) an den PC. Das ist reines Werkzeug zum Fehlersuchen.

Nicht-blockierend Die USART-Ausgabe ist interrupt-gesteuert und nicht-blockierend: Läuft noch eine Übertragung, gibt usartWriteString() einfach 0 zurück und die Zeile wird verworfen. Für Debug-Zwecke ist das egal — main() prüft den Rückgabewert bewusst nicht.

4.3 Das Herzstück: Änderungserkennung (tmpcontroller)

if (controller == tmpcontroller) {
    _delay_ms(DELAY);
    continue;                 // nichts hat sich geändert -> nichts tun
} else {
    tmpcontroller = controller;
    update_display(controller, frequency);   // erst Display + Array
    update_audio(frequency);                 // dann Ton
}
_delay_ms(DELAY);

Das ist die wichtigste Stelle der Datei. Sie vergleicht die aktuelle Richtung mit der vom letzten Durchlauf:

Gleich (controller == tmpcontroller) → continue: 100 ms warten und von vorne. Display und Audio werden nicht angefasst.
Verschieden → neue Richtung merken, dann update_display(...) und update_audio(...).

Reihenfolge: Display vor Audio update_display() schreibt in frequency (es baut die eine aktive Zelle). update_audio() liest frequency und stellt den Timer ein. Würde man die Reihenfolge tauschen, spielte der Ton noch den alten Zustand. Display → Audio ist also Pflicht.
Array-Zerfall in C Warum frequency ohne & übergeben? In C „zerfällt" ein Array beim Funktionsaufruf zu einem Zeiger auf sein erstes Element. update_display(controller, frequency) übergibt also die Adresse des globalen Arrays — die Funktion verändert wirklich das Original in main.c, keine Kopie.

5. Die zwei Ebenen der Flankenerkennung (Prüfungsklassiker!)

Es gibt im Projekt zwei unabhängige Mechanismen, die verhindern, dass ein gehaltener Joystick endlos weiterzählt.

EbeneWoWas sie tut
tmpcontrollermain.cTor vor den Aufrufen. update_display()/update_audio() laufen nur, wenn sich die Richtung gegenüber dem letzten Tick geändert hat. Ein konstant gehaltener Joystick löst also genau eine Aktion aus.
last_inputdisplay.cSchutz im Modul selbst. Innerhalb von update_display() wird ein Schritt nur ausgeführt, wenn die Eingabe nicht der zuletzt verarbeiteten entspricht. So bleibt das Display korrekt, egal wie oft update_display() aufgerufen wird.
Zusammenspiel Im aktuellen Programm ist tmpcontroller das primäre Tor. last_input ist die zweite Sicherung: Sie macht das Display-Modul für sich allein robust. Würde man das main-Tor entfernen, sorgt last_input weiterhin dafür, dass eine Auslenkung = ein Schritt ist. Zwei voneinander unabhängige Schichten → doppelt abgesichert.
Konkret Joystick nach oben halten → Tick 1: Richtung wechselt 0→1, update_display(1) läuft, Lautstärke +1. Tick 2: Richtung ist immer noch 1 → tmpcontroller-Tor blockt, nichts passiert. Erst Loslassen (→0) und erneut Hochdrücken (→1) zählt wieder. So wird aus „Dauerdruck" ein sauberer Einzelschritt.

6. Ablauf als Bild

 ┌─────────────────────────────────────────────────────────────┐
 │  setup_audio()  setup_controller()  setup_display()          │  einmalig
 └─────────────────────────────────────────────────────────────┘
                              │
                  ┌───────────▼───────────┐
                  │   while(1)  (alle 100ms) │
                  └───────────┬───────────┘
                              ▼
              controller = controller_status()        (0–5)
                              │
              Debug: "x=.. y=.. z=.. s=.."  → USART
                              │
                 controller == tmpcontroller ?
                   ┌──── ja ──┴── nein ────┐
                   ▼                       ▼
              100 ms warten        tmpcontroller = controller
              (nichts tun)         update_display(controller, frequency)
                   │               update_audio(frequency)
                   └───────────┬───────────────────────┘
                               ▼
                          100 ms warten → zurück nach oben

7. Typische Prüfungsfragen (Selbsttest)

Wofür steht tmpcontroller und warum Startwert −1?
Letzte erkannte Richtung; −1 ist kein gültiger Wert (0–5), also gilt die erste Messung als Änderung → Selbst-Initialisierung im ersten Durchlauf.
Warum wird update_display vor update_audio aufgerufen?
Display schreibt das frequency-Array, Audio liest es. Sonst spielt der Ton den alten Zustand.
Wieso verändert update_display das Array aus main wirklich (keine Kopie)?
Das Array wird als Zeiger (Adresse) übergeben (Array-Zerfall), nicht kopiert.
Was macht die snprintf/usartWriteString-Zeile?
Nur Debug-Ausgabe der Rohwerte über die serielle Schnittstelle; nicht funktionsrelevant.
Warum zählt ein gehaltener Joystick nicht endlos hoch?
Zwei Ebenen: tmpcontroller (main) ruft nur bei Änderung auf; last_input (display) sichert das Modul zusätzlich ab.
Wie schnell wird abgetastet?
Ca. 10×/Sekunde (_delay_ms(100)).

controller.c Joystick & Taster

ADC-Auswertung · Richtungserkennung · Schwellwert-Logik

★ Die 3 wahrscheinlichsten Prüfungsfragen
Wie wird aus X/Y genau eine Richtung bestimmt — wozu die Schwellen ±10 und ±6?
±10 auf der Hauptachse verlangt einen klaren Ausschlag; die Toleranz ±6 auf der Gegenachse schließt Diagonalen aus, sodass immer genau eine Richtung (1–4) erkannt wird, sonst 0 (Mitte).
Warum ist der Taster „low-aktiv" und wozu PORTA |= Bit(2)?
PA2 ist Eingang (DDRA &= ~Bit(2)) mit internem Pull-up (PORTA |= Bit(2)), liegt also im Ruhezustand auf high. Drücken verbindet ihn mit GND → 0; der Code wertet das als „gedrückt" und liefert 5 (Vorrang vor jeder Richtung).
Was bedeuten die Rückgabewerte und wozu sei() in setup_controller?
0=Mitte, 1=Oben, 2=Unten, 3=Links, 4=Rechts, 5=Taster. sei() gibt die globalen Interrupts frei — nötig, weil ADC (Free-Running) und USART per Interrupt arbeiten.

Diese Datei erklärt den Teil des Projekts, der den Joystick und den Taster ausliest: src/controller.c, include/controller.h sowie die ADC-Hilfsfunktionen in src/adc.c / include/adc.h.

Was macht das Controller-Modul?

Der Joystick gibt zwei analoge Spannungen aus (X-Achse und Y-Achse) und hat zusätzlich einen digitalen Taster (Z-Achse, „drücken"). Das Modul liest diese drei Eingaben aus und liefert einen einzigen Zahlenwert (0–5), der angibt, was der Benutzer gerade tut:

RückgabewertBedeutung
0Mitte / keine Bewegung
1Joystick nach oben
2Joystick nach unten
3Joystick nach links
4Joystick nach rechts
5Taster gedrückt

Hardware-Anschlüsse

Joystick-Pin   → ATmega32-Pin   Funktion
─────────────────────────────────────────
VRx  (X-Achse) →  PA0 / ADC0    Analoge Spannung 0–5 V
VRy  (Y-Achse) →  PA1 / ADC1    Analoge Spannung 0–5 V
SW   (Taster)  →  PA2            Digitaler Eingang (Low-aktiv)
Low-aktiv Wenn der Taster gedrückt ist, liegt PA2 auf GND (0 V) = logisch 0. Wenn er nicht gedrückt ist, zieht der interne Pull-up-Widerstand PA2 auf 5 V = logisch 1.

ADC — Was ist das und wie funktioniert Free-Running?

Der ADC (Analog-Digital-Converter) wandelt eine Spannung (analog, 0–5 V) in eine Zahl um. Beim ATmega32 liefert er Werte von 0 bis 1023 (10 Bit):

  0 V  →   0
  2,5 V →  511
  5 V  → 1023

Im Free-Running-Modus läuft der ADC kontinuierlich: Er wandelt Kanal 0 (X), dann Kanal 1 (Y), dann wieder Kanal 0, usw. – automatisch und im Hintergrund. Sobald eine Wandlung fertig ist, springt der Prozessor kurz in die ISR (ISR(ADC_vect) in adc.c), speichert den Wert, und kehrt dann zum Hauptprogramm zurück.

Warum Prescaler /32?

Der ADC-Takt muss laut Datenblatt zwischen 50 kHz und 200 kHz liegen für volle 10-Bit-Genauigkeit. Da wir nur 33 Stufen (−16 bis +16, also ~5 Bit) brauchen, darf der Takt höher sein.

CPU-Takt: 8 MHz
Prescaler /32 → ADC-Takt: 250 kHz → eine Wandlung dauert ~52 µs

Kalibrierung: ADC_X_CENTER_RAW und ADC_Y_CENTER_RAW

Im Ruhezustand sollte der ADC theoretisch 512 liefern (Mitte von 0–1023). In der Praxis weicht das ab, weil der Joystick mechanisch nicht perfekt mittig ist.

Durch Messen wurde festgestellt:

— X-Achse im Ruhezustand: ca. 687 statt 512
— Y-Achse im Ruhezustand: ca. 495 statt 512

#define ADC_X_CENTER_RAW 687
#define ADC_Y_CENTER_RAW 495
Kalibrierung anpassen Falls am eigenen Joystick im Ruhezustand nicht 0 angezeigt wird, einfach diese Werte anpassen. Die USART-Ausgabe (x=%d y=%d) zeigt die skalierten Werte — der Rohwert ist über adcLastReadRaw() erreichbar.

adcScale() — Rohwert auf [−16, +16] umrechnen

Die Funktion adcScale(raw, center) in adc.c rechnet den ADC-Rohwert (0–1023) in einen skalierten Wert (−16 bis +16) um, wobei der gemessene Ruhewert (center) als Nullpunkt gilt.

Warum nicht einfach raw − center skalieren? Weil der Ruhepunkt nicht in der Mitte liegt (z. B. X = 687 statt 512), würde eine einseitige Skalierung den Ausschlag nach links und rechts unterschiedlich stark gewichten. Stattdessen wird für jede Seite getrennt skaliert:
  raw < center → linke Seite:  -(center - raw) × 16 / center
  raw > center → rechte Seite:  (raw - center) × 16 / (1023 - center)

Beispiel für X-Achse (center = 687):

  raw =   0 → -(687 -   0) × 16 / 687 = -16  (voller Ausschlag links)
  raw = 687 → 0                              (Ruhelage)
  raw = 1023 → (1023 - 687) × 16 / 336 = +16  (voller Ausschlag rechts)

setup_controller() — Initialisierung

void setup_controller(void)

Diese Funktion wird einmal beim Start in main.c aufgerufen. Sie:

1. Richtet die USART-Schnittstelle ein (9600 Baud, 8N1) → für Debug-Ausgaben
2. Startet den ADC im Free-Running-Modus → X/Y werden ab jetzt dauerhaft gemessen
3. Konfiguriert PA2 als Eingang mit internem Pull-up → für den Joystick-Taster
4. Schaltet mit sei() die globalen Interrupts frei → ISRs können jetzt laufen

controller_status() — Joystick auswerten

int controller_status(void)

Diese Funktion wird im Hauptprogramm regelmäßig aufgerufen (alle 100 ms).

Schritt 1: Werte lesen

int8_t x = adcLastRead(0);  // X-Achse: -16 (links) bis +16 (rechts)
int8_t y = adcLastRead(1);  // Y-Achse: -16 (unten) bis +16 (oben)
int8_t z = (PINA & Bit(2)) ? 0 : 1;  // Taster: 0 = offen, 1 = gedrückt

Schritt 2: Taster hat Vorrang

if (z == 1) return 5;

Schritt 3: Richtungserkennung

Die Schwelle von ±10 sorgt dafür, dass kleine Wackler in der Mitte nicht als Richtung gezählt werden. Die Bedingung auf der Gegenachse (±6) verhindert, dass Diagonalbewegungen als zwei Richtungen erkannt werden:

  x ≥ +10  und  |y| ≤ 6  → Rechts (4)
  x ≤ -10  und  |y| ≤ 6  → Links  (3)
  y ≤ -10  und  |x| ≤ 6  → Unten  (2)
  y ≥ +10  und  |x| ≤ 6  → Oben   (1)
  sonst                   → Mitte  (0)

Visualisierung der Schwellwerte:

         y ≥ +10
         ↑ Oben (1)
         |
x ≤ -10 ← ·Mitte· → x ≥ +10
 Links(3)  |          Rechts(4)
           ↓
         y ≤ -10
         Unten (2)

Zusammenspiel mit dem Rest des Projekts

main.c
  │
  ├── setup_controller()   → initialisiert ADC + USART + Taster
  │
  └── (Loop alle 100 ms)
        │
        ├── controller_status()  → liefert 0–5
        │
        ├── update_display(status, ...)   → display.c: ändert Frequenz/Amplitude/Waveform
        └── update_audio(frequency[...]) → audio.c:   spielt Ton ab

Joystick-Simulator — ATmega32 controller.c / adc.c

Interaktive Visualisierung der ADC-Skalierung und Richtungs-Schwellen

OBEN UNTEN LINKS RECHTS MITTE
Live-Readout
rawX 687
rawY 495
x (skaliert) 0
y (skaliert) 0
z (Taster) 0
0
Mitte
Warum zweiseitige Skalierung? Der Joystick-Ruhepunkt liegt bei X=687, Y=495 — weit entfernt von der Mitte 512. Würde man naiv auf [0..1023] → [-16..+16] skalieren, hätten die linke und die rechte Seite unterschiedliche Steigungen: Links (0→687) wäre viel „weicher" als Rechts (687→1023). Die zweiseitige lineare Skalierung in adcScale() berechnet für jede Seite des Ruhepunkts eine eigene Steigung — dadurch liefern beide Richtungen einen vollen Ausschlag von ±16.

Wozu dienen ±10 / ±6 Totzonen? Die Schwelle ±10 auf der Hauptachse filtert kleine Wackler oder zittrige Joystick-Ruhelagen heraus — erst ein deutlicher Ausschlag wird als Richtung gewertet. Die ±6-Bedingung auf der Gegenachse schließt Diagonaleingaben aus: Schiebt man den Joystick z. B. leicht schräg nach rechts oben, zählt das nur als „Rechts" wenn Y noch im Band −6…+6 liegt — sonst wird gar keine Richtung erkannt. So gibt es immer genau eine eindeutige Richtung oder Mitte.

Sichtbare Asymmetrie: Weil der Ruhepunkt X=687 rechts von der Mitte liegt, sind die Zonen auf dem Pad deutlich verschoben. Das Fadenkreuz sitzt rechts und leicht unterhalb der geometrischen Mitte — exakt wie im echten Hardware-Aufbau.
Selbsttests (Pflicht + Diskriminierungstest)

audio.c Tonerzeugung via Timer1

Hardware-PWM · Fast-PWM (Modus 14) · ICR1 = TOP/Frequenz · OCR1A/OCR1B = Tastverhältnis · Lautstärke-Stufen

★ Die 3 wahrscheinlichsten Prüfungsfragen
Wie entsteht der Ton und warum muss die CPU im Betrieb nichts tun?
Timer1 läuft im Fast-PWM-Modus 14: ICR1 ist der TOP-Wert und bestimmt die Frequenz, OCR1A/OCR1B sind die Compare-Werte und bestimmen das Tastverhältnis (die Pulsweite). Sobald die Pins über COM1A1/COM1B1 an den Timer angeschlossen sind, läuft das Pulssignal autonom in Hardware. Die CPU stellt ICR1/OCR1A/OCR1B nur einmal bei einer Änderung neu ein.
Erkläre die ICR1-Formel — warum KEIN Faktor 2 und warum 32-Bit (UL/uint32_t)?
ICR1 = F_CPU·10 / (PRESCALER·note_tenth_hz) − 1. Kein Faktor 2, weil im Fast-PWM-Modus ein kompletter Zähldurchlauf 0→TOP→0 bereits genau einer vollen Signalperiode entspricht (kein Toggle-Paar wie früher im CTC-Modus). Mit Faktor 2 klänge jeder Ton eine Oktave zu hoch. 32-Bit, weil F_CPU·10 = 80 Mio. 16 Bit überläuft.
Warum klingen Sinus/Rechteck/Sägezahn jetzt unterschiedlich und wie wird die Lautstärke gemacht?
Jede Wellenform bekommt über duty_shift ein anderes Tastverhältnis: Sin 50 %, Square 25 %, Saw 12,5 % von (ICR1+1). Unterschiedliche Tastverhältnisse haben unterschiedlichen Obertongehalt → hörbar andere Klangfarbe am Buzzer, auch ohne DAC. Lautstärke bleibt weiterhin grob über die Zahl aktiver Lautsprecher gestuft (amp 1–5: einer, 6–10: beide).

Dieses Dokument erklärt die audio.c Zeile für Zeile bzw. abschnittsweise. Hardware-Kontext: ATmega32, 8 MHz Takt. Tonerzeugung läuft komplett über Timer1 als Hardware-PWM. Die CPU muss im laufenden Betrieb nichts tun — der Timer schaltet die Lautsprecher-Pins selbstständig im richtigen Takt.

1. Die Grundidee

Ein Lautsprecher macht ein Geräusch, wenn sich die Spannung an ihm schnell ändert. Schalten wir einen Pin sehr schnell zwischen high (5 V) und low (0 V) um, entsteht ein Pulssignal:

Spannung
 5V  ┌────┐    ┌────┐    ┌────┐
     │    │    │    │    │    │
 0V ─┘    └────┘    └────┘    └──   →  Zeit
     <-T-> = eine Periode = 1 / Frequenz

Schnelles Umschalten → hoher Ton.
Langsames Umschalten → tiefer Ton.
Das ist die erste Stellschraube: die Frequenz. Die zweite Stellschraube ist die Pulsweite (das Tastverhältnis) — wie lange der Pin pro Periode high gegenüber low bleibt. Sie ändert nicht die Tonhöhe, sondern den Obertongehalt und damit die Klangfarbe.

2. Die #define-Konstanten

#define F_CPU      8000000UL
#define PRESCALER  8UL
KonstanteBedeutung
F_CPUTaktfrequenz der CPU = 8 MHz = 8 000 000 Hz.
PRESCALERVorteiler. Der CPU-Takt wird vor dem Timer durch 8 geteilt. Timer1 zählt also mit 8 MHz / 8 = 1 MHz (1 Mio. Schritte/Sekunde).
Wichtig PRESCALER (Wert 8) muss zum gesetzten Prescaler-Bit im Timer passen. Im Code wird CS11 gesetzt = Vorteiler /8. Würde man eins ändern und das andere vergessen, stimmt jede berechnete Frequenz nicht mehr.
#include "note.h"   // liefert NOTE_NONE = 5000

Ein Sondersignal: Wenn in einer Zelle der Wert 5000 steht, bedeutet das „kein Ton / Pause". Das UL hinter Zahlen in den Formeln weiter unten heißt Unsigned Long — es zwingt den Compiler, mit großen vorzeichenlosen 32-Bit-Zahlen zu rechnen.

note.h — vom Labor bereitgestellter Header Definiert ein enum Note mit allen Klaviernoten in 1/10 Hz (scientific pitch notation), von NOTE_B0 = 309 bis NOTE_DS8 = 49780 — z. B. NOTE_C4 = 2616 oder NOTE_A4 = 4400 (Kammerton a'), NOTE_B4 = 4939. Dazu NOTE_NONE = 5000 als Marker für „kein Ton / Pause". Spart in audio.c die eigene Magic-Zahl AUDIO_NOTE_NONE_TENTH_HZ.

3. Das Datenmodell (frequency[7][11][3])

int frequency[AUDIO_NUM_FREQ][AUDIO_NUM_AMP][AUDIO_NUM_WAVE];
//                  7              11             3
IndexNameBereichBedeutung
fFrequenz0..6Tonhöhen-Slot
aAmplitude0..10Lautstärke (0 = stumm)
wWave0..2Wellenform: 0=Sin, 1=Rechteck, 2=Säge
Konvention „One-Hot" Es ist immer genau eine Zelle ungleich 0. Diese eine Zelle beschreibt den aktuell zu spielenden Ton. Der gespeicherte Wert ist die Tonhöhe in 1/10 Hz (z. B. 4400 = 440,0 Hz = Kammerton a').

4. setup_audio() — die einmalige Einrichtung

4.1 Pins auf Ausgang schalten

AUDIO_SPK1_DDR |= (1 << AUDIO_SPK1_PIN);
AUDIO_SPK2_DDR |= (1 << AUDIO_SPK2_PIN);

DDRx = Data Direction Register. Ein Bit auf 1 = der Pin ist ein Ausgang. (1 << PIN) erzeugt eine Maske mit genau dem einen gesetzten Bit, |= setzt dieses Bit ohne die anderen Pins zu verändern.

4.2 Timer1 konfigurieren

TCCR1A = (1 << WGM11);
TCCR1B = (1 << WGM13) | (1 << WGM12) | (1 << CS11);
BitRegisterWirkung
WGM11TCCR1ATeil des Wellenform-Modus
WGM13, WGM12TCCR1Bzusammen mit WGM11: WGM13:10 = 1110Fast-PWM, Modus 14, TOP = ICR1
CS11TCCR1BClock Select: Prescaler /8
Wie entsteht daraus ein Ton? Im Fast-PWM-Modus zählt Timer1 von 0 bis ICR1 (TOP) hoch und springt dann sofort wieder auf 0 — ein kompletter Durchlauf entspricht genau einer vollen Signalperiode. ICR1 bestimmt damit die Tonhöhe.
Pins bleiben hier absichtlich noch stumm Die COM-Bits (COM1A1/COM1B1), die die Pins an den Timer anschließen, werden in setup_audio() bewusst noch nicht gesetzt. Solange sie 0 sind, bleiben die Pins normale GPIO-Ausgänge und werden fest auf low gezogen → sauberer, garantiert stummer Start. Erst update_audio() übergibt die Pins beim Spielen an den Timer und nimmt sie beim Stummschalten wieder weg.

5. update_audio() — der eigentliche Ablauf

Diese Funktion arbeitet in 5 logischen Schritten:

Schritt 1 — Aktive Zelle suchen

for (uint8_t f = 0; f < AUDIO_NUM_FREQ; f++)
  for (uint8_t a = 0; a < AUDIO_NUM_AMP; a++)
    for (uint8_t w = 0; w < AUDIO_NUM_WAVE; w++)
      if (frequency[f][a][w] != 0) {
          active_f = f; active_a = a; active_w = w;
          note_tenth_hz = frequency[f][a][w];
          goto cell_found;
      }
cell_found:
Warum goto? Ein break würde nur die innerste Schleife verlassen. Um aus allen drei Schleifen auf einmal herauszuspringen, ist ein goto an ein Label hier der sauberste und gut lesbare Weg.

Schritt 2 — Stumm-Fälle abfangen

if (note_tenth_hz == 0
    || active_a == 0
    || note_tenth_hz == NOTE_NONE) {
    TCCR1A &= ~((1 << COM1A1) | (1 << COM1B1));
    AUDIO_SPK1_PORT &= ~(1 << AUDIO_SPK1_PIN);
    AUDIO_SPK2_PORT &= ~(1 << AUDIO_SPK2_PIN);
    return;
}

In drei Fällen soll es still bleiben: kein aktive Zelle gefunden, Amplitude ist null, oder das „kein Ton"-Sondersignal (5000).

Schritt 3 — Tonhöhe → TOP-Wert ICR1

uint16_t top = (uint16_t)((F_CPU * 10UL)
                   / (PRESCALER * (uint32_t)note_tenth_hz) - 1UL);
ICR1 = top;

Grundformel mit echter Frequenz f:

       F_CPU
ICR1 = ───────────── − 1
       PRESCALER · f

Setzt man f = note_tenth_hz / 10 ein, wandert die 10 in den Zähler:

             F_CPU · 10
ICR1 = ───────────────────────── − 1
        PRESCALER · note_tenth_hz

Rechenbeispiel für 440,0 Hz (note_tenth_hz = 4400):

ICR1 = (8 000 000 · 10) / (8 · 4400) − 1
     = 80 000 000 / 35 200 − 1
     ≈ 2272 − 1 = 2271
Warum fällt der Faktor 2 weg? Im alten CTC-Toggle-Modus brauchte eine volle Schwingung zwei Toggles (high→low→high), daher stand dort ein Faktor 2 im Nenner. Im Fast-PWM-Modus entspricht ein kompletter Zähldurchlauf 0→TOP→0 bereits einer vollen Periode — der Faktor 2 entfällt. Würde man ihn (versehentlich) stehen lassen, käme jeder Ton eine Oktave zu hoch heraus.
Warum überall UL / (uint32_t)? Der Zwischenwert 80 000 000 passt nicht in 16 Bit (max. 65 535). Ohne 32-Bit-Rechnung gäbe es einen Überlauf und einen völlig falschen Ton. Das Ergebnis wird erst am Ende auf uint16_t zurückgecastet, weil ICR1 ein 16-Bit-Register ist.

Schritt 4 — Wellenform

uint8_t duty_shift;
switch (active_w) {
    case AUDIO_WAVE_SIN:    duty_shift = 1; break;  // 50%
    case AUDIO_WAVE_SQUARE: duty_shift = 2; break;  // 25%
    case AUDIO_WAVE_SAW:    duty_shift = 3; break;  // 12,5%
    default:                duty_shift = 1; break;
}
uint16_t duty = (uint16_t)((top + 1U) >> duty_shift);
OCR1A = duty;
OCR1B = duty;

TCCR1A |= (1 << COM1A1);
Tastverhältnis → Obertongehalt → Klangfarbe Jetzt macht der switch wirklich etwas Unterschiedliches: jede Wellenform bekommt ein anderes Tastverhältnis (Bruchteil von top + 1): Sin = 50 % (obertonärmster, „rundester" Puls), Square = 25 %, Saw = 12,5 % (obertonreichster, „schärfster" Puls). Unterschiedlicher Obertongehalt heißt unterschiedliche Klangfarbe — die drei Wellenformen klingen am Buzzer jetzt tatsächlich hörbar verschieden.
Ehrlichkeit: kein echter Sinus Ein 50-%-Puls ist physikalisch ein Rechteck und vertritt hier nur den Sinus — ein echter Sinus ist am Piezo-Buzzer nicht möglich (resonant, kein DAC/Tiefpass). Die drei Tastverhältnisse haben aber verschiedenen Obertongehalt und klingen daher hörbar unterschiedlich.

Schritt 5 — Lautstärke über Anzahl der Lautsprecher

if (active_a >= 6) {
    TCCR1A |= (1 << COM1B1);          // Speaker 2 dazuschalten
} else {
    TCCR1A &= ~(1 << COM1B1);         // Speaker 2 aus
    AUDIO_SPK2_PORT &= ~(1 << AUDIO_SPK2_PIN);
}
Amplitude aVerhalten
0stumm (schon in Schritt 2 erledigt)
15nur Speaker 1 (Speaker 2 hart auf low)
610beide Speaker parallel (COM1B1 zusätzlich an)

6. Zusammenfassung des Datenflusses

  3D-Array frequency[f][a][w]   (genau eine Zelle ≠ 0)
              │
              ▼
   update_audio()
   ├─ 1. aktive Zelle suchen        →  note_tenth_hz, active_a, active_w
   ├─ 2. stumm?  ── ja ──► Timer-Ausgänge aus, Pins low, return
   │        │ nein
   ├─ 3. ICR1 = Frequenz (TOP)       →  bestimmt Tonhöhe
   ├─ 4. switch(wave)                →  OCR1A/OCR1B = Tastverhältnis (Duty)
   └─ 5. amplitude ≥ 6 ?             →  Speaker 2 dazu (lauter)
              │
              ▼
   Timer1 erzeugt Pulssignal an PD5 (+ ggf. PD4) autonom  →  hörbarer Ton
1. Aktive Zelle suchen for f/a/w: frequency[f][a][w] ≠ 0 2. Stumm? a=0 oder note=5000 oder note=0 ja Pins low return nein 3. ICR1 berechnen (TOP) (F_CPU·10) / (PRESCALER·note) − 1 4. switch(wave) → OCR1A/OCR1B (Duty) SIN 50% · SQUARE 25% · SAW 12,5% 5. Lautstärke: a ≥ 6? ja → COM1B1 ein (Speaker 2) · nein → aus Timer1 erzeugt Pulssignal an PD5/PD4 → hörbarer Ton

7. Typische Prüfungsfragen (Selbsttest)

Warum hängen beide Lautsprecher an Timer1?
Damit sie garantiert synchron exakt denselben Ton spielen.
Was passiert im Fast-PWM-Modus genau?
Der Zähler läuft von 0 bis ICR1 (TOP), springt dann auf 0 zurück und startet neu — ein Durchlauf entspricht genau einer vollen Signalperiode.
Warum hat die ICR1-Formel KEINEN Faktor 2?
Anders als beim alten CTC-Toggle (2 Toggles = 1 Schwingung) entspricht im Fast-PWM-Modus ein einziger Zähldurchlauf 0→TOP→0 bereits einer vollen Periode — daher kein Faktor 2 im Nenner.
Warum UL und (uint32_t) in der Formel?
Der Zwischenwert F_CPU * 10 überläuft 16 Bit; man braucht 32-Bit-Rechnung.
Warum goto statt break?
break verlässt nur die innerste Schleife; goto springt aus allen dreien.
Warum klingen Sin/Square/Saw jetzt unterschiedlich?
Jede Wellenform bekommt über duty_shift ein anderes Tastverhältnis (50 % / 25 % / 12,5 % von ICR1+1) in OCR1A/OCR1B — unterschiedliches Tastverhältnis bedeutet unterschiedlichen Obertongehalt und damit hörbar andere Klangfarbe.
Wie wird die Lautstärke realisiert?
Grob über die Anzahl aktiver Lautsprecher (1 vs. 2), nicht über einen DAC.

Timer1 Fast-PWM (Modus 14) — ATmega32 Tonerzeugung

audio.c · F_CPU = 8 MHz · Prescaler /8 → Timer-Takt 1 MHz

Eingabe
440 Hz

Wellenform (Visualisierung)
Live-Anzeige (aus audio.c berechnet)
Eingangsfrequenz 440,0 Hz (4400 · 1/10 Hz)
ICR1 (TOP) = ? 2271
OCR1A = OCR1B (Duty) 1136
f_real (aus ICR1) 440,14 Hz
Periode (ICR1+1) 2272 µs
Rundungsabweichung +0,14 Hz
ICR1 = (F_CPU × 10) / (PRESCALER × note_tenth_hz) − 1
      = (8 000 000 × 10) / (8 × 4400) − 1 = 2271
f_real = F_CPU / (PRESCALER × (ICR1 + 1))
⚠ Pulsweite ≠ echter Sinus: Die drei Wellenformen erzeugen jetzt drei verschiedene Tastverhältnisse (Sin 50 % · Square 25 % · Saw 12,5 %) und klingen am Buzzer deshalb wirklich hörbar unterschiedlich. Ein 50-%-Puls ist aber physikalisch immer noch ein Rechteck und vertritt hier nur den Sinus — ein echter Sinus ist am Piezo-Buzzer nicht möglich (resonant, kein DAC/Tiefpass).
Zähler (Sägezahn-Rampe 0 → ICR1 → 0…)
Ausgangssignal OC1A / Ausgabe-Pin (Visualisiert: Rechteck, Tastverhältnis-Puls)

Herleitung der ICR1-Formel: Der ATmega32 läuft mit F_CPU = 8 MHz. Durch den Prescaler /8 zählt Timer1 mit 1 MHz — jeder Zählschritt dauert genau 1 µs.

Im Fast-PWM-Modus (WGM13/WGM12/WGM11 = Modus 14) zählt der Timer von 0 bis ICR1 (TOP) und springt dann sofort auf 0 zurück. Ein kompletter Durchlauf 0→TOP→0 entspricht dabei bereits genau einer vollen Periode — anders als beim alten CTC-Toggle-Modus gibt es hier kein Toggle-Paar und damit auch keinen Faktor 2 im Nenner.

Die Tonhöhe wird intern als 1/10-Hz-Wert gespeichert (440,0 Hz → 4400). Das Teilen durch 10 wird durch das ×10 im Zähler herausgekürzt, sodass keine Gleitkomma-Arithmetik nötig ist.

Das „−1" entsteht, weil der Zähler bei 0 beginnt: Ein Durchlauf von 0 bis ICR1 = N umfasst N+1 Schritte — daher muss der gewünschte TOP-Wert um 1 verringert werden.

Wichtig für 32-Bit: F_CPU × 10 = 80.000.000 überschreitet den 16-Bit-Bereich (max. 65.535). Im echten C-Code wird deshalb mit UL bzw. (uint32_t) gecastet, damit die Zwischenrechnung in 32-Bit erfolgt — ansonsten würde die Multiplikation überlaufen und das Ergebnis wäre falsch.

display.c OLED-Rendering & Array-Aufbau

SH1106 SPI · Framebuffer · Wellenform-Pixel · One-Hot-Befüllung

★ Die 3 wahrscheinlichsten Prüfungsfragen
Wozu der Framebuffer im RAM statt direkt aufs Display zu schreiben?
Erst wird das ganze Bild im Speicher gezeichnet, dann in einer Übertragung an den SH1106 geschickt → flackerfrei und einfacher.
Was bedeutet „One-Hot" beim frequency-Array und wie hängt display.c mit audio.c zusammen?
Genau eine Zelle ist ≠ 0; ihre Indizes stehen für Slot/Lautstärke/Wellenform, der Wert für die Tonhöhe in 1/10 Hz. display.c schreibt diese Zelle, update_audio() liest sie.
Warum beginnt das Schreiben bei Spalte 2 und wie funktioniert der Byte-Trick (Little-Endian)?
Der SH1106 hat 132 interne, aber nur 128 sichtbare Spalten → Offset 2 zentriert das Bild. Eine uint64_t-Spalte wird als 8 Bytes betrachtet; Byte page = die 8 Pixel dieser Page — was nur funktioniert, weil der AVR Little-Endian ist.
Interaktiver OLED-Simulator unten auf dieser Seite — zum Simulator scrollen ↓

Dieses Modul ist das größte des Projekts. Es hat zwei Jobs:

1. Das OLED ansteuern — einen SH1106-Controller über SPI mit Bildinhalt füttern.
2. Den App-Zustand verwalten — sich merken, welche Tonhöhe/Lautstärke/Wellenform eingestellt ist, diesen Zustand auf Joystick-Eingaben verändern und ihn in das gemeinsame frequency-Array für audio.c schreiben.

Hardware: ATmega32, 8 MHz, OLED-Controller SH1106, 128×64 Pixel.

1. Pin-Belegung

#define DISPLAY_RESET_PIN     PB3   // RES  -> Display sauber zurücksetzen
#define DISPLAY_DATA_CMD_PIN  PB4   // D/C  -> LOW=Befehl, HIGH=Pixeldaten
#define DISPLAY_SPI_MOSI_PIN  PB5   // DIN  -> SPI-Daten (Master Out)
#define DISPLAY_SPI_MISO_PIN  PB6   // (unbenutzt, nur Eingang)
#define DISPLAY_SPI_SCK_PIN   PB7   // CLK  -> SPI-Takt
// CS liegt fest auf GND -> kein eigener Pin nötig
D/C-Pin D/C (Data/Command) ist der wichtigste Sonder-Pin: Er sagt dem Display, ob das nächste Byte ein Befehl (LOW) oder Bilddaten (HIGH) ist. CS = GND: Das Display ist dauerhaft „ausgewählt".

2. SPI — wie ein Byte zum Display kommt

static void display_spi_setup(void) {
    DDRB |= (1 << MOSI) | (1 << SCK);   // MOSI + SCK = Ausgang
    DDRB &= ~(1 << MISO);               // MISO = Eingang
    SPCR = (1 << SPE) | (1 << MSTR);    // SPI an, Master
    SPSR = (1 << SPI2X);                // doppelte Geschwindigkeit -> 4 MHz
}

SPI schiebt Daten bitweise synchron zum Takt SCK hinaus. Bei F_CPU = 8 MHz und SPI2X ergibt sich 4 MHz SPI-Takt (Mode 0, MSB first).

static uint8_t display_spi_transfer_byte(uint8_t data) {
    SPDR = data;                          // Schreiben startet die Übertragung
    while (!(SPSR & (1 << SPIF))) { }     // warten bis Hardware fertig (SPIF)
    return SPDR;
}

3. Der SH1106 in „Pages" — wie das Display organisiert ist

Eine Page = 8 Pixel HOCH, 132 Spalten breit.
page 0 -> y 0..7      page 4 -> y 32..39
page 1 -> y 8..15     page 5 -> y 40..47
page 2 -> y 16..23    page 6 -> y 48..55
page 3 -> y 24..31    page 7 -> y 56..63

Ein Datenbyte beschreibt 8 senkrecht übereinanderliegende Pixel einer Page (Bit 0 oben, Bit 7 unten).

Spalten-Offset 2 Der SH1106 hat intern 132 Spalten, sichtbar sind aber nur 128. Deshalb beginnt das Schreiben bei Spalte 2 (DISPLAY_NONVISIBLE_BORDER_OFFSET), sonst wäre das Bild um 2 Pixel verschoben.

4. Der Framebuffer — und der clevere Bit-Trick (Prüfungs-Highlight!)

static uint64_t display_frame_buffer[DISPLAY_PIXEL_WIDTH];  // 128 Spalten

Wir zeichnen nicht direkt aufs Display, sondern erst in diesen RAM-Puffer:

Ein uint64_t = eine komplette senkrechte Spalte mit 64 Pixeln.
Bit y = Pixel (x, y). Also: display_frame_buffer[x] |= 1ULL << y; setzt den Pixel bei Spalte x, Zeile y.

Wie wird daraus eine Page für den SH1106?

uint8_t *first_page_column = ((uint8_t *)display_frame_buffer) + page;
for (uint8_t x = 0; x < DISPLAY_PIXEL_WIDTH; x++) {
    display_spi_transfer_byte(*(first_page_column + DISPLAY_PAGES * x));
}

Der Puffer ist uint64_t[128] — wir betrachten ihn als Byte-Array ((uint8_t *)). Jede Spalte belegt 8 Bytes. Für Spalte x liegen die Bytes 8·x … 8·x+7. Der Code liest für eine bestimmte Page das Byte an Index 8·x + page.

uint64_t einer Spalte x:   Bits  0..7   8..15  16..23 ... 56..63
                            └ Byte 0 ┘ └ Byte 1 ┘ ...   └ Byte 7 ┘
y-Pixel:                    y0..y7     y8..y15        ...  y56..y63
SH1106-Page:               Page 0     Page 1         ...  Page 7
Kernaussage für die Prüfung: Little-Endian! Dieser Trick funktioniert nur, weil AVR Little-Endian ist. Auf einer Big-Endian-Architektur läge Byte 0 oben (Bits 56–63) und das Mapping wäre vertauscht. Vorteil des Tricks: Man muss die Bits nicht einzeln umsortieren — das Speicherlayout passt schon zur Page-Struktur des Displays.
FRAMEBUFFER: uint64_t display_frame_buffer[128] Spalte x uint64_t Byte 0 Byte 1 Byte 2 Byte 3 Byte 4 Byte 5 Byte 6 Byte 7 ← Little-Endian AVR niederwertigstes Byte zuerst Bits 0–7 Bits 8–15 Bits 24–31 Bits 56–63 SH1106 Pages (je 8 Pixel hoch) Page 0 Page 1 Page 2 Page 3 Page 4 Page 5 Page 6 Page 7 y 0–7 y 8–15 y 56–63 Zugriff: *( (uint8_t*)frame_buffer + 8·x + page ) = Byte[page] von Spalte x = 8 Pixel für SH1106-Page[page] in Spalte x DISPLAY_PAGES * x = 8·x → springt zur Spalte x + page → wählt Page-Etage (0–7) → cast (uint8_t *) macht den uint64_t als Byte-Array sichtbar (Little-Endian)

5. Zeichen-Primitive

display_draw_pixel(x, y);                  // Bit in fb[x] setzen (+ Bereichscheck)
display_draw_vertical_line(x, y1, y2);     // y1,y2 sortieren, dann Bits setzen
display_draw_horizontal_line(x1, x2, y);   // x1,x2 sortieren, dann zeichnen
display_clear_buffer();                    // alle 128 Spalten = 0

Alle Zeichenoperationen verändern nur den RAM-Puffer. Erst display_update_hardware() macht sie auf dem echten OLED sichtbar.

6. Die Wellenform zeichnen

6.1 Sample → Y-Pixel

static uint8_t display_sample_to_y(int16_t sample) {       // sample: -127..+127
    int16_t pixel_amplitude =
        (DISPLAY_MAX_WAVE_AMPLITUDE * display_state.amplitude_index) / DISPLAY_MAX_AMPLITUDE_INDEX;
    int16_t y = DISPLAY_CENTER_Y - ((sample * pixel_amplitude) / 127);
    return display_clamp_y(y);             // auf [WAVE_TOP=16 .. WAVE_BOTTOM=60] begrenzen
}

Wichtige Konstanten: CENTER_Y = (16+60)/2 = 38, MAX_WAVE_AMPLITUDE = (60−16)/2 = 22, MAX_AMPLITUDE_INDEX = 10.

Display: y=0 ist oben Ein positiver Sample-Wert muss nach oben, deshalb CENTER_Y − … (Subtraktion). display_clamp_y() hält die Kurve im Bereich 16..60.

6.2 Die drei Wellenformen (rein optisch)

case AUDIO_WAVE_SIN:    sample = display_sine_lut[x % 32];                 break;
case AUDIO_WAVE_SQUARE: sample = ((x % 32) < 16) ? 127 : -127;            break;
case AUDIO_WAVE_SAW:    sample = ((int16_t)((x % 32) * 255) / 31) - 127;  break;
Nur fürs Display! Diese Wellenformen sind nur fürs Display. Der Ton bleibt (siehe audio.c) immer ein Rechteck. Sinus/Sägezahn sieht man also, hört sie aber nicht als solche.

7. Die Mini-Schrift (3×5-Font) für die Statuszeile

Oben am Display steht z.B. SIN A:5 F:349. Dafür gibt es einen winzigen 3 Pixel breiten, 5 Pixel hohen Font. display_draw_uint() zerlegt eine Zahl rückwärts in Ziffern (über % 10 und / 10) und zeichnet sie dann wieder vorwärts.

1/10 Hz → ganze Hz Audio rechnet in 1/10 Hz (z.B. 3492). Für die Anzeige reicht volle Hz, deshalb / 10349.

8. Der komplette Bildaufbau

static void display_render_current_state(void) {
    display_generate_waveform_pixels();   // 1. Y-Werte der Kurve berechnen
    display_clear_buffer();               // 2. Puffer leeren
    display_draw_status();                // 3. Statuszeile oben
    display_draw_horizontal_line(0, 127, DISPLAY_STATUS_HEIGHT);  // 4. Trennlinie bei y=12
    display_draw_waveform();              // 5. Kurve
    display_update_hardware();            // 6. alles ans OLED schicken
}

Jedes Bild wird komplett neu gebaut (kein teilweises Update). Bei 128×64 Pixeln ist das schnell genug.

9. Zustand & Eingabe-Logik

9.1 Der gemerkte Zustand

typedef struct {
    uint8_t frequency_index;   // 0..6  -> welche Tonhöhe aus display_notes_tenth_hz[]
    uint8_t amplitude_index;   // 0..10 -> Lautstärke (0 = stumm)
    uint8_t waveform_index;    // 0=Sin, 1=Rechteck, 2=Säge
} DisplayState;
// Startwerte: frequency_index=3, amplitude_index=5, waveform_index=0 (Sinus)

Die 7 Tonhöhen (in 1/10 Hz):

display_notes_tenth_hz[7] = { 2616, 2937, 3296, 3492, 3920, 4400, 4939 };
//   Index:                    C4    D4    E4    F4    G4    A4    B4

9.2 Eingabe → Zustandsänderung (display_step_state())

case UP:    if (amplitude_index < 10) amplitude_index++;          break;  // lauter
case DOWN:  if (amplitude_index > 0)  amplitude_index--;          break;  // leiser (0=stumm)
case LEFT:  frequency_index = (frequency_index==0) ? 6 : frequency_index-1;  break;
case RIGHT: frequency_index++; if (frequency_index>=7) frequency_index=0;    break;
case SELECT:waveform_index++;  if (waveform_index>=3) waveform_index=0;      break;

Hoch/Runter → Lautstärke (mit Grenzen 0 und 10, kein Umlauf).
Links/Rechts → Tonhöhe (mit Umlauf: nach dem letzten Slot wieder zum ersten).
Taster (SELECT) → Wellenform durchschalten (0→1→2→0).

9.3 update_display() — der Einstieg aus main.c

void update_display(int controller, int frequency[7][11][3]) {
    DisplayInput input = display_is_valid_input(controller) ? (DisplayInput)controller : CENTER;

    if (input != CENTER && input != last_input)   // Flankenerkennung im Modul
        display_step_state(input);
    last_input = input;

    display_apply_state(frequency);   // Zustand -> One-Hot-Array (Nachricht an Audio)
    display_render_current_state();   // OLED neu zeichnen
}
last_input-Flankenerkennung Nur reagieren, wenn die Eingabe neu und nicht Mitte ist. Ein gehaltener Joystick schaltet pro Auslenkung nur einmal weiter. Das ist die zweite Sicherungsebene zur tmpcontroller-Prüfung in main.c.

9.4 Das One-Hot-Array bauen

static void display_apply_state(DisplayFrequencyTable frequency) {
    display_clear_frequency(frequency);                 // ALLES auf 0
    frequency[frequency_index][amplitude_index][waveform_index]
        = display_notes_tenth_hz[frequency_index];      // genau EINE Zelle setzen
}
One-Hot-Regel Das Array frequency[7][11][3] wird zuerst komplett gelöscht und dann wird genau eine Zelle gesetzt. Diese Vereinbarung mit audio.c: Indizes = Slots, Wert = Tonhöhe in 1/10 Hz.

10. Initialisierung setup_display()

display_spi_setup();
DDRB |= (1<<RESET) | (1<<DATA_CMD);
display_reset();
display_send_command(SET_DISPLAY_OFF);
display_set_contrast(128);
display_send_command(CHARGEPUMP); display_send_command(0x14);  // interne Hochspannung AN
display_clear_buffer(); display_update_hardware();
display_send_command(SET_DISPLAY_ON);
Charge Pump (0x8D, 0x14) OLED-Pixel brauchen eine höhere Spannung, als die 5 V liefern. Der SH1106 erzeugt sie intern — der Charge-Pump-Befehl schaltet diese Spannungspumpe ein. Ohne ihn bliebe der Schirm dunkel.

12. Typische Prüfungsfragen (Selbsttest)

Wozu der Framebuffer im RAM statt direkt aufs Display?
Erst alles im Speicher zeichnen, dann einmal komplett übertragen → flackerfrei, einfacher.
Erkläre den Byte-Trick in display_update_hardware().
uint64_t-Spalte als 8 Bytes betrachtet; Byte page einer Spalte = die 8 Pixel dieser Page. Funktioniert, weil AVR Little-Endian ist (Byte 0 = niederwertigste Bits = oberste Page).
Warum wird beim Schreiben Spalte 2 als Start genommen?
SH1106 hat 132 interne, aber nur 128 sichtbare Spalten → Offset 2 zentriert das Bild.
Warum 1/10 Hz in der Notentabelle und /10 für die Anzeige?
So lassen sich Nachkommastellen (z.B. 349,2 Hz) ohne float speichern; die Anzeige reicht in vollen Hz.
Wie wird ein gehaltener Joystick „entprellt"?
last_input-Flankenerkennung: Schritt nur, wenn die Eingabe neu (≠ letzte) und ≠ Mitte ist.
Was bedeutet „One-Hot" beim frequency-Array und wer liest es?
Genau eine Zelle ≠ 0 (Indizes = Slot/Lautstärke/Wellenform, Wert = Tonhöhe in 1/10 Hz); update_audio() liest es.
Warum Charge Pump?
Erzeugt intern die höhere Betriebsspannung der OLED-Pixel; ohne ihn bleibt das Display dunkel.
Sieht man die Wellenform-Auswahl auch im Ton?
Teilweise. Das OLED zeichnet die idealisierte Wellenform; der Buzzer spielt den nächstliegenden Tastverhältnis-Puls (50 % / 25 % / 12,5 %) — das klingt unterschiedlich, ist aber eine Pulsweiten-Näherung, kein echter Sinus.
ATmega32 · SH1106 OLED-Simulator · display.c
Tasten halten = nur 1× schalten · ↑↓←→ Leertaste
Systemzustand
Eingabe (input) 0 · CENTER Letzte (last_input) 0 · CENTER Tick-Aktion
Wellenform-Index 0 · SIN Lautstärke (A) 5 Frequenz-Index (F) 3 Frequenz 349 Hz
▲ / ▼  Lautstärke (Amplitude) erhöhen / senken  (Index 0–10, 0 = stumm).  ◀ / ▶  Tonhöhe ändern (C4 → D4 → E4 → F4 → G4 → A4 → B4, zirkulär).  SEL  Wellenform wechseln: Sinus → Rechteck → Sägezahn → Sinus …
Flankenerkennung (last_input-Logik): Eine gehaltene Taste schaltet nur einmal weiter. Erst wenn der Joystick kurz in die Mitte (CENTER = 0) zurückkehrt, wird die nächste Auslenkung als neue Flanke erkannt.