Zum Inhalt

Audio-Feel-Überarbeitung + globale Audio-Config — Design-Spec

Datum: 2026-06-07 · Status: Design (genehmigt im Brainstorming, vor Spec-Review) Auslöser: Johannes' Geräte-Test der Audio-Reaktivität auf dem iPhone JK. Scope: Spec 1 von 2. Die audio-reaktive Interaktionsmatrix (Audio → Species-Anziehung/Abstoßung) ist bewusst ausgegliedert → eigenes Spec 2 (…-audio-matrix-reactivity-design.md), weil sie Engine-Anbindung + neue Faktor-Semantik braucht.

1. Kontext & Motivation

Drei Beobachtungen am Gerät:

  1. Bänder ungleich ausgesteuert. Der Input-Meter zeigt bei High kaum, bei Mid wenig, bei Bass am meisten Ausschlag. Ursache (verifiziert im Code): bandLevel mittelt dB über feste Hz-Bänder (Bass 20–250, Mid 250–4000, High 4000–16000). Musik hat ein abfallendes Spektrum (Bass trägt physikalisch mehr Energie), und das sehr breite High-Band mittelt über viele Bins nahe dem Rauschboden → niedriger Schnitt. Eine lineare „gleich große Bins"-Aufteilung würde das verschlimmern.
  2. Reaktivität gefühlt zu zahm. Der vorhandene Gain-Slider (0.1×–10×) wirkt zwar bereits multiplikativ auch bei Auto-Gain an (effGain = gain · autoGainMul), aber durch die bass-lastige Aussteuerung bleibt die Reaktion von Mid/High schwach.
  3. Audio-Config wird beim Preset-Wechsel überschrieben. audioReactive lebt in PresetParams; AppState.select(id:) macht edited = preset.params → die gesamte Audio-Verdrahtung wird durch die des neuen Presets ersetzt. Verhindert das Durchtesten eines Audio-Setups über mehrere Looks.

Zusätzlich vorhandene, hier mit-gelöste Feel-Risiken (aus früherem Spec offen): - Kein Silence-Gate → bei Stille zieht Auto-Gain bis ×20 hoch, Grundrauschen kann zappeln. - Low-Loudness-Over-Boost → der lautheits-abhängige autoGainMul wird auch auf den (bereits per-Band-normalisierten) Transient-Pfad multipliziert → leise Tracks können über-flashen.

2. Ziele / Nicht-Ziele

Ziele - Bass/Mid/High schlagen ähnlich aus — im Meter und in den level-Routes. - Insgesamt kräftigere, aber dynamik-erhaltende Reaktion: leise = sanft, laut = voll, Stille = ruhig. - Audio-Config bleibt über Preset-Wechsel/Undo/Randomize hinweg erhalten (global). - Transient nicht mehr lautheits-über-geboostet.

Nicht-Ziele - Audio → Interaktionsmatrix (→ Spec 2). - System-/Tab-Audio (bleibt Mic-only). - Web-Parität beim Audio (wird bewusst aufgegeben, siehe §6). - Änderungen am GPU-Uniform-Layout oder an Shadern (nicht nötig).

3. Design-Überblick — zwei Pegel statt einem

Die zentrale Idee: rohen Pegel (für die Lautheits-Stufe) und per-Band-normalisierten Pegel (für Balance + schnelle Dynamik) trennen.

AudioAnalyzer.analyze()  (Audio-Thread, pro FFT-Frame)
  rawBass/Mid/High  (dB→0..1, wie bisher)
        │
        ├──> loudness = max(raw)                         ── roher Gesamtpegel
        │
        └──> Per-Band-AGC: je Band träger running-peak    ── Balance + Dynamik
             normBand = clamp(rawBand / max(peakBand, floor), 0, 1)
  transient*  (unverändert; schon per-Band peak-normalisiert)
        │
   AudioBands{ bass/mid/high = normBand, loudness, transient* }
        │
        ├──> meterLevels  (UI-Meter zeigt normBand → ausgeglichen)
        │
        └──> evaluateAudioFactors()  (Render-Loop, pro Frame)
               Loudness-Response  resp = smoothstep(gateLo, gateHi, loudnessEnv)
               effGain = gain · (autoGain ? resp : 1)
               pro Route: factor += signal(normBand|transient) · depth · effGain

Warum die schnelle Dynamik erhalten bleibt: Weil der per-Band-Peak träge ist (Decay ~Sekunden), folgt normBand der schnellen Musik (leise Stelle → klein gegen den noch hohen Peak, laute Stelle → ~1). Identisches Prinzip wie der bereits funktionierende Transient-Peak.

Warum Auto-Gain seinen Sinn behält (umgedeutet): Die per-Band-Normalisierung macht den klassischen kompensierenden Auto-Gain (leise hochziehen) redundant — er würde leise sogar doppelt boosten. Stattdessen wird er zur Loudness-Response: einer sanften 0..1-Kurve auf der rohen Lautheit, die bei Stille → 0 (ruhig) und ab normaler Lautstärke → 1 (voll) geht. Das ersetzt das alte autoGainMul und das fehlende Silence-Gate in einem Konzept. Toggle „Auto-gain" aus → resp = 1 (immer volle, ungedämpfte Reaktion).

