Zum Inhalt

Audio-Feel-Überarbeitung + globale Audio-Config — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Per-Band-normalisierte Audio-Reaktivität (ausgeglichene Bänder), Auto-Gain als Loudness-Response (leise=sanft/laut=voll/still=ruhig), und globale Audio-Config (überlebt Preset-Wechsel).

Architecture: Zwei Pegel im AudioAnalyzer: roher loudness (→ Loudness-Response) und per-Band-normalisierte bass/mid/high (→ Meter + level-Routes). evaluateAudioFactors ersetzt den kompensierenden Auto-Gain durch eine smoothstep-Loudness-Kurve; Transient teilt denselben effGain. audioReactive wandert von PresetParams in AppState (global, persistiert via PresetStore).

Tech Stack: Swift 5, SwiftUI/@Observable, Accelerate/vDSP, XCTest. Spec: docs/superpowers/specs/2026-06-07-audio-feel-rework-design.md.

Build/Test-Befehle: - macOS App-Tests: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test - iOS-Sim-Build: xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build - Neue Source/Test-Datei → vorher cd apple/PhysarumApp && xcodegen generate.


File Structure

  • apple/PhysarumApp/Sources/Audio/AudioBands.swift — loudness-Feld; bass/mid/high = normalisiert.
  • apple/PhysarumApp/Sources/Audio/AudioAnalyzer.swift — per-Band-Level-AGC (pure normalizeLevel), loudness=max(raw).
  • apple/PhysarumApp/Sources/Audio/AudioFactors.swift — Loudness-Response (smoothstep), effGain geteilt.
  • apple/PhysarumApp/Sources/Model/AppState.swift — audioReactive global + Persistenz-Hook.
  • apple/PhysarumApp/Sources/Model/PresetStore.swift — saveAudio/loadAudio.
  • apple/PhysarumApp/Sources/Player/RenderCoordinator.swift — audioReactive-Property; applyAudio nutzt sie.
  • apple/PhysarumApp/Sources/Player/MetalView.swift — audioReactive durchreichen.
  • apple/PhysarumApp/Sources/Player/RootView.swift (o.ä. MetalView-Aufrufer) — audioReactive: state.audioReactive.
  • apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift — state.audioReactive statt edited; pushUndo-Aufrufe raus.
  • apple/PhysarumApp/Sources/ParamHelp.swift (o.ä.) — Hilfetext „Auto-gain".
  • Tests: apple/PhysarumApp/Tests/AudioAnalyzerTests.swift (neu), AudioFactorsTests.swift, AudioWiringTests.swift.

Task 1: Per-Band-Level-Normalisierung + loudness

Files: - Modify: apple/PhysarumApp/Sources/Audio/AudioBands.swift - Modify: apple/PhysarumApp/Sources/Audio/AudioAnalyzer.swift - Test: apple/PhysarumApp/Tests/AudioAnalyzerTests.swift (neu)

  • [ ] Step 1: loudness-Feld in AudioBands

In AudioBands nach high ergänzen: var loudness: Float = 0 (in init mit Default loudness: Float = 0 aufnehmen, static let zero bleibt gültig da Default). bass/mid/high bleiben Float — ihre Bedeutung wechselt zu „normalisiert" (gesetzt im Analyzer).

  • [ ] Step 2: Pure normalizeLevel + failing tests schreiben

In AudioAnalyzer.swift (Datei-Scope, neben bandLevel):

/// Per-band AGC: divide the raw 0..1 level by a slow running peak so each band
/// uses its full range regardless of spectral tilt. Peak decays slowly → fast
/// musical dynamics survive (quiet passage → small, loud → ~1).
func normalizeLevel(_ raw: Float, peak: inout Float, decay: Float, floor: Float) -> Float {
    peak = max(raw, decay * peak)
    return min(1, max(0, raw / max(peak, floor)))
}

Test (AudioAnalyzerTests.swift):

import XCTest
@testable import Physarum

