Zum Inhalt

apple/PhysarumApp/Sources/Audio/AudioFactors.swift

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
}