4. Teil A — Per-Band-Normalisierung + Loudness-Response

4.1 AudioBands.swift

  • bass/mid/high tragen künftig die normalisierten Werte (was Meter + level-Routes sehen).
  • Neues Feld var loudness: Float = 0 — roher Gesamtpegel (max(rawBass,rawMid,rawHigh)).
  • peak (computed max(bass,mid,high)) wird in evaluateAudioFactors durch loudness ersetzt; peak kann bleiben (von normalisierten Bändern) oder entfallen — Implementierungsdetail.
  • level(_:) gibt damit automatisch den normalisierten Wert zurück; transient(_:) unverändert.

4.2 AudioAnalyzer.swift

  • Rohe Band-Level wie bisher berechnen (bandLevel bleibt unangetastet).
  • loudness = max(rawBass, rawMid, rawHigh) setzen.
  • Per-Band-Level-AGC analog zur vorhandenen Transient-Peak-Mechanik (BandT): pro Band ein träger running-peak levelPeak, Update levelPeak = max(rawBand, peakDecay · levelPeak), dann normBand = clamp(rawBand / max(levelPeak, levelPeakFloor), 0, 1). EMA-Glättung (smoothing) wie bisher auf das normalisierte Ergebnis anwenden (weicher an den Rails).
  • Die transient*-Berechnung bleibt exakt wie sie ist.

4.3 AudioFactors.swift

  • AutoGainState → trägt den loudnessEnv (EMA der rohen loudness).
  • Neue Loudness-Response: resp = smoothstep(gateLo, gateHi, loudnessEnv) (Helfer falls nötig: clamp((x-lo)/(hi-lo),0,1) mit t·t·(3-2t)).
  • effGain = reactive.gain · (reactive.autoGain ? resp : 1).
  • Routen-Auswertung: signal = route.mode == .transient ? bands.transient(src) : bands.level(src) (level = normBand); f[target] += signal · depth · effGain. Kein separater autoGainMul auf Transient mehr — beide Pfade teilen effGain.

4.4 Konstanten (Startwerte — begründet, am Gerät zu justieren)

Konstante Startwert Begründung
levelPeakDecay (τ) ~2.5 s Träge genug für Dynamik-Erhalt, schnell genug um Track-Wechsel zu folgen (Transient nutzt 3.0 s).
levelPeakFloor 0.05 Verhindert Division-Explosion bei sehr leisen Bändern (Transient nutzt 0.02; höher = ruhiger bei leise).
loudnessEnv-Glättung ~150 ms Folgt Passagen-Dynamik, glättet aber Einzel-Frames.
gateLo 0.04 Unterhalb ≈ Rauschboden → resp 0 → Stille bleibt ruhig.
gateHi 0.22 Ab „normaler Zimmerlautstärke" volle Reaktion.
gain-Default 1.0 Master-Headroom; bei systematisch zu zahmer Reaktion am Gerät anheben. Range bleibt 0.1–10.

Diese sechs Werte sind die Tuning-Schrauben. Erwartung: Johannes justiert gateLo/gateHi (Stille-/Voll-Schwelle) und gain (Gesamt-Punch) am Gerät; ich passe die Defaults nach seinem Feedback an.

4.5 UI

  • Toggle-Label bleibt „Auto-gain" (vertraut, konzeptuell weiterhin „automatische Anpassung an die Lautheit"). Der zugehörige Hilfetext (ParamHelp) wird auf die neue Semantik aktualisiert: „Passt die Gesamt-Reaktion an die Lautstärke an — leise sanft, laut voll, Stille ruhig. Aus = immer volle Reaktion." (Falls im Review gewünscht: Umbenennung in „Loudness" / „Dynamik".)
  • Meter unverändert in der Mechanik (zeigt meterLevels.bass/mid/high), jetzt ausgeglichen.

5. Teil B — Globale Audio-Config

5.1 Scope-Verschiebung

  • Neue Quelle der Wahrheit: AppState.audioReactive: AudioReactive (global, nicht Teil von edited/PresetParams).
  • AudioReactiveSection liest/schreibt state.audioReactive statt state.edited.audioReactive.
  • RenderCoordinator.applyAudio() liest state.audioReactive.
  • select(id:), Undo/Redo, Randomize/Mutate/Morph (AppState+Exploration) berühren audioReactive nicht mehr → Config überlebt Look-Wechsel.
  • setAudioEnabled(_:)-Logik (auto-on-enable der 5 Default-Routen bei leerer Liste) bleibt identisch, nur auf dem globalen Feld.

5.2 Persistenz

  • audioReactive wird global persistiert (App-Support-JSON über den vorhandenen Store-Mechanismus oder @AppStorage-kodiert) und beim App-Start geladen; Default = enabled:false, gain:1, autoGain:true, routes:[] (auto-on-enable füllt beim ersten Enable).