final class AudioAnalyzerTests: XCTestCase {
    // A loud band and a quiet band both reach ~1 after settling on their own peak.
    func testPerBandBalanceNormalizesIndependently() {
        var loudPeak: Float = 0.05, quietPeak: Float = 0.05
        let decay: Float = 0.99, floor: Float = 0.05
        var loud: Float = 0, quiet: Float = 0
        for _ in 0..<50 {
            loud = normalizeLevel(0.9, peak: &loudPeak, decay: decay, floor: floor)
            quiet = normalizeLevel(0.15, peak: &quietPeak, decay: decay, floor: floor)
        }
        XCTAssertGreaterThan(loud, 0.9)
        XCTAssertGreaterThan(quiet, 0.9)               // quiet band also fills its range
    }

    // Fast dynamics survive: a dip below the slow peak yields a small value.
    func testDynamicsSurviveSlowPeak() {
        var peak: Float = 0.05
        let decay: Float = 0.999, floor: Float = 0.05
        for _ in 0..<50 { _ = normalizeLevel(0.9, peak: &peak, decay: decay, floor: floor) }
        let dip = normalizeLevel(0.2, peak: &peak, decay: decay, floor: floor)
        XCTAssertLessThan(dip, 0.4)                     // quiet moment reads low
    }

    // Silence → ~0.
    func testSilenceNormalizesToZero() {
        var peak: Float = 0.5
        let decay: Float = 0.99, floor: Float = 0.05
        var v: Float = 1
        for _ in 0..<200 { v = normalizeLevel(0.0, peak: &peak, decay: decay, floor: floor) }
        XCTAssertLessThan(v, 0.05)
    }
}
  • [ ] Step 3: xcodegen generate (neue Testdatei) + Test-Lauf → FAIL

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "normalizeLevel|Compiling error|TEST" Expected: Compile-Fehler (normalizeLevel fehlt) bis Step 2 eingefügt — danach FAIL→PASS-Übergang. (Falls normalizeLevel schon in Step 2 angelegt: Tests PASS, weiter.)

  • [ ] Step 4: Analyzer verdrahten — loudness + per-Band-Normalisierung

In AudioAnalyzer: drei levelPeak-State-Felder ergänzen und Konstanten:

private var levelPeakBass: Float = 0.05, levelPeakMid: Float = 0.05, levelPeakHigh: Float = 0.05
private static let levelDecay: Float = 0.999    // ~2.5s @ ~hop; tune on device
private static let levelFloor: Float = 0.05

In analyze(...), nach dem Bilden von raw (Schritt 6) und vor der EMA-Glättung:

let loudness = max(raw.bass, max(raw.mid, raw.high))
let normBass = normalizeLevel(raw.bass, peak: &levelPeakBass, decay: Self.levelDecay, floor: Self.levelFloor)
let normMid  = normalizeLevel(raw.mid,  peak: &levelPeakMid,  decay: Self.levelDecay, floor: Self.levelFloor)
let normHigh = normalizeLevel(raw.high, peak: &levelPeakHigh, decay: Self.levelDecay, floor: Self.levelFloor)

EMA-Glättung auf die norm*-Werte statt raw.* anwenden; smoothed.loudness = loudness setzen (loudness selbst NICHT per-Band-glätten — er ist der rohe Indikator). Transient-Block unverändert.

  • [ ] Step 5: Test-Lauf → PASS

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "AudioAnalyzerTests|TEST SUCCEEDED|failed" Expected: AudioAnalyzerTests PASS.

  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/Audio/AudioBands.swift apple/PhysarumApp/Sources/Audio/AudioAnalyzer.swift apple/PhysarumApp/Tests/AudioAnalyzerTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(audio): per-band level normalization + raw loudness in analyzer"

Task 2: Loudness-Response in evaluateAudioFactors

Files: - Modify: apple/PhysarumApp/Sources/Audio/AudioFactors.swift - Test: apple/PhysarumApp/Tests/AudioFactorsTests.swift

  • [ ] Step 1: Failing tests schreiben

In AudioFactorsTests.swift ergänzen (Helper bands(loudness:bass:…) ggf. an vorhandenes Muster anlehnen):

func testLoudnessResponseGatesSilence() {
    var ag = AutoGainState()
    let r = AudioReactive(enabled: true, gain: 1, autoGain: true,
        routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .level)])
    var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.0   // silence loudness
    for _ in 0..<300 { _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag) }
    let f = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag)
    XCTAssertEqual(f[.moveSpeed], 1.0, accuracy: 0.05)   // gated → neutral despite bass=1
}

