import XCTest
@testable import Physarum
import PhysarumEngine
final class AudioFactorsTests: XCTestCase {
private func reactive(enabled: Bool = true, gain: Float = 1, autoGain: Bool = false,
_ routes: [AudioRoute] = []) -> AudioReactive {
AudioReactive(enabled: enabled, gain: gain, autoGain: autoGain, routes: routes)
}
func testDisabledYieldsAllNeutral() {
var ag = AutoGainState()
let f = evaluateAudioFactors(bands: AudioBands(bass: 1, mid: 1, high: 1),
reactive: reactive(enabled: false), autoGain: &ag)
for t in ModTarget.allCases { XCTAssertEqual(f[t], 1.0, accuracy: 1e-6) }
}
func testSingleRouteAdditiveOverOne() {
var ag = AutoGainState()
let f = evaluateAudioFactors(
bands: AudioBands(bass: 0.5, mid: 0, high: 0),
reactive: reactive(gain: 1, [AudioRoute(source: .bass, target: .moveSpeed, depth: 2)]),
autoGain: &ag)
XCTAssertEqual(f[.moveSpeed], 2.0, accuracy: 1e-5)
XCTAssertEqual(f[.turnSpeed], 1.0, accuracy: 1e-6)
}
func testMultipleRoutesToSameTargetAccumulate() {
var ag = AutoGainState()
let f = evaluateAudioFactors(
bands: AudioBands(bass: 0.5, mid: 0.5, high: 0),
reactive: reactive([AudioRoute(source: .bass, target: .exposure, depth: 1),
AudioRoute(source: .mid, target: .exposure, depth: 1)]),
autoGain: &ag)
XCTAssertEqual(f[.exposure], 1.0 + 0.5 + 0.5, accuracy: 1e-5)
}
func testNegativeDepthSubtracts() {
var ag = AutoGainState()
let f = evaluateAudioFactors(
bands: AudioBands(bass: 0.5, mid: 0, high: 0),
reactive: reactive([AudioRoute(source: .bass, target: .contrast, depth: -1)]),
autoGain: &ag)
XCTAssertEqual(f[.contrast], 0.5, accuracy: 1e-5)
}
func testLoudnessResponseGatesSilence() {
var ag = AutoGainState()
let r = reactive(gain: 1, autoGain: true,
[AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.0 // silent overall
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 = reactive(gain: 1, autoGain: true,
[AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
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 = reactive(gain: 1, autoGain: false,
[AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
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
}
func testAgentAndPostAdapters() {
var f = AudioFactors()
f[.moveSpeed] = 1.5; f[.bloomIntensity] = 2.0
XCTAssertEqual(f.agentUniform.moveSpeedFactor, 1.5, accuracy: 1e-6)
XCTAssertEqual(f.agentUniform.turnSpeedFactor, 1.0, accuracy: 1e-6)
XCTAssertEqual(f.postBloomIntensity, 2.0, accuracy: 1e-6)
XCTAssertEqual(f.postExposure, 1.0, accuracy: 1e-6)
}
func testLoudnessEnvAdvancesEvenWhenDisabled() {
var ag = AutoGainState() // loudnessEnv starts 0
var b = AudioBands(bass: 1, mid: 1, high: 1); b.loudness = 1
_ = evaluateAudioFactors(bands: b, reactive: reactive(enabled: false), autoGain: &ag)
// The loudness envelope advances whenever hasAudio is true, regardless of
// `enabled` (only the route loop is gated on enabled).
// Uses AudioTuning().envCoef — canary: fires if the shipped default changes without updating this test.
XCTAssertEqual(ag.loudnessEnv, AudioTuning().envCoef * 1.0, accuracy: 1e-6)
}
func testHasAudioFalseSkipsEnvAndRoutes() {
var ag = AutoGainState()
var b = AudioBands(bass: 1, mid: 1, high: 1); b.loudness = 1
let f = evaluateAudioFactors(
bands: b,
reactive: reactive(gain: 1, [AudioRoute(source: .bass, target: .moveSpeed, depth: 2)]),
autoGain: &ag, hasAudio: false)
XCTAssertEqual(ag.loudnessEnv, 0, accuracy: 1e-9) // env NOT advanced
XCTAssertEqual(f[.moveSpeed], 1.0, accuracy: 1e-6) // route loop suppressed
}
func testTransientModeUsesTransientSignal() {
var ag = AutoGainState()
var bands = AudioBands(bass: 0.9, mid: 0, high: 0)
bands.transientBass = 0.1
let r = AudioReactive(enabled: true, gain: 1, autoGain: false,
routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 2, mode: .transient)])
let f = evaluateAudioFactors(bands: bands, reactive: r, autoGain: &ag)
XCTAssertEqual(f[.moveSpeed], 1.0 + 0.1 * 2, accuracy: 1e-5)
}
func testLevelModeUnchanged() {
var ag = AutoGainState()
var bands = AudioBands(bass: 0.5, mid: 0, high: 0)
bands.transientBass = 0.9
let r = AudioReactive(enabled: true, gain: 1, autoGain: false,
routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 2, mode: .level)])
let f = evaluateAudioFactors(bands: bands, reactive: r, autoGain: &ag)
XCTAssertEqual(f[.moveSpeed], 1.0 + 0.5 * 2, accuracy: 1e-5)
}
// MARK: - AudioTuning (device-tuning rig)
/// The AudioTuning defaults ARE the shipped loudness-response constants — the contract
/// that guarantees zero behaviour drift when the rig is untouched (release, all tests).
func testTuningDefaultsMatchShippedConstants() {
let t = AudioTuning()
XCTAssertEqual(t.envCoef, 0.15, accuracy: 1e-9)
XCTAssertEqual(t.gateLo, 0.04, accuracy: 1e-9)
XCTAssertEqual(t.gateHi, 0.22, accuracy: 1e-9)
}
/// Calling with the defaulted `tuning` param must be identical to passing AudioTuning().
func testDefaultTuningReproducesPreRigCurve() {
let r = reactive(gain: 1, autoGain: true,
[AudioRoute(source: .bass, target: .moveSpeed, depth: 1)])
var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.3
var agImplicit = AutoGainState()
var agExplicit = AutoGainState()
for _ in 0..<300 {
_ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agImplicit)
_ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agExplicit, tuning: AudioTuning())
}
let fImplicit = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agImplicit)
let fExplicit = evaluateAudioFactors(bands: b, reactive: r, autoGain: &agExplicit, tuning: AudioTuning())
XCTAssertEqual(fImplicit[.moveSpeed], fExplicit[.moveSpeed], accuracy: 1e-9)
XCTAssertGreaterThan(fImplicit[.moveSpeed], 1.5) // discriminating: fails if both paths degenerate to neutral
}
/// An injected tuning actually moves the live response curve: a lower `gateHi`
/// reaches "full" at a quieter loudness, so the same input yields a stronger factor.
func testInjectedTuningMovesResponseCurve() {
let r = reactive(gain: 1, autoGain: true,
[AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .level)])
var b = AudioBands(bass: 1, mid: 0, high: 0); b.loudness = 0.15
func warmedResponse(_ tuning: AudioTuning) -> Float {
var ag = AutoGainState()
for _ in 0..<400 { _ = evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag, tuning: tuning) }
return evaluateAudioFactors(bands: b, reactive: r, autoGain: &ag, tuning: tuning)[.moveSpeed]
}
let saturated = warmedResponse(AudioTuning(envCoef: 0.15, gateLo: 0, gateHi: 0.1)) // env 0.15 > hi → full
let gated = warmedResponse(AudioTuning(envCoef: 0.15, gateLo: 0.04, gateHi: 0.6)) // env 0.15 in ramp → partial
XCTAssertGreaterThan(saturated, gated + 0.3, "lower gateHi must yield a stronger response")
XCTAssertEqual(saturated, 2.0, accuracy: 0.05) // fully on: 1 + 1*1*1
}
}