import Foundation
import PhysarumEngine
/// One multiplier per ModTarget (all default 1.0 = neutral). Port of the web
/// `AudioFactors = Record<ModTarget, number>` with FACTOR_ONES default.
struct AudioFactors: Equatable {
private var values: [ModTarget: Float]
init() { values = Dictionary(uniqueKeysWithValues: ModTarget.allCases.map { ($0, 1.0) }) }
subscript(_ t: ModTarget) -> Float {
get { values[t] ?? 1.0 }
set { values[t] = newValue }
}
var agentUniform: UniformInput.Audio {
var a = UniformInput.Audio()
a.moveSpeedFactor = self[.moveSpeed]
a.turnSpeedFactor = self[.turnSpeed]
a.depositFactor = self[.depositAmount]
a.sensorDistanceFactor = self[.sensorDistance]
a.rhythmAmplitudeFactor = self[.rhythmAmplitude]
a.chaosStrengthFactor = self[.chaosStrength]
return a
}
var postExposure: Float { self[.exposure] }
var postBloomIntensity: Float { self[.bloomIntensity] }
var postSaturation: Float { self[.saturation] }
var postContrast: Float { self[.contrast] }
}
/// Carries the loudness envelope across frames (slow follower of the raw loudness).
struct AutoGainState { var loudnessEnv: Float = 0 }
/// The three loudness-response constants, lifted out of a hardcoded enum so they can be
/// live-tuned on-device (DEBUG rig) without a rebuild. The defaults are the shipped
/// values, so `AudioTuning()` reproduces the exact pre-rig response curve — anywhere the
/// tuning is left at its defaults (release builds, every test) behaviour is unchanged.
struct AudioTuning: Codable, Sendable, Equatable {
var envCoef: Float = 0.15 // follower coefficient on raw loudness (higher = snappier envelope)
var gateLo: Float = 0.04 // below ≈ noise floor → response 0 (silence stays calm)
var gateHi: Float = 0.22 // above ≈ normal loudness → response 1 (full)
// Per-band balance adaptation (BandNormalizer). Starting values from the design doc;
// they are meant to be dialled in on-device via the DEBUG rig and then baked in here.
var adaptiveBalance: Bool = true
var peakTau: Float = 4.0 // peak follower decay — slower than the transient peak (3 s)
var valleyTau: Float = 8.0 // valley follower rise — a break must not lift the floor at once
var peakFloor: Float = 0.02 // lower anchor so silence cannot collapse the span
var bandGateLo: Float = 0.06 // band presence ramp, lower bound (evaluated on the peak)
var bandGateHi: Float = 0.15 // band presence ramp, upper bound
var minSpan: Float = 0.08 // never spread a span narrower than this (noise guard)
}
// The decoder lives in an extension on purpose: declaring `init(from:)` inside the struct
// would suppress the synthesised memberwise and no-argument inits, and both are in use
// (`AudioTuning()` everywhere, `AudioTuning(envCoef:gateLo:gateHi:)` in the tests).
extension AudioTuning {
// Hand-written so a `_tuning.json` predating the balance keys decodes onto the
// defaults instead of throwing. See AudioTuningCodableTests.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AudioTuning()
envCoef = try c.decodeIfPresent(Float.self, forKey: .envCoef) ?? d.envCoef
gateLo = try c.decodeIfPresent(Float.self, forKey: .gateLo) ?? d.gateLo
gateHi = try c.decodeIfPresent(Float.self, forKey: .gateHi) ?? d.gateHi
adaptiveBalance = try c.decodeIfPresent(Bool.self, forKey: .adaptiveBalance) ?? d.adaptiveBalance
peakTau = try c.decodeIfPresent(Float.self, forKey: .peakTau) ?? d.peakTau
valleyTau = try c.decodeIfPresent(Float.self, forKey: .valleyTau) ?? d.valleyTau
peakFloor = try c.decodeIfPresent(Float.self, forKey: .peakFloor) ?? d.peakFloor
bandGateLo = try c.decodeIfPresent(Float.self, forKey: .bandGateLo) ?? d.bandGateLo
bandGateHi = try c.decodeIfPresent(Float.self, forKey: .bandGateHi) ?? d.bandGateHi
minSpan = try c.decodeIfPresent(Float.self, forKey: .minSpan) ?? d.minSpan
}
}
/// Smooth 0..1 ramp between `lo` and `hi` (Hermite); clamped outside.
/// Internal rather than private: BandNormalizer uses the same ramp for its band-presence
/// gate, and two copies of a curve that shapes the feel would drift apart.
func smoothstep(_ lo: Float, _ hi: Float, _ x: Float) -> Float {
guard hi > lo else { return x < lo ? 0 : 1 }
let t = min(1, max(0, (x - lo) / (hi - lo)))
return t * t * (3 - 2 * t)
}
/// Advance the loudness envelope, derive the loudness-response (auto-gain reframed),
/// then for each route accumulate `signal · depth · effGain` additively onto the
/// target's factor (which starts at 1.0). `hasAudio` mirrors the web's `this.audio !== null`.
/// Both level and transient routes share the same `effGain` (no separate transient boost).
func evaluateAudioFactors(bands: AudioBands, reactive: AudioReactive,
autoGain: inout AutoGainState, hasAudio: Bool = true,
tuning: AudioTuning = AudioTuning()) -> AudioFactors {
if hasAudio {
autoGain.loudnessEnv += tuning.envCoef * (bands.loudness - autoGain.loudnessEnv)
}
// Auto-gain reframed as a loudness-response: a slow 0..1 curve on the RAW loudness
// (leise=sanft / laut=voll / Stille=ruhig). Off → always full (1).
let resp = reactive.autoGain
? smoothstep(tuning.gateLo, tuning.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
}