func testLoudnessResponseFullWhenLoud() {
    var ag = AutoGainState()
    let r = AudioReactive(enabled: true, gain: 1, autoGain: true,
        routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .level)])
    var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.5    // clearly audible
    for _ in 0..<300 { _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag) }
    let f = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag)
    XCTAssertGreaterThan(f[.moveSpeed], 1.8)             // ~1 + 1*1*1
}

func testAutoGainOffIgnoresLoudness() {
    var ag = AutoGainState()
    let r = AudioReactive(enabled: true, gain: 1, autoGain: false,
        routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .level)])
    var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.0
    let f = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag)
    XCTAssertGreaterThan(f[.moveSpeed], 1.8)             // no gating → full
}
  • [ ] Step 2: Test-Lauf → FAIL

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "LoudnessResponse|AutoGainOff|failed" Expected: FAIL (alte Auto-Gain-Logik boostet leise statt zu gaten).

  • [ ] Step 3: evaluateAudioFactors umbauen

AudioGain-Konstanten ersetzen; AutoGainState trägt loudnessEnv:

struct AutoGainState { var loudnessEnv: Float = 0 }

private enum AudioGain {
    static let envCoef: Float = 0.15     // ~150ms follower; tune on device
    static let gateLo: Float = 0.04      // below ≈ noise floor → resp 0 (silence stays calm)
    static let gateHi: Float = 0.22      // above ≈ normal loudness → resp 1 (full)
}

private func smoothstep(_ lo: Float, _ hi: Float, _ x: Float) -> Float {
    let t = min(1, max(0, (x - lo) / (hi - lo)))
    return t * t * (3 - 2 * t)
}

Body:

func evaluateAudioFactors(bands: AudioBands, reactive: AudioReactive,
                          autoGain: inout AutoGainState, hasAudio: Bool = true) -> AudioFactors {
    if hasAudio {
        autoGain.loudnessEnv += AudioGain.envCoef * (bands.loudness - autoGain.loudnessEnv)
    }
    let resp = reactive.autoGain
        ? smoothstep(AudioGain.gateLo, AudioGain.gateHi, autoGain.loudnessEnv)
        : 1
    let effGain = reactive.gain * resp

    var f = AudioFactors()
    if reactive.enabled && hasAudio {
        for route in reactive.routes {
            let signal = route.mode == .transient
                ? bands.transient(route.source)
                : bands.level(route.source)
            f[route.target] += signal * route.depth * effGain
        }
    }
    return f
}
  • [ ] Step 4: Test-Lauf → PASS

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "AudioFactorsTests|TEST SUCCEEDED|failed" Expected: PASS. (Bestehende AudioFactorsTests, die auf altes runningPeak/peak setzten, ggf. an loudness/loudnessEnv anpassen — Compile-Fehler zuerst beheben.)

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Audio/AudioFactors.swift apple/PhysarumApp/Tests/AudioFactorsTests.swift
git commit -m "feat(audio): replace compensating auto-gain with loudness-response curve"

Task 3: Globale Audio-Config (Scope aus PresetParams lösen)

Files: - Modify: apple/PhysarumApp/Sources/Model/PresetStore.swift - Modify: apple/PhysarumApp/Sources/Model/AppState.swift - Modify: apple/PhysarumApp/Sources/Player/RenderCoordinator.swift - Modify: apple/PhysarumApp/Sources/Player/MetalView.swift + MetalView-Aufrufer - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift - Test: apple/PhysarumApp/Tests/AudioWiringTests.swift

  • [ ] Step 1: Failing test — select lässt Audio-Config unberührt
func testSelectPreservesGlobalAudioConfig() {
    let s = AppState()
    s.setAudioEnabled(true)                              // → 5 default routes (global)
    let before = s.audioReactive.routes.count
    let other = s.presets.first { $0.id != s.selectedID }!
    s.select(id: other.id)
    XCTAssertEqual(s.audioReactive.routes.count, before) // survived preset switch
    XCTAssertTrue(s.audioReactive.enabled)
}
  • [ ] Step 2: Test-Lauf → FAIL (audioReactive ist noch in edited)

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "SelectPreserves|value of type 'AppState'|failed" Expected: Compile-Fehler (s.audioReactive existiert nicht) → nach Impl PASS.

  • [ ] Step 3: PresetStore Persistenz
