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 → vorhercd 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 (purenormalizeLevel),loudness=max(raw).apple/PhysarumApp/Sources/Audio/AudioFactors.swift— Loudness-Response (smoothstep),effGaingeteilt.apple/PhysarumApp/Sources/Model/AppState.swift—audioReactiveglobal + Persistenz-Hook.apple/PhysarumApp/Sources/Model/PresetStore.swift—saveAudio/loadAudio.apple/PhysarumApp/Sources/Player/RenderCoordinator.swift—audioReactive-Property;applyAudionutzt sie.apple/PhysarumApp/Sources/Player/MetalView.swift—audioReactivedurchreichen.apple/PhysarumApp/Sources/Player/RootView.swift(o.ä. MetalView-Aufrufer) —audioReactive: state.audioReactive.apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift—state.audioReactivestattedited;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 inAudioBands
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:
evaluateAudioFactorsumbauen
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 —
selectlä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 (
audioReactiveist noch inedited)
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:
PresetStorePersistenz
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— globalesaudioReactive+ Persistenz-Hook
Property (mit didSet-Persistenz), Default aus Store:
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+MetalViewdurchreichen
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:
AudioReactiveSectionauf 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"
- [ ] 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.audioReactivebleibt (kein Edit nötig), §7→Tests in T1-T3. ✓ levelDecayist als Frame-Decay-Faktor (0.999) formuliert, nicht als τ-Sekunden — am Gerät zu kalibrieren; Spec nennt τ≈2.5s als Intention.