5.3 Undo

  • Audio-Config ist nicht Teil der Preset-Undo-History (sie ist nicht mehr in edited). Die pushUndo()-Aufrufe in AudioReactiveSection (autoGain/gain/routes) entfallen. Zurücksetzen erfolgt über den vorhandenen „Default"-Button (stellt die 5 Default-Routen wieder her). (YAGNI: kein separates Audio-Undo.)

5.4 Migration / JSON-Kompatibilität

  • PresetParams.audioReactive bleibt im Struct (das Feld nur verschieben statt entfernen) → alle bestehenden Preset-JSONs und Custom-Presets decodieren unverändert. Es wird lediglich nicht mehr als Live-Quelle gelesen; AppState.audioReactive (global) ist die einzige Quelle der Wahrheit.
  • Custom-Presets, die künftig gespeichert werden, brauchen das Feld nicht mehr; alte behalten es harmlos.
  • Kein Datenverlust: Built-in-Presets haben enabled:false, routes:[], der globale Default ist identisch.

6. Web-Parität (bewusste Abweichung)

Der native Audio-Pfad war ein paritätstreuer Port von computeAudioFactors. Diese Überarbeitung weicht bewusst ab (Per-Band-AGC, Loudness-Response statt kompensierendem Auto-Gain, globale Config). Die Web-App (Codeberg Pages, eingefroren) bleibt unverändert. Begründung: native ist die Zukunft (Pivot), das Feel-Tuning erfordert die Freiheit. Dokumentiert hier, damit später kein „Rückportieren" stillschweigend erwartet wird.

7. Tests

AudioAnalyzerTests (neu/erweitert) - Per-Band-Balance: synthetisches Spektrum mit starkem Bass + schwachem High → nach Einschwingen liefern normBass und normHigh ähnliche Größenordnung (während raw stark divergiert). - loudness == max(rawBass,rawMid,rawHigh). - Stille (Null-Samples) → alle normBand → ~0, loudness → ~0. - Dynamik-Erhalt: lauter Block → leiser Block (bei trägem Peak) → normBand fällt sichtbar (nicht sofort auf 1 geklemmt).

AudioFactorsTests (erweitert) - Loudness-Response-Kurve: loudnessEnv < gateLo → resp ≈ 0 (Faktoren neutral trotz Routen); > gateHi → resp ≈ 1 (volle Wirkung); Monotonie dazwischen. - Transient-Entkopplung: bei konstant niedriger loudness wird ein Transient-Route nicht über effGain hochgeboostet (kein Low-Loudness-Over-Boost). - autoGain == false → resp wirkt nicht (effGain == gain). - gain skaliert beide Pfade gemeinsam.

AudioWiringTests / AppState (erweitert) - select(id:) lässt audioReactive unverändert (global): Setup setzen → Preset wechseln → Routen/gain/autoGain identisch. - setAudioEnabled auto-on-enable funktioniert auf dem globalen Feld (leer → 5 Routen + autoGain; bestehend → unverändert; disable → Routen bleiben). - Persistenz-Roundtrip (encode/decode der globalen Config).

8. Offene / justierbare Punkte (fürs Review)

  • Startwerte §4.4 (besonders gateLo/gateHi, gain-Default) — Geräte-Tuning.
  • UI-Label „Auto-gain" behalten vs. umbenennen (§4.5).
  • peak-Property in AudioBands behalten oder entfernen (kosmetisch).

9. Bewusst NICHT in diesem Spec

  • Audio → Interaktionsmatrix (Self-Cohesion / Inter-Affinity, additive Modulation, Engine-audioMatrixDelta, eigener UI-Unterbereich) → Spec 2.

10. Umsetzungs-Notiz (2026-06-08) — Per-Band-Balance verschoben

Bei der Umsetzung hat eine Messreihe gezeigt, dass die Per-Band-Normalisierung (§4.1/§4.2) nicht wie angenommen sauber balanciert: Die FFT-Bandextraktion trennt schlecht (dB-Mittel über sehr unterschiedlich breite Bänder + Fensterleckage → ein 8-kHz-Sinus liest mid=0.69 > high=0.58, ein reiner Bass-Ton füllt alle Bänder auf 0.5–1.0). Per-Band-AGC verstärkt darauf jedes Band über dem Floor auf Vollausschlag — ein Bass-Ton würde High-Routes triggern. Das ist mit Sinus-Unit-Tests nicht validierbar und braucht Geräte-Tuning auf echter Musik bzw. eine überarbeitete Extraktion (fester perzeptueller Boost statt adaptiver AGC, oder andere Bandkanten).

Entscheidung (mit Johannes): Band-Balance entkoppeln → eigene gerätegetunte Runde. Umgesetzt aus diesem Spec: Loudness-Response (§4.3) + globale Audio-Config (§5). AudioBands.loudness (roher Max) ist drin; die Bänder bleiben vorerst roh (kein Per-Band-AGC). Messdaten + Optionen für die Balance-Runde liegen in den Session-Notizen.