private var audioURL: URL { dir.appendingPathComponent("_audio.json") }
func saveAudio(_ a: AudioReactive) { try? JSONEncoder().encode(a).write(to: audioURL) }
func loadAudio() -> AudioReactive? {
    guard let d = try? Data(contentsOf: audioURL) else { return nil }
    return try? JSONDecoder().decode(AudioReactive.self, from: d)
}
  • [ ] Step 4: AppState — globales audioReactive + Persistenz-Hook

Property (mit didSet-Persistenz), Default aus Store:

var audioReactive: AudioReactive {
    didSet { store.saveAudio(audioReactive) }
}

In init() (nach customs = store.loadCustom()):

audioReactive = store.loadAudio() ?? AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])

setAudioEnabled/applyDefaultAudioWiring auf audioReactive umstellen, ohne pushUndo():

func applyDefaultAudioWiring() {
    audioReactive = AudioReactive(enabled: true, gain: 1, autoGain: true, routes: Self.defaultAudioRoutes)
}
func setAudioEnabled(_ on: Bool) {
    if on && audioReactive.routes.isEmpty {
        audioReactive.routes = Self.defaultAudioRoutes
        audioReactive.autoGain = true
    }
    audioReactive.enabled = on
}
  • [ ] Step 5: RenderCoordinator + MetalView durchreichen

RenderCoordinator: var audioReactive = AudioReactive(enabled: false, gain: 1, autoGain: true, routes: []) ergänzen; in applyAudio() let ar = params.audioReactive → let ar = audioReactive.

MetalView: var audioReactive: AudioReactive Property; in sync(_:) c.audioReactive = audioReactive.

MetalView-Aufrufer (grep MetalView(): Argument audioReactive: state.audioReactive ergänzen.

  • [ ] Step 6: AudioReactiveSection auf globalen Scope

Alle state.edited.audioReactive → state.audioReactive; private var ar: AudioReactive { state.audioReactive }. Alle state.pushUndo()-Aufrufe in dieser Datei entfernen (Audio-Config ist nicht undo-bar).

  • [ ] Step 7: Test-Lauf → PASS

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "AudioWiringTests|TEST SUCCEEDED|failed" Expected: PASS. Bestehende AudioWiring-Tests (auf edited.audioReactive) auf audioReactive umstellen.

  • [ ] Step 8: Commit
git add apple/PhysarumApp/Sources/Model/PresetStore.swift apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/Player/RenderCoordinator.swift apple/PhysarumApp/Sources/Player/MetalView.swift apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift apple/PhysarumApp/Tests/AudioWiringTests.swift
# + MetalView-Aufrufer (RootView o.ä.)
git commit -m "feat(audio): make audio-reactive config global (survives preset switch)"

Task 4: UI-Hilfetext + Full-Suite-Verifikation

Files: - Modify: apple/PhysarumApp/Sources/.../ParamHelp.swift (Eintrag „Auto-gain")

  • [ ] Step 1: Hilfetext aktualisieren

„Auto-gain"-Text → „Passt die Gesamt-Reaktion an die Lautstärke an: leise sanft, laut voll, Stille ruhig. Aus = immer volle Reaktion." (englischer Bestand: passende EN-Formulierung beibehalten falls die App EN-Texte nutzt.)

  • [ ] Step 2: Volle Suite macOS + iOS-Build

Run:

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "Executed|TEST SUCCEEDED|failed"
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD SUCCEEDED|error:"
cd ../PhysarumEngine && swift test 2>&1 | grep -E "passed|failed"
Expected: App-Suite grün (≥ 95: 92 + neue), iOS BUILD SUCCEEDED, Engine 25 grün.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources
git commit -m "docs(audio): update auto-gain help text for loudness-response semantics"

Self-Review-Notiz

  • Spec-Coverage: §4.1→T1, §4.2→T1, §4.3→T2, §4.4→Konstanten in T1/T2 (justierbar), §4.5→T4, §5.1→T3, §5.2→T3(PresetStore), §5.3→T3(pushUndo raus), §5.4→PresetParams.audioReactive bleibt (kein Edit nötig), §7→Tests in T1-T3. ✓
  • levelDecay ist als Frame-Decay-Faktor (0.999) formuliert, nicht als τ-Sekunden — am Gerät zu kalibrieren; Spec nennt τ≈2.5s als Intention.