diff --git a/FineTune/Audio/EQ/BiquadMath.swift b/FineTune/Audio/EQ/BiquadMath.swift index 98e0dc99..a3b097f7 100644 --- a/FineTune/Audio/EQ/BiquadMath.swift +++ b/FineTune/Audio/EQ/BiquadMath.swift @@ -89,6 +89,28 @@ enum BiquadMath { return [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0] } + /// Compute low-pass biquad coefficients (RBJ cookbook, 2nd-order Butterworth) + /// Returns [b0, b1, b2, a1, a2] normalized by a0 for vDSP_biquad + static func lowPassCoefficients( + frequency: Double, + q: Double, + sampleRate: Double + ) -> [Double] { + let omega = 2.0 * .pi * frequency / sampleRate + let sinW = sin(omega) + let cosW = cos(omega) + let alpha = sinW / (2.0 * q) + + let b0 = (1.0 - cosW) / 2.0 + let b1 = 1.0 - cosW + let b2 = (1.0 - cosW) / 2.0 + let a0 = 1.0 + alpha + let a1 = -2.0 * cosW + let a2 = 1.0 - alpha + + return [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0] + } + /// Compute high-pass biquad coefficients (RBJ cookbook, 2nd-order Butterworth) /// Returns [b0, b1, b2, a1, a2] normalized by a0 for vDSP_biquad static func highPassCoefficients( diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index cd3d07e6..11d3826b 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -329,7 +329,8 @@ final class AudioEngine { } tap.updateLoudnessCompensation( volume: self.effectiveLoudnessVolume(for: tap), - enabled: loudnessEnabled + enabled: loudnessEnabled, + intensity: self.settingsManager.appSettings.loudnessCompensationIntensity ) } } @@ -627,7 +628,11 @@ final class AudioEngine { applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: tap.currentDeviceUIDs) tap.updateEQSettings(.flat) tap.updateAutoEQProfile(nil) - tap.updateLoudnessCompensation(volume: effectiveLoudnessVolume(for: tap), enabled: false) + tap.updateLoudnessCompensation( + volume: effectiveLoudnessVolume(for: tap), + enabled: false, + intensity: settingsManager.appSettings.loudnessCompensationIntensity + ) } // 6. Re-apply from clean settings (re-establishes routing to system default) @@ -646,7 +651,8 @@ final class AudioEngine { if settingsManager.appSettings.loudnessCompensationEnabled { tap.updateLoudnessCompensation( volume: effectiveLoudnessVolume(for: tap), - enabled: true + enabled: true, + intensity: settingsManager.appSettings.loudnessCompensationIntensity ) } } @@ -795,8 +801,23 @@ final class AudioEngine { } func setLoudnessCompensationEnabled(_ enabled: Bool) { + let intensity = settingsManager.loudnessCompensationIntensity for tap in taps.values { - tap.updateLoudnessCompensation(volume: effectiveLoudnessVolume(for: tap), enabled: enabled) + tap.updateLoudnessCompensation( + volume: effectiveLoudnessVolume(for: tap), + enabled: enabled, + intensity: intensity + ) + } + } + + func setLoudnessCompensationIntensity(_ intensity: Float) { + for tap in taps.values { + tap.updateLoudnessCompensation( + volume: effectiveLoudnessVolume(for: tap), + enabled: settingsManager.appSettings.loudnessCompensationEnabled, + intensity: intensity + ) } } @@ -808,6 +829,12 @@ final class AudioEngine { } } + func setLoudnessEqualizationIntensity(_ intensity: Float) { + for tap in taps.values { + tap.setLoudnessEqualizationIntensity(intensity) + } + } + /// Apply AutoEQ profile to all taps currently routed to the given device. private func applyAutoEQToTaps(for deviceUID: String) { for tap in taps.values { @@ -832,6 +859,7 @@ final class AudioEngine { autoEQPreampEnabled: settingsManager.autoEQPreampEnabled, loudnessVolume: deviceVolume * volumeState.getVolume(for: app.id), loudnessCompensationEnabled: settingsManager.appSettings.loudnessCompensationEnabled, + loudnessCompensationIntensity: settingsManager.appSettings.loudnessCompensationIntensity, loudnessEqualizerSettings: loudnessEqSettings ) } @@ -948,6 +976,8 @@ final class AudioEngine { self.logger.debug("Switched \(app.name) to device: \(targetUID)") } catch { self.logger.error("Failed to switch device for \(app.name): \(error.localizedDescription)") + self.logger.info("Falling back to recreateTap for \(app.name)") + await self.recreateTap(for: app.id) } } } else { @@ -1067,7 +1097,12 @@ final class AudioEngine { try tap.activate(initial: initial) taps[app.id] = tap - // Catalog AutoEQ may not have been cached yet — kick off async resolve. + tap.setLoudnessEqualizationIntensity(settingsManager.appSettings.loudnessEqualizationIntensity) + tap.updateLoudnessCompensation( + volume: effectiveLoudnessVolume(for: tap), + enabled: settingsManager.appSettings.loudnessCompensationEnabled, + intensity: settingsManager.appSettings.loudnessCompensationIntensity + ) if initial.autoEQProfile == nil { applyAutoEQToTap(tap) } @@ -1183,6 +1218,8 @@ final class AudioEngine { self.applyAutoEQToTap(existingTap) } catch { self.logger.error("Failed to re-route \(app.name) to \(deviceUID): \(error.localizedDescription)") + self.logger.info("Falling back to recreateTap for \(app.name)") + await self.recreateTap(for: app.id) } } appliedPIDs.insert(app.id) @@ -1228,8 +1265,12 @@ final class AudioEngine { try tap.activate(initial: initial) taps[app.id] = tap - // Catalog AutoEQ may not have been cached yet — kick off async resolve. - // Imported profiles always hit the synchronous path above. + tap.setLoudnessEqualizationIntensity(settingsManager.appSettings.loudnessEqualizationIntensity) + tap.updateLoudnessCompensation( + volume: effectiveLoudnessVolume(for: tap), + enabled: settingsManager.appSettings.loudnessCompensationEnabled, + intensity: settingsManager.appSettings.loudnessCompensationIntensity + ) if initial.autoEQProfile == nil { applyAutoEQToTap(tap) } @@ -1328,6 +1369,8 @@ final class AudioEngine { self.applyAutoEQToTap(tap) } catch { self.logger.error("Failed to switch \(app.name) to \(targetUID): \(error.localizedDescription)") + self.logger.info("Falling back to recreateTap for \(app.name)") + await self.recreateTap(for: app.id) } } } @@ -1408,6 +1451,8 @@ final class AudioEngine { self.applyAutoEQToTap(tap) } catch { self.logger.error("Failed to switch \(tap.app.name) to fallback: \(error.localizedDescription)") + self.logger.info("Falling back to recreateTap for \(tap.app.name)") + await self.recreateTap(for: tap.app.id) } } @@ -1421,6 +1466,8 @@ final class AudioEngine { self.logger.debug("Removed \(deviceName) from \(tap.app.name) multi-device output") } catch { self.logger.error("Failed to update \(tap.app.name) devices: \(error.localizedDescription)") + self.logger.info("Falling back to recreateTap for \(tap.app.name) (multi-mode)") + await self.recreateTap(for: tap.app.id, overridingDeviceUIDs: remainingUIDs) } } } @@ -1480,6 +1527,8 @@ final class AudioEngine { self.applyAutoEQToTap(tap) } catch { self.logger.error("Failed to switch \(tap.app.name) back to \(deviceName): \(error.localizedDescription)") + self.logger.info("Falling back to recreateTap for \(tap.app.name)") + await self.recreateTap(for: tap.app.id) } } } @@ -1543,6 +1592,10 @@ final class AudioEngine { // macOS already auto-switched to the lower-priority device — restore // what the user was on (not highest priority — they may have chosen a mid-priority device) restoreConfirmedDefault() + } else if currentDefault == deviceUID { + // The reconnected device is the current default (e.g. macOS auto-switched to a higher-priority device) + // Route follows-default apps to it in case we deferred it earlier. + routeFollowsDefaultApps(to: deviceUID) } // Cancel any existing PENDING_AUTOSWITCH before entering a new one. @@ -1941,9 +1994,9 @@ final class AudioEngine { /// Tears down and recreates a tap for a given PID, preserving routing and settings. /// Async: awaits full CoreAudio resource teardown before creating the replacement tap /// to prevent orphaned IO procs from accumulating (issue #176). - private func recreateTap(for pid: pid_t) async { + private func recreateTap(for pid: pid_t, overridingDeviceUIDs: [String]? = nil) async { guard let oldTap = taps.removeValue(forKey: pid) else { return } - let deviceUIDs = oldTap.currentDeviceUIDs + let deviceUIDs = overridingDeviceUIDs ?? oldTap.currentDeviceUIDs await oldTap.invalidateAsync() // Set cooldown to prevent thrashing diff --git a/FineTune/Audio/Engine/ProcessTapController.swift b/FineTune/Audio/Engine/ProcessTapController.swift index 6d5492de..62c11706 100644 --- a/FineTune/Audio/Engine/ProcessTapController.swift +++ b/FineTune/Audio/Engine/ProcessTapController.swift @@ -77,6 +77,9 @@ final class ProcessTapController: ProcessTapControlling { /// Set once any audio callback has rendered at least one buffer. private nonisolated(unsafe) var _hasRenderedAudio: Bool = false + /// Last AGC intensity setting, applied to newly created LoudnessEqualizer instances. + private var _lastAgcIntensity: Float = 1.0 + /// Callback role identification — RT-safe via atomic UInt32 reads. /// Each IO proc closure captures an immutable callbackID at creation. /// The callback compares against these to determine primary/secondary role. @@ -125,9 +128,11 @@ final class ProcessTapController: ProcessTapControlling { private nonisolated(unsafe) var autoEQProcessor: AutoEQProcessor? private nonisolated(unsafe) var loudnessCompensator: LoudnessCompensator? private nonisolated(unsafe) var loudnessEqualizerProcessor: LoudnessEqualizer? + private nonisolated(unsafe) var postAgcCompressorProcessor: PostAgcCompressor? /// Last effective loudness volume (device × app) passed to updateLoudnessCompensation. /// Used by createSecondaryTap to initialize secondary compensator with the correct volume. private var _lastLoudnessVolume: Float = 1.0 + private var _lastLoudnessIntensity: Float = 1.0 /// Independent EQ processors for secondary tap during crossfade. /// Each tap needs its own biquad delay buffers — sharing would corrupt filter state /// because both callbacks write concurrently from different HAL I/O threads. @@ -135,6 +140,7 @@ final class ProcessTapController: ProcessTapControlling { private nonisolated(unsafe) var secondaryAutoEQProcessor: AutoEQProcessor? private nonisolated(unsafe) var secondaryLoudnessCompensator: LoudnessCompensator? private nonisolated(unsafe) var secondaryLoudnessEqualizerProcessor: LoudnessEqualizer? + private nonisolated(unsafe) var secondaryPostAgcCompressorProcessor: PostAgcCompressor? // Target device UIDs for synchronized multi-output (first is clock source) private var targetDeviceUIDs: [String] @@ -256,11 +262,12 @@ final class ProcessTapController: ProcessTapControlling { secondaryAutoEQProcessor?.setPreampEnabled(enabled) } - func updateLoudnessCompensation(volume: Float, enabled: Bool) { + func updateLoudnessCompensation(volume: Float, enabled: Bool, intensity: Float = 1.0) { _lastLoudnessVolume = volume + _lastLoudnessIntensity = intensity if enabled { - loudnessCompensator?.updateForVolume(volume) - secondaryLoudnessCompensator?.updateForVolume(volume) + loudnessCompensator?.updateForVolume(volume, intensity: intensity) + secondaryLoudnessCompensator?.updateForVolume(volume, intensity: intensity) } else { loudnessCompensator?.setEnabled(false) secondaryLoudnessCompensator?.setEnabled(false) @@ -274,20 +281,48 @@ final class ProcessTapController: ProcessTapControlling { // RT-thread process() calls. if let sampleRate = try? primaryResources.aggregateDeviceID.readNominalSampleRate() { let newProcessor = LoudnessEqualizer(settings: settings, sampleRate: Float(sampleRate)) + newProcessor.setIntensity(_lastAgcIntensity) let old = loudnessEqualizerProcessor loudnessEqualizerProcessor = newProcessor if let old { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { _ = old } } + + // Create Post AGC Compressor alongside AGC (auto-enabled when AGC is enabled) + var compressorSettings = PostAgcCompressorSettings() + compressorSettings.enabled = settings.enabled + let newCompressor = PostAgcCompressor(settings: compressorSettings, sampleRate: Float(sampleRate)) + let oldCompressor = postAgcCompressorProcessor + postAgcCompressorProcessor = newCompressor + if let oldCompressor { + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { _ = oldCompressor } + } } if let secondary = secondaryLoudnessEqualizerProcessor, let sampleRate = try? secondaryResources.aggregateDeviceID.readNominalSampleRate() { let newSecondary = LoudnessEqualizer(settings: settings, sampleRate: Float(sampleRate)) + newSecondary.setIntensity(_lastAgcIntensity) secondaryLoudnessEqualizerProcessor = newSecondary DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { _ = secondary } + + // Secondary Post AGC Compressor + var secCompressorSettings = PostAgcCompressorSettings() + secCompressorSettings.enabled = settings.enabled + let newSecCompressor = PostAgcCompressor(settings: secCompressorSettings, sampleRate: Float(sampleRate)) + let oldSecCompressor = secondaryPostAgcCompressorProcessor + secondaryPostAgcCompressorProcessor = newSecCompressor + if let oldSecCompressor { + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { _ = oldSecCompressor } + } } } + func setLoudnessEqualizationIntensity(_ intensity: Float) { + _lastAgcIntensity = intensity + loudnessEqualizerProcessor?.setIntensity(intensity) + secondaryLoudnessEqualizerProcessor?.setIntensity(intensity) + } + // MARK: - Multi-Device Aggregate Configuration /// Resolved plan for FineTune's private wrapping aggregate: which hardware sub-devices @@ -641,6 +676,7 @@ final class ProcessTapController: ProcessTapControlling { eqProcessor = EQProcessor(sampleRate: sampleRate) autoEQProcessor = AutoEQProcessor(sampleRate: sampleRate) loudnessEqualizerProcessor = LoudnessEqualizer(settings: initial.loudnessEqualizerSettings, sampleRate: Float(sampleRate)) + postAgcCompressorProcessor = PostAgcCompressor(settings: PostAgcCompressorSettings(), sampleRate: Float(sampleRate)) loudnessCompensator = LoudnessCompensator(sampleRate: sampleRate) // Apply persisted state to fresh processors before AudioDeviceStart so the @@ -652,9 +688,10 @@ final class ProcessTapController: ProcessTapControlling { } loudnessCompensator?.setEnabled(initial.loudnessCompensationEnabled) if initial.loudnessCompensationEnabled { - loudnessCompensator?.updateForVolume(initial.loudnessVolume) + loudnessCompensator?.updateForVolume(initial.loudnessVolume, intensity: initial.loudnessCompensationIntensity) } _lastLoudnessVolume = initial.loudnessVolume + _lastLoudnessIntensity = initial.loudnessCompensationIntensity // Create IO proc with gain processing nextCallbackID += 1 @@ -864,6 +901,9 @@ final class ProcessTapController: ProcessTapControlling { secondaryAutoEQProcessor = nil secondaryLoudnessCompensator = nil secondaryLoudnessEqualizerProcessor = nil + secondaryPostAgcCompressorProcessor = nil + loudnessEqualizerProcessor = nil + postAgcCompressorProcessor = nil _invalidating = false } @@ -1029,8 +1069,11 @@ final class ProcessTapController: ProcessTapControlling { let secLoudnessEqualizer = LoudnessEqualizer(settings: loudnessEqualizerProcessor?.currentSettings ?? LoudnessEqualizerSettings(), sampleRate: Float(sampleRate)) secondaryLoudnessEqualizerProcessor = secLoudnessEqualizer + let secPostAgcCompressor = PostAgcCompressor(settings: postAgcCompressorProcessor?.currentSettings ?? PostAgcCompressorSettings(), sampleRate: Float(sampleRate)) + secondaryPostAgcCompressorProcessor = secPostAgcCompressor + let secLoudness = LoudnessCompensator(sampleRate: sampleRate) - secLoudness.updateForVolume(_lastLoudnessVolume) + secLoudness.updateForVolume(_lastLoudnessVolume, intensity: _lastLoudnessIntensity) if !(loudnessCompensator?.isEnabled ?? false) { secLoudness.setEnabled(false) } secondaryLoudnessCompensator = secLoudness @@ -1077,6 +1120,7 @@ final class ProcessTapController: ProcessTapControlling { secondaryAutoEQProcessor = nil secondaryLoudnessCompensator = nil secondaryLoudnessEqualizerProcessor = nil + secondaryPostAgcCompressorProcessor = nil } private func promoteSecondaryToPrimary() { @@ -1095,24 +1139,28 @@ final class ProcessTapController: ProcessTapControlling { let oldAutoEQ = autoEQProcessor let oldLoudness = loudnessCompensator let oldLoudnessEqualizer = loudnessEqualizerProcessor + let oldPostAgcCompressor = postAgcCompressorProcessor eqProcessor = secondaryEQProcessor autoEQProcessor = secondaryAutoEQProcessor loudnessCompensator = secondaryLoudnessCompensator loudnessEqualizerProcessor = secondaryLoudnessEqualizerProcessor + postAgcCompressorProcessor = secondaryPostAgcCompressorProcessor secondaryEQProcessor = nil secondaryAutoEQProcessor = nil secondaryLoudnessCompensator = nil secondaryLoudnessEqualizerProcessor = nil + secondaryPostAgcCompressorProcessor = nil // Deferred cleanup: hold old processors alive briefly so any in-flight RT callback // that read the pointer before the swap finishes its buffer without accessing freed memory. // 0.5s is conservative — audio callbacks run at ~5ms intervals. - if oldEQ != nil || oldAutoEQ != nil || oldLoudness != nil || oldLoudnessEqualizer != nil { + if oldEQ != nil || oldAutoEQ != nil || oldLoudness != nil || oldLoudnessEqualizer != nil || oldPostAgcCompressor != nil { DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { _ = oldEQ _ = oldAutoEQ _ = oldLoudness _ = oldLoudnessEqualizer + _ = oldPostAgcCompressor } } @@ -1257,6 +1305,16 @@ final class ProcessTapController: ProcessTapControlling { loudnessEqualizerProcessor = newLE DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { _ = oldLE } } + + // Post AGC Compressor is also immutable — swap to new instance at new sample rate + if let oldPAC = postAgcCompressorProcessor { + let newPAC = PostAgcCompressor( + settings: oldPAC.currentSettings, + sampleRate: Float(deviceSampleRate) + ) + postAgcCompressorProcessor = newPAC + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { _ = oldPAC } + } } } @@ -1328,6 +1386,7 @@ final class ProcessTapController: ProcessTapControlling { eqProc: EQProcessor?, autoEQProc: AutoEQProcessor?, loudnessEqualizerProc: LoudnessEqualizer?, + postAgcCompressorProc: PostAgcCompressor?, loudnessCompensatorProc: LoudnessCompensator? ) { let inputBufferCount = inputBuffers.count @@ -1456,11 +1515,16 @@ final class ProcessTapController: ProcessTapControlling { autoEQProc.process(input: outputSamples, output: outputSamples, frameCount: frameCount) } - // Loudness Equalization (before loudness compensation) + // Loudness Equalization (before post-AGC compressor) if let loudnessEqualizerProc, loudnessEqualizerProc.isEnabled, eqCanProcessStereoInterleaved { loudnessEqualizerProc.process(input: UnsafePointer(outputSamples), output: outputSamples, frameCount: frameCount, channelCount: outputChannels) } + // Post AGC Compressor (catches transient overshoots that slow AGC misses) + if let postAgcCompressorProc, postAgcCompressorProc.isEnabled, eqCanProcessStereoInterleaved { + postAgcCompressorProc.process(input: UnsafePointer(outputSamples), output: outputSamples, frameCount: frameCount, channelCount: outputChannels) + } + // Loudness compensation (after all EQ, before limiting) if let loudnessCompensatorProc, loudnessCompensatorProc.isEnabled, eqCanProcessStereoInterleaved { loudnessCompensatorProc.process(input: outputSamples, output: outputSamples, frameCount: frameCount) @@ -1588,6 +1652,7 @@ final class ProcessTapController: ProcessTapControlling { let eqProc: EQProcessor? let autoEQProc: AutoEQProcessor? let loudnessEqualizerProc: LoudnessEqualizer? + let postAgcCompressorProc: PostAgcCompressor? let loudnessCompensatorProc: LoudnessCompensator? if isPrimary { @@ -1602,6 +1667,7 @@ final class ProcessTapController: ProcessTapControlling { eqProc = eqProcessor autoEQProc = autoEQProcessor loudnessEqualizerProc = loudnessEqualizerProcessor + postAgcCompressorProc = postAgcCompressorProcessor loudnessCompensatorProc = loudnessCompensator } else { currentVol = _secondaryCurrentVolume @@ -1614,6 +1680,7 @@ final class ProcessTapController: ProcessTapControlling { eqProc = secondaryEQProcessor autoEQProc = secondaryAutoEQProcessor loudnessEqualizerProc = secondaryLoudnessEqualizerProcessor + postAgcCompressorProc = secondaryPostAgcCompressorProcessor loudnessCompensatorProc = secondaryLoudnessCompensator } @@ -1630,6 +1697,7 @@ final class ProcessTapController: ProcessTapControlling { eqProc: eqProc, autoEQProc: autoEQProc, loudnessEqualizerProc: loudnessEqualizerProc, + postAgcCompressorProc: postAgcCompressorProc, loudnessCompensatorProc: loudnessCompensatorProc ) diff --git a/FineTune/Audio/Engine/ProcessTapControlling.swift b/FineTune/Audio/Engine/ProcessTapControlling.swift index 2409798f..36c4d708 100644 --- a/FineTune/Audio/Engine/ProcessTapControlling.swift +++ b/FineTune/Audio/Engine/ProcessTapControlling.swift @@ -22,8 +22,9 @@ protocol ProcessTapControlling: AnyObject, Sendable { func updateEQSettings(_ settings: EQSettings) func updateAutoEQProfile(_ profile: AutoEQProfile?) func setAutoEQPreampEnabled(_ enabled: Bool) - func updateLoudnessCompensation(volume: Float, enabled: Bool) + func updateLoudnessCompensation(volume: Float, enabled: Bool, intensity: Float) func updateLoudnessEqualization(_ settings: LoudnessEqualizerSettings) + func setLoudnessEqualizationIntensity(_ intensity: Float) func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws func hasRecentAudioCallback(within seconds: Double) -> Bool @@ -56,6 +57,14 @@ extension ProcessTapControlling { invalidate() } + func updateLoudnessCompensation(volume: Float, enabled: Bool) { + updateLoudnessCompensation(volume: volume, enabled: enabled, intensity: 1.0) + } + + func setLoudnessEqualizationIntensity(_ intensity: Float) { + // Default no-op for mocks + } + func refreshTapSource(_ preferredDeviceUID: String?) async throws { // Default no-op for mocks that don't override } diff --git a/FineTune/Audio/Engine/TapInitialState.swift b/FineTune/Audio/Engine/TapInitialState.swift index 32464976..139acdd7 100644 --- a/FineTune/Audio/Engine/TapInitialState.swift +++ b/FineTune/Audio/Engine/TapInitialState.swift @@ -8,5 +8,6 @@ struct TapInitialState { var autoEQPreampEnabled: Bool = false var loudnessVolume: Float = 1.0 var loudnessCompensationEnabled: Bool = false + var loudnessCompensationIntensity: Float = 1.0 var loudnessEqualizerSettings: LoudnessEqualizerSettings = .init() } diff --git a/FineTune/Audio/Loudness/AgcPhonOffsetSmoother.swift b/FineTune/Audio/Loudness/AgcPhonOffsetSmoother.swift new file mode 100644 index 00000000..1aae9288 --- /dev/null +++ b/FineTune/Audio/Loudness/AgcPhonOffsetSmoother.swift @@ -0,0 +1,90 @@ +// FineTune/Audio/Loudness/AgcPhonOffsetSmoother.swift +import Foundation + +/// Smooths AGC gain for use as a phon offset in loudness compensation. +/// +/// The AGC's own time constants (6 dB/s attack, 2.7 dB/s release) are designed +/// for compression — they track individual transients. Using raw AGC gain as a +/// phon offset would cause audible bass-boost pumping with program dynamics. +/// +/// This smoother adds a second, much longer time constant layer: +/// - **3 s attack** (EBU R128 Short-term loudness): tracks content-type changes +/// smoothly, ignoring individual phrases and transients. +/// - **10 s release**: humans weight louder portions more heavily (Dolby ASA +/// patent US11362631), so compensation holds during brief quiet passages. +/// - **Fast path** for large changes (>6 dB): 1 s attack / 3 s release — +/// adapts faster when AGC gain changes dramatically (e.g., source switch). +/// +/// **Threading:** `process` runs on the main thread (during periodic polling). +/// `currentOffset` is read on the main thread. No RT-thread interaction. +final class AgcPhonOffsetSmoother: @unchecked Sendable { + + // MARK: - Coefficients + + /// Normal attack coefficient (3 s time constant). + private let attackCoeff: Float + /// Normal release coefficient (10 s time constant). + private let releaseCoeff: Float + /// Fast attack coefficient (1 s time constant, for large jumps). + private let fastAttackCoeff: Float + /// Fast release coefficient (3 s time constant, for large drops). + private let fastReleaseCoeff: Float + + /// Threshold in dB above which the fast-path time constants are used. + private let fastThresholdDb: Float = 6.0 + + // MARK: - State + + /// Current smoothed gain value in dB. + private var smoothedDb: Float = 0 + + // MARK: - Output + + /// Current smoothed AGC gain offset in dB (always ≤ 0, since AGC is downward-only). + var currentOffset: Float { smoothedDb } + + // MARK: - Init + + /// Create a smoother with time constants calibrated for the polling interval. + /// + /// - Parameter pollIntervalMs: The interval at which `process` will be called, + /// in milliseconds. Coefficients are computed relative to this interval. + init(pollIntervalMs: Float = 200) { + let stepMs = pollIntervalMs + attackCoeff = 1 - exp(-stepMs / 3000.0) + releaseCoeff = 1 - exp(-stepMs / 10000.0) + fastAttackCoeff = 1 - exp(-stepMs / 1000.0) + fastReleaseCoeff = 1 - exp(-stepMs / 3000.0) + } + + // MARK: - Processing + + /// Process a new raw AGC gain value, returning the smoothed offset. + /// + /// Call this at the polling interval (e.g., every 200 ms from AudioEngine). + /// + /// - Parameter rawGainDb: The AGC's current gain in dB (always ≤ 0). + /// - Returns: The smoothed gain offset in dB. + @discardableResult + func process(_ rawGainDb: Float) -> Float { + let delta = rawGainDb - smoothedDb + let absDelta = abs(delta) + + let coeff: Float + if delta >= 0 { + // Gain increasing (becoming less negative — AGC releasing, signal getting quieter) + coeff = absDelta > fastThresholdDb ? fastAttackCoeff : attackCoeff + } else { + // Gain decreasing (becoming more negative — AGC attacking, signal getting louder) + coeff = absDelta > fastThresholdDb ? fastReleaseCoeff : releaseCoeff + } + + smoothedDb += coeff * delta + return smoothedDb + } + + /// Reset the smoother state (e.g., when AGC is toggled off/on). + func reset() { + smoothedDb = 0 + } +} diff --git a/FineTune/Audio/Loudness/GainComputer.swift b/FineTune/Audio/Loudness/GainComputer.swift deleted file mode 100644 index b6cd6bd6..00000000 --- a/FineTune/Audio/Loudness/GainComputer.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation - -/// Computes the desired gain in dB given a smoothed loudness level. -/// Pure, stateless value type — RT-safe. -struct GainComputer: Sendable { - let settings: LoudnessEqualizerSettings - - /// Returns the desired gain (dB) for a wideband leveler that boosts quiet material - /// and applies a gentle soft-knee cut to louder passages. - func desiredGainDb(forLevelDb smoothedLevelDb: Float) -> Float { - let boost = desiredBoostDb(forLevelDb: smoothedLevelDb) - let cut = desiredCutDb(forLevelDb: smoothedLevelDb) - return boost + cut - } - - private func desiredBoostDb(forLevelDb smoothedLevelDb: Float) -> Float { - let raw = settings.targetLoudnessDb - smoothedLevelDb - var clamped = LoudnessEqualizerMath.clamp(raw, min: 0, max: settings.maxBoostDb) - - // Step 3: noise-floor protection — cap upward gain when signal is very quiet - if smoothedLevelDb < settings.noiseFloorThresholdDb, clamped > 0 { - clamped = min(clamped, settings.lowLevelMaxBoostDb) - } - - return clamped - } - - private func desiredCutDb(forLevelDb smoothedLevelDb: Float) -> Float { - guard settings.maxCutDb > 0 else { return 0 } - - let threshold = settings.targetLoudnessDb + settings.compressionThresholdOffsetDb - let ratio = max(settings.compressionRatio, 1) - let kneeWidth = max(settings.compressionKneeDb, 0) - - let compressedLevel = softKneeCompressedLevel( - inputLevelDb: smoothedLevelDb, - thresholdDb: threshold, - ratio: ratio, - kneeWidthDb: kneeWidth - ) - let gainReduction = compressedLevel - smoothedLevelDb - return LoudnessEqualizerMath.clamp(gainReduction, min: -settings.maxCutDb, max: 0) - } - - private func softKneeCompressedLevel( - inputLevelDb: Float, - thresholdDb: Float, - ratio: Float, - kneeWidthDb: Float - ) -> Float { - guard ratio > 1 else { return inputLevelDb } - - if kneeWidthDb <= 0 { - guard inputLevelDb > thresholdDb else { return inputLevelDb } - return thresholdDb + (inputLevelDb - thresholdDb) / ratio - } - - let kneeHalfWidth = kneeWidthDb * 0.5 - let kneeStart = thresholdDb - kneeHalfWidth - let kneeEnd = thresholdDb + kneeHalfWidth - - if inputLevelDb <= kneeStart { - return inputLevelDb - } - - if inputLevelDb >= kneeEnd { - return thresholdDb + (inputLevelDb - thresholdDb) / ratio - } - - let normalizedDistance = inputLevelDb - kneeStart - let quadraticGain = (1 / ratio - 1) * normalizedDistance * normalizedDistance / (2 * kneeWidthDb) - return inputLevelDb + quadraticGain - } -} diff --git a/FineTune/Audio/Loudness/GainSmoother.swift b/FineTune/Audio/Loudness/GainSmoother.swift deleted file mode 100644 index 803dcf0d..00000000 --- a/FineTune/Audio/Loudness/GainSmoother.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -/// Smooths gain changes using asymmetric attack/release time constants. -/// Ticks once per analysis hop. RT-safe — no allocations in `process`. -final class GainSmoother: @unchecked Sendable { - private var settings: LoudnessEqualizerSettings - private var attackCoeff: Float - private var releaseCoeff: Float - private(set) var currentGainDb: Float = 0 - - init(settings: LoudnessEqualizerSettings, sampleRate: Float) { - self.settings = settings - let hopMs = settings.analysisHopMs - self.attackCoeff = LoudnessEqualizerMath.timeConstantCoefficient(timeMs: settings.gainAttackMs, stepMs: hopMs) - self.releaseCoeff = LoudnessEqualizerMath.timeConstantCoefficient(timeMs: settings.gainReleaseMs, stepMs: hopMs) - } - - /// Reset smoother to a known initial gain. - func reset(initialGainDb: Float = 0) { - currentGainDb = initialGainDb - } - - /// Advance one hop toward `targetGainDb`. Returns the current smoothed gain. - func process(targetGainDb: Float) -> Float { - // Attack: target < current means gain is being reduced (signal got louder) — use faster coeff - // Release: target >= current means gain is recovering — use slower coeff - let coeff: Float = targetGainDb < currentGainDb ? attackCoeff : releaseCoeff - currentGainDb += coeff * (targetGainDb - currentGainDb) - return currentGainDb - } -} diff --git a/FineTune/Audio/Loudness/ISO226Contours.swift b/FineTune/Audio/Loudness/ISO226Contours.swift index bd07f14d..7de385e4 100644 --- a/FineTune/Audio/Loudness/ISO226Contours.swift +++ b/FineTune/Audio/Loudness/ISO226Contours.swift @@ -50,11 +50,11 @@ enum ISO226Contours { /// nearly inaudible. Lowering to 80 keeps compensation gains moderate at /// typical listening levels while still providing meaningful bass correction /// at low volumes. - static let defaultReferencePhon: Double = 80.0 + static let defaultReferencePhon: Double = 85.0 private static let referenceFrequencyIndex = 17 private static let referenceSoundPressureSquaredPa: Double = 4e-10 - private static let supportedPhonRange = 20.0...90.0 + private static let supportedPhonRange = 20.0...120.0 private static let estimatedPhonRange = 20.0...defaultReferencePhon // MARK: - Volume → Phon Mapping @@ -63,7 +63,7 @@ enum ISO226Contours { static func estimatedPhon(fromSystemVolume volume: Float) -> Double { let v = Double(max(0.0, min(1.0, volume))) return estimatedPhonRange.lowerBound - + (defaultReferencePhon - estimatedPhonRange.lowerBound) * pow(v, 0.5) + + (defaultReferencePhon - estimatedPhonRange.lowerBound) * v } // MARK: - Normative Contour Computation diff --git a/FineTune/Audio/Loudness/LinkwitzRileyCrossover.swift b/FineTune/Audio/Loudness/LinkwitzRileyCrossover.swift new file mode 100644 index 00000000..cb6de97b --- /dev/null +++ b/FineTune/Audio/Loudness/LinkwitzRileyCrossover.swift @@ -0,0 +1,80 @@ +import Foundation + +struct BiquadSection { + private var b0: Float, b1: Float, b2: Float + private var a1: Float, a2: Float + private var z1: Float = 0, z2: Float = 0 + + init(coefficients: [Double]) { + precondition(coefficients.count == 5) + b0 = Float(coefficients[0]) + b1 = Float(coefficients[1]) + b2 = Float(coefficients[2]) + a1 = Float(coefficients[3]) + a2 = Float(coefficients[4]) + } + + @inline(__always) + mutating func process(_ sample: Float) -> Float { + let y = b0 * sample + z1 + z1 = b1 * sample - a1 * y + z2 + z2 = b2 * sample - a2 * y + return y + } + + mutating func reset() { + z1 = 0; z2 = 0 + } +} + +struct LR4Filter { + private var stage1: BiquadSection + private var stage2: BiquadSection + + init(lowPass frequency: Double, sampleRate: Double) { + let q = 1.0 / sqrt(2.0) + let coeffs1 = BiquadMath.lowPassCoefficients(frequency: frequency, q: q, sampleRate: sampleRate) + let coeffs2 = BiquadMath.lowPassCoefficients(frequency: frequency, q: q, sampleRate: sampleRate) + stage1 = BiquadSection(coefficients: coeffs1) + stage2 = BiquadSection(coefficients: coeffs2) + } + + init(highPass frequency: Double, sampleRate: Double) { + let q = 1.0 / sqrt(2.0) + let coeffs1 = BiquadMath.highPassCoefficients(frequency: frequency, q: q, sampleRate: sampleRate) + let coeffs2 = BiquadMath.highPassCoefficients(frequency: frequency, q: q, sampleRate: sampleRate) + stage1 = BiquadSection(coefficients: coeffs1) + stage2 = BiquadSection(coefficients: coeffs2) + } + + @inline(__always) + mutating func process(_ sample: Float) -> Float { + return stage2.process(stage1.process(sample)) + } + + mutating func reset() { + stage1.reset(); stage2.reset() + } +} + +struct LinkwitzRileyCrossover2 { + private var lp4: LR4Filter + private var hp4: LR4Filter + + init(frequency: Double, sampleRate: Double) { + lp4 = LR4Filter(lowPass: frequency, sampleRate: sampleRate) + hp4 = LR4Filter(highPass: frequency, sampleRate: sampleRate) + } + + @inline(__always) + mutating func process(_ sample: Float) -> (Float, Float) { + let low = lp4.process(sample) + let high = hp4.process(sample) + return (low, high) + } + + mutating func reset() { + lp4.reset() + hp4.reset() + } +} diff --git a/FineTune/Audio/Loudness/LoudnessCompensator.swift b/FineTune/Audio/Loudness/LoudnessCompensator.swift index 7dadcf50..8d82c348 100644 --- a/FineTune/Audio/Loudness/LoudnessCompensator.swift +++ b/FineTune/Audio/Loudness/LoudnessCompensator.swift @@ -7,8 +7,9 @@ import Accelerate /// to bass and treble at low listening levels. At the reference level (~80 phon), /// compensation is flat (bypassed). At lower levels, the contour difference is /// normalized around 1 kHz so only spectral balance is corrected. The app then fits -/// that target curve with a low-cost four-section shelf/bell cascade. The downstream -/// SoftLimiter handles any peaks that exceed unity after EQ boost. +/// that target curve with a low-cost four-section shelf/bell cascade. +/// Headroom is computed from the realized cascade response and subtracted from all +/// band gains so the cascade peak never exceeds 0 dBFS. /// /// Subclass of `BiquadProcessor` — inherits atomic setup swaps, stereo biquad processing, /// delay buffer management, and NaN safety. Follows the same pattern as `EQProcessor`. @@ -45,7 +46,8 @@ final class LoudnessCompensator: BiquadProcessor, @unchecked Sendable { // MARK: - State /// Phon level used for the last coefficient computation. - private var _currentPhon: Double = 80.0 + private var _currentPhon: Double = 85.0 + private var _currentIntensity: Float = 1.0 // MARK: - Init @@ -69,15 +71,22 @@ final class LoudnessCompensator: BiquadProcessor, @unchecked Sendable { /// which the RT audio callback reads via `nonisolated(unsafe)`. Calling from any other /// thread creates a data race. Not annotated `@MainActor` because `BiquadProcessor` /// is not actor-isolated and test call sites run on arbitrary Swift Testing threads. - func updateForVolume(_ systemVolume: Float) { + func updateForVolume(_ systemVolume: Float, intensity: Float = 1.0) { + // Volume-based phon estimation (primary — tracks user's intended listening level, + // matching Dolby Volume Modeler / THX Loudness Plus architecture). let phon = ISO226Contours.estimatedPhon(fromSystemVolume: systemVolume) + let clampedIntensity = min(max(intensity, 0.0), 3.0) + let paramsChanged = clampedIntensity != _currentIntensity + // Coalesce rapid updates, but never skip a disabled processor because re-enabling // loudness from the UI must rebuild coefficients immediately even at the same volume. - guard !isEnabled || abs(phon - _currentPhon) >= 1.0 else { return } + // Also do not skip if intensity changed. + guard !isEnabled || paramsChanged || abs(phon - _currentPhon) >= 1.0 else { return } _currentPhon = phon + _currentIntensity = clampedIntensity - let gains = computeBandGains(phon: phon) + let gains = computeBandGains(phon: phon, intensity: clampedIntensity) // Bypass when all gains are negligible (near reference level) let allNegligible = gains.allSatisfy { abs($0) < 0.1 } @@ -98,8 +107,16 @@ final class LoudnessCompensator: BiquadProcessor, @unchecked Sendable { // MARK: - Coefficient Computation /// Compute per-section gains (dB) for the fixed four-filter loudness topology. - private func computeBandGains(phon: Double) -> [Float] { - Self.fittedSectionGains(forPhon: phon, sampleRate: sampleRate) + /// + /// Post-processes the fitted gains by computing the realized cascade response, + /// finding its peak (the "headroom" needed), and subtracting that peak from all + /// band gains so the cascade never clips. + private func computeBandGains(phon: Double, intensity: Float) -> [Float] { + let gains = Self.fittedSectionGains(forPhon: phon, sampleRate: sampleRate) + let scaledGains = gains.map { $0 * intensity } + let realized = Self.realizedResponseDB(sectionGains: scaledGains.map(Double.init), sampleRate: sampleRate) + let peakDB = max(realized.max() ?? 0.0, 0.0) + return scaledGains.map { $0 - Float(peakDB) } } /// Fit the fixed four-section loudness topology to the ISO-derived target curve. @@ -174,7 +191,7 @@ final class LoudnessCompensator: BiquadProcessor, @unchecked Sendable { override func recomputeCoefficients() -> (coefficients: [Double], sectionCount: Int)? { // Called by updateSampleRate() — recompute for current phon at new sample rate - let gains = computeBandGains(phon: _currentPhon) + let gains = computeBandGains(phon: _currentPhon, intensity: _currentIntensity) let allNegligible = gains.allSatisfy { abs($0) < 0.1 } guard !allNegligible else { return nil } let coefficients = Self.coefficientsForBands(gains: gains, sampleRate: sampleRate) diff --git a/FineTune/Audio/Loudness/LoudnessDetector.swift b/FineTune/Audio/Loudness/LoudnessDetector.swift deleted file mode 100644 index 732ae106..00000000 --- a/FineTune/Audio/Loudness/LoudnessDetector.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation - -/// Measures signal loudness using a ring-buffer RMS estimator and applies -/// asymmetric attack/release envelope smoothing in the dB domain. -/// -/// Thread-safety: all mutation must occur on a single real-time audio thread. -/// The class is marked @unchecked Sendable because its mutable state is -/// exclusively owned by that thread. -final class LoudnessDetector: @unchecked Sendable { - - // MARK: - Private state - - private var settings: LoudnessEqualizerSettings - private var sampleRate: Float - - // Ring buffer (stores squared samples) - private var ringBuffer: [Float] - private var writeIndex: Int = 0 - private var hopCounter: Int = 0 - private var runningSquareSum: Float = 0 - - // Derived sizes - private var windowSamples: Int - private var inverseWindowSamples: Float - private var hopSamples: Int - - // Envelope coefficients - private var attackCoeff: Float - private var releaseCoeff: Float - - // Smoothed level in dB - private var smoothedLevel: Float = -120.0 - - // MARK: - Init - - init(settings: LoudnessEqualizerSettings, sampleRate: Float) { - self.settings = settings - self.sampleRate = sampleRate - - let ws = Int(settings.analysisWindowMs / 1000 * sampleRate) - windowSamples = max(ws, 1) - inverseWindowSamples = 1.0 / Float(windowSamples) - - let hs = Int(settings.analysisHopMs / 1000 * sampleRate) - hopSamples = max(hs, 1) - - ringBuffer = [Float](repeating: 0, count: windowSamples) - - attackCoeff = LoudnessEqualizerMath.timeConstantCoefficient( - timeMs: settings.detectorAttackMs, - stepMs: settings.analysisHopMs - ) - releaseCoeff = LoudnessEqualizerMath.timeConstantCoefficient( - timeMs: settings.detectorReleaseMs, - stepMs: settings.analysisHopMs - ) - } - - // MARK: - Real-time ingest - - /// Ingest one K-weighted sample. Returns a new smoothed level (dB) when a - /// hop boundary is reached, otherwise returns nil. - /// RT-safe: no allocations, no logging, no ObjC. - func ingest(weightedSample: Float) -> Float? { - // Update running sum: subtract old squared value, store new squared value, add it - runningSquareSum -= ringBuffer[writeIndex] - ringBuffer[writeIndex] = weightedSample * weightedSample - runningSquareSum += ringBuffer[writeIndex] - - writeIndex += 1 - if writeIndex == windowSamples { - writeIndex = 0 - } - - hopCounter += 1 - if hopCounter >= hopSamples { - hopCounter = 0 - let meanSquare = runningSquareSum * inverseWindowSamples - let levelDb = LoudnessEqualizerMath.meanSquareToDb(meanSquare) - return updateEnvelope(with: levelDb) - } - return nil - } - - // MARK: - Envelope smoothing - - /// Apply asymmetric attack/release smoothing. Returns the updated smoothed level. - func updateEnvelope(with measuredLevelDb: Float) -> Float { - let coeff: Float - if measuredLevelDb > smoothedLevel { - coeff = attackCoeff - } else { - coeff = releaseCoeff - } - smoothedLevel += coeff * (measuredLevelDb - smoothedLevel) - return smoothedLevel - } - - // MARK: - Reset - - func reset() { - for i in 0.. 0 { + self.fallbackAlpha = Float(1.0 - exp(-1.0 / (Double(sampleRate) * Double(fallbackTime)))) + } else { + self.fallbackAlpha = 0.0 + } + + // Envelope follower: 10 ms attack, 200 ms release + let samplePeriodMs: Float = 1000.0 / sampleRate + self.envAttackCoeff = LoudnessEqualizerMath.timeConstantCoefficient( + timeMs: 10, stepMs: samplePeriodMs + ) + self.envReleaseCoeff = LoudnessEqualizerMath.timeConstantCoefficient( + timeMs: 200, stepMs: samplePeriodMs + ) + self.sjpAttackCoeff = LoudnessEqualizerMath.timeConstantCoefficient( + timeMs: 5.0, stepMs: samplePeriodMs + ) + self.lnGateSlowdownFactor = log(max(settings.gateSlowdownFactor, 1e-9)) + + self.crossoverL = LinkwitzRileyCrossover2(frequency: 150.0, sampleRate: Double(sampleRate)) + self.crossoverR = LinkwitzRileyCrossover2(frequency: 150.0, sampleRate: Double(sampleRate)) + let initialGain = -settings.driveDb + self.masterBand = AgcBandState(initialGainDb: initialGain) + self.bassBand = AgcBandState(initialGainDb: initialGain) + + // Start fully attenuated to avoid initial pop on first enabled frame. + // Release will smoothly bring gain up as the AGC settles. + self.currentGainDb = initialGain + self.currentGainLinear = LoudnessEqualizerMath.dbToLinear(initialGain) + self._intensity = 1.0 } // MARK: - Public API @@ -45,6 +134,13 @@ final class LoudnessEqualizer: @unchecked Sendable { /// The current settings snapshot (read from main thread for creating replacement instances). var currentSettings: LoudnessEqualizerSettings { settings } + /// Set the dry/wet intensity blend. Main thread only. + /// + /// - Parameter intensity: 0.0 (drive only, no normalization) to 1.0 (full AGC). + func setIntensity(_ intensity: Float) { + _intensity = max(0.0, min(1.0, intensity)) + } + /// Process audio from an interleaved input buffer to an interleaved output buffer. /// /// - Parameters: @@ -68,53 +164,403 @@ final class LoudnessEqualizer: @unchecked Sendable { return } - var linearGain = currentLinearGain + // Scale drive in dB domain for perceptually linear intensity control. + // At intensity=0 → 0 dB (unity passthrough), at intensity=1 → full driveDb. + let effectiveDriveDb = max(0.0, Double(settings.driveDb) * Double(_intensity)) + let effectiveDrive = LoudnessEqualizerMath.dbToLinear(Float(effectiveDriveDb)) + let targetDb = settings.targetLevelDb + let attackNorm = attackSpeedNorm + let releaseNorm = releaseSpeedNorm + let jumpProtection = settings.suddenJumpProtectionEnabled + let silenceGateThreshold = settings.silenceGateThresholdDb + let silenceGateSlowdown = settings.silenceGateSlowdownDb + + let suddenDropProtection = settings.suddenDropProtection + let suddenDropThreshold = settings.suddenDropThresholdDb + let suddenDropSpeedup = settings.suddenDropSpeedup + let agcWindowSize = settings.agcWindowSizeDb + let progressiveRatioEnabled = settings.progressiveRatioEnabled + let minRatio = settings.minRatio + let maxRatio = settings.maxRatio + let progressiveRate = settings.progressiveRate + let targetIdleGain = min(0.0, settings.silenceGateIdleGainDb) + + var envSq = envelopeSq if channelCount == 2 { for frame in 0.. envSq { + envSq += envAttackCoeff * (sampleSq - envSq) + } else { + envSq += envReleaseCoeff * (sampleSq - envSq) + } + let levelDb = LoudnessEqualizerMath.meanSquareToDb(envSq) + let gateRange = silenceGateSlowdown - silenceGateThreshold + let gatingFactor: Float + if gateRange > 0 { + gatingFactor = LoudnessEqualizerMath.clamp((levelDb - silenceGateThreshold) / gateRange, min: 0.0, max: 1.0) + } else { + gatingFactor = levelDb >= silenceGateThreshold ? 1.0 : 0.0 } - output[base] = input[base] * linearGain - output[base + 1] = input[base + 1] * linearGain + let bassDrivenPeak = max(abs(lowL), abs(lowR)) + let masterDrivenPeak = max(abs(highL), abs(highR)) + + // 5. Process AGC for both bands + processBandSample( + band: &bassBand, + weighted: bassMonoWeighted, + drivenPeak: bassDrivenPeak, + targetDb: targetDb, + attackNorm: attackNorm, + releaseNorm: releaseNorm, + jumpProtection: jumpProtection, + broadbandGatingFactor: gatingFactor, + silenceGateSlowdown: silenceGateSlowdown, + suddenDropProtection: suddenDropProtection, + suddenDropThresholdDb: suddenDropThreshold, + suddenDropSpeedup: suddenDropSpeedup, + agcWindowSize: agcWindowSize, + progressiveRatioEnabled: progressiveRatioEnabled, + minRatio: minRatio, + maxRatio: maxRatio, + progressiveRate: progressiveRate, + targetIdleGain: targetIdleGain + ) + + processBandSample( + band: &masterBand, + weighted: masterMonoWeighted, + drivenPeak: masterDrivenPeak, + targetDb: targetDb, + attackNorm: attackNorm, + releaseNorm: releaseNorm, + jumpProtection: jumpProtection, + broadbandGatingFactor: gatingFactor, + silenceGateSlowdown: silenceGateSlowdown, + suddenDropProtection: suddenDropProtection, + suddenDropThresholdDb: suddenDropThreshold, + suddenDropSpeedup: suddenDropSpeedup, + agcWindowSize: agcWindowSize, + progressiveRatioEnabled: progressiveRatioEnabled, + minRatio: minRatio, + maxRatio: maxRatio, + progressiveRate: progressiveRate, + targetIdleGain: targetIdleGain + ) + + // 6. Apply Bass-to-Master coupling clamp + let maxBassGain = masterBand.currentGainDb + 3.0 + let minBassGain = masterBand.currentGainDb - 3.0 + bassBand.currentGainDb = max(min(bassBand.currentGainDb, maxBassGain), minBassGain) + if bassBand.currentGainDb > 0 { bassBand.currentGainDb = 0 } + bassBand.currentGainLinear = LoudnessEqualizerMath.dbToLinear(bassBand.currentGainDb) + + // 7. Apply gains to bands and sum + let outL = lowL * bassBand.currentGainLinear + highL * masterBand.currentGainLinear + let outR = lowR * bassBand.currentGainLinear + highR * masterBand.currentGainLinear + + output[base] = outL + output[base + 1] = outR } - return + } else if channelCount == 1 { + for frame in 0.. envSq { + envSq += envAttackCoeff * (sampleSq - envSq) + } else { + envSq += envReleaseCoeff * (sampleSq - envSq) + } + let levelDb = LoudnessEqualizerMath.meanSquareToDb(envSq) + let gateRange = silenceGateSlowdown - silenceGateThreshold + let gatingFactor: Float + if gateRange > 0 { + gatingFactor = LoudnessEqualizerMath.clamp((levelDb - silenceGateThreshold) / gateRange, min: 0.0, max: 1.0) + } else { + gatingFactor = levelDb >= silenceGateThreshold ? 1.0 : 0.0 + } + + let bassDrivenPeak = abs(low) + let masterDrivenPeak = abs(high) + + // 5. Process AGC for both bands + processBandSample( + band: &bassBand, + weighted: bassMonoWeighted, + drivenPeak: bassDrivenPeak, + targetDb: targetDb, + attackNorm: attackNorm, + releaseNorm: releaseNorm, + jumpProtection: jumpProtection, + broadbandGatingFactor: gatingFactor, + silenceGateSlowdown: silenceGateSlowdown, + suddenDropProtection: suddenDropProtection, + suddenDropThresholdDb: suddenDropThreshold, + suddenDropSpeedup: suddenDropSpeedup, + agcWindowSize: agcWindowSize, + progressiveRatioEnabled: progressiveRatioEnabled, + minRatio: minRatio, + maxRatio: maxRatio, + progressiveRate: progressiveRate, + targetIdleGain: targetIdleGain + ) + + processBandSample( + band: &masterBand, + weighted: masterMonoWeighted, + drivenPeak: masterDrivenPeak, + targetDb: targetDb, + attackNorm: attackNorm, + releaseNorm: releaseNorm, + jumpProtection: jumpProtection, + broadbandGatingFactor: gatingFactor, + silenceGateSlowdown: silenceGateSlowdown, + suddenDropProtection: suddenDropProtection, + suddenDropThresholdDb: suddenDropThreshold, + suddenDropSpeedup: suddenDropSpeedup, + agcWindowSize: agcWindowSize, + progressiveRatioEnabled: progressiveRatioEnabled, + minRatio: minRatio, + maxRatio: maxRatio, + progressiveRate: progressiveRate, + targetIdleGain: targetIdleGain + ) + + // 6. Apply Bass-to-Master coupling clamp + let maxBassGain = masterBand.currentGainDb + 3.0 + let minBassGain = masterBand.currentGainDb - 3.0 + bassBand.currentGainDb = max(min(bassBand.currentGainDb, maxBassGain), minBassGain) + if bassBand.currentGainDb > 0 { bassBand.currentGainDb = 0 } + bassBand.currentGainLinear = LoudnessEqualizerMath.dbToLinear(bassBand.currentGainDb) + + // 7. Apply gains to bands and sum + output[frame] = low * bassBand.currentGainLinear + high * masterBand.currentGainLinear + } + } else { + let invCh = 1.0 / Float(channelCount) + for f in 0.. maxDrivenAbs { maxDrivenAbs = a } + } + drivenMono *= invCh + + // Filter sidechains in parallel via SIMD. + // Bass lane ([1]) is unused in multi-channel — no crossover splitting. + // The zero input accumulates harmless filter state; lane is discarded via `_`. + let (weighted, _, masterMonoWeighted) = sidechainFilter.process( + mono: drivenMono, + bass: 0.0, + master: drivenMono + ) + + // Broadband Envelope + let sampleSq = weighted * weighted + if sampleSq > envSq { + envSq += envAttackCoeff * (sampleSq - envSq) + } else { + envSq += envReleaseCoeff * (sampleSq - envSq) + } + let levelDb = LoudnessEqualizerMath.meanSquareToDb(envSq) + let gateRange = silenceGateSlowdown - silenceGateThreshold + let gatingFactor: Float + if gateRange > 0 { + gatingFactor = LoudnessEqualizerMath.clamp((levelDb - silenceGateThreshold) / gateRange, min: 0.0, max: 1.0) + } else { + gatingFactor = levelDb >= silenceGateThreshold ? 1.0 : 0.0 + } + + processBandSample( + band: &masterBand, + weighted: masterMonoWeighted, + drivenPeak: maxDrivenAbs, + targetDb: targetDb, + attackNorm: attackNorm, + releaseNorm: releaseNorm, + jumpProtection: jumpProtection, + broadbandGatingFactor: gatingFactor, + silenceGateSlowdown: silenceGateSlowdown, + suddenDropProtection: suddenDropProtection, + suddenDropThresholdDb: suddenDropThreshold, + suddenDropSpeedup: suddenDropSpeedup, + agcWindowSize: agcWindowSize, + progressiveRatioEnabled: progressiveRatioEnabled, + minRatio: minRatio, + maxRatio: maxRatio, + progressiveRate: progressiveRate, + targetIdleGain: targetIdleGain + ) + + // Apply masterBand gain to all channels + for ch in 0.. band.envelopeSq { + band.envelopeSq += envAttackCoeff * (sampleSq - band.envelopeSq) + } else { + band.envelopeSq += envReleaseCoeff * (sampleSq - band.envelopeSq) } + let levelDb = LoudnessEqualizerMath.meanSquareToDb(band.envelopeSq) - let inverseChannelCount = 1.0 / Float(channelCount) - for f in 0.. 1.0 { + let sMin = 1.0 / minRatio + let sMax = maxRatio > 0.0 ? (1.0 / maxRatio) : 0.0 + let s = sMax + (sMin - sMax) * exp(-progressiveRate * max(inputOvershootDb, 0.0)) + targetGainDb = -halfWindow - inputOvershootDb * (1.0 - s) + } + + if deltaOutput > halfWindow { + // Output is too loud — ATTACK towards targetGainDb (never gated, to prevent clipping) + let overshootDb = deltaInput - halfWindow + let attackDelta = attackNorm * max(overshootDb, 0) + gainDb -= attackDelta + if gainDb < targetGainDb { gainDb = targetGainDb } + } else if deltaOutput < -halfWindow { + // Output is too quiet — RELEASE/RECOVERY towards active target (either 0 dB or targetGainDb) + let activeTargetGainDb = min(0.0, targetGainDb) + var speedMult: Float = 1.0 + + if levelDb < silenceGateSlowdown { + speedMult *= exp(lnGateSlowdownFactor * (1.0 - gatingFactor)) + } + if suddenDropProtection && deltaInput < -suddenDropThresholdDb { + speedMult *= suddenDropSpeedup } - mono *= inverseChannelCount - // --- K-weighting --- - let weighted = kFilter.processSample(mono) + let activeAlpha = releaseNorm * speedMult - // --- Detector --- - if let newLevel = detector.ingest(weightedSample: weighted) { - let desiredGain = gainComputer.desiredGainDb(forLevelDb: newLevel) - let smoothedGain = gainSmoother.process(targetGainDb: desiredGain) - linearGain = LoudnessEqualizerMath.dbToLinear(smoothedGain) - currentLinearGain = linearGain + // Interpolate target and speed between active release and idle gain fallback + let effectiveTargetGainDb = gatingFactor * activeTargetGainDb + (1.0 - gatingFactor) * targetIdleGain + let effectiveAlpha = gatingFactor * activeAlpha + (1.0 - gatingFactor) * (1.0 - gatingFactor) * fallbackAlpha + + let releaseDeficitDb = effectiveTargetGainDb - gainDb + gainDb += releaseDeficitDb * effectiveAlpha + if gainDb > activeTargetGainDb { gainDb = activeTargetGainDb } + if gainDb > 0 { gainDb = 0 } + } else { + // Output is within comfort zone (window) + // If gatingFactor < 1.0, we drift towards idle gain, but with a speed scaled quadratically by (1 - gatingFactor)^2 + if fallbackAlpha > 0 { + let effectiveAlpha = (1.0 - gatingFactor) * (1.0 - gatingFactor) * fallbackAlpha + let deficitDb = targetIdleGain - gainDb + gainDb += deficitDb * effectiveAlpha + if gainDb > 0 { gainDb = 0 } } + } - // --- Apply gain to all channels --- - for ch in 0.. 0 { + let drivenPeakDb = LoudnessEqualizerMath.linearToDb(drivenPeak) + let outputDb = drivenPeakDb + gainDb + let jumpThresholdDb = targetDb + 4.0 + if outputDb > jumpThresholdDb { + let targetSjpGainDb = jumpThresholdDb - drivenPeakDb + // Smoothly attack toward the jump protection target gain. + // We use a fast attack time constant (5.0 ms) to pull the gain down + // without introducing a step discontinuity (which causes clicks/pops). + gainDb += sjpAttackCoeff * (targetSjpGainDb - gainDb) } } + + band.currentGainDb = gainDb + band.currentGainLinear = LoudnessEqualizerMath.dbToLinear(gainDb) } + + #if DEBUG + /// Internal properties exposed for unit testing band-coupling behavior + var bassGainDb: Float { bassBand.currentGainDb } + var masterGainDb: Float { masterBand.currentGainDb } + #endif } diff --git a/FineTune/Audio/Loudness/LoudnessEqualizerMath.swift b/FineTune/Audio/Loudness/LoudnessEqualizerMath.swift index 44288e44..af2a3583 100644 --- a/FineTune/Audio/Loudness/LoudnessEqualizerMath.swift +++ b/FineTune/Audio/Loudness/LoudnessEqualizerMath.swift @@ -1,26 +1,98 @@ import Foundation enum LoudnessEqualizerMath { + + // MARK: - Fast Approximations (RT-safe, used in per-sample processing) + + /// Fast base-2 logarithm using IEEE 754 bit manipulation. + /// + /// Extracts the exponent and mantissa from the float's binary representation, + /// then applies a 2nd-order minimax polynomial for `log2(1+f)` on `[0, 1)`. + /// Maximum error: ~0.008 in log2 units ≈ 0.05 dB for `linearToDb`. + /// + /// - Precondition: `x > 0`. Caller must guard against zero/negative inputs. + @inline(__always) + private static func fastLog2(_ x: Float) -> Float { + let bits = x.bitPattern + let e = Float(Int32(bits >> 23) - 127) + // Reconstitute mantissa as a float in [1, 2), then subtract 1 → f in [0, 1) + let f = Float(bitPattern: (bits & 0x007F_FFFF) | 0x3F80_0000) - 1.0 + // Minimax polynomial for log2(1 + f) on [0, 1) + // Constrained to pass through (0, 0) and (1, 1) exactly. + // Max absolute error < 0.009 across the interval. + return e + f * (1.3494295 + f * -0.3494295) + } + + /// Fast 2^x approximation using IEEE 754 bit construction + polynomial. + /// + /// Splits `x` into integer part `n` (sets exponent bits) and fractional part `f` + /// (tuned 3rd-order minimax-adjusted polynomial for `2^f`). Exact at integer inputs. + /// Maximum error: ~0.6% amplitude ≈ 0.05 dB. + /// + /// - Parameter x: Exponent value. Clamped to `[-126, 126]` to avoid overflow. + @inline(__always) + private static func fastPow2(_ x: Float) -> Float { + let clipped = max(min(x, 126.0), -126.0) + let floorVal = floor(clipped) + let n = Int32(floorVal) + let f = clipped - floorVal + // Tuned 3rd-order polynomial for 2^f on [0, 1) + // Coeffs adjusted from Taylor series (e.g. 0.0558016 vs ln³2/6 ≈ 0.0555041) + // to minimize error at the f=1 boundary, ensuring smooth transitions. + let pow2f = 1.0 + f * (0.6931472 + f * (0.2402265 + f * 0.0558016)) + // Construct 2^n via IEEE 754 exponent field + let bits = UInt32(bitPattern: n + 127) << 23 + return Float(bitPattern: bits) * pow2f + } + + // MARK: - dB / Linear Conversions + + /// Convert dB to linear amplitude using fast 2^x approximation. + /// + /// `10^(db/20) = 2^(db / (20·log10(2))) = 2^(db · 0.16609640)` + /// + /// Maximum error: ~0.05 dB. Exact at 0 dB (returns 1.0). + @inline(__always) static func dbToLinear(_ db: Float) -> Float { - pow(10, db / 20) + // 1 / (20 · log10(2)) = 1 / 6.0205999 = 0.16609640 + fastPow2(db * 0.16609640) } + /// Convert linear amplitude to dB using fast log2 approximation. + /// + /// `20·log10(x) = (20/log2(10))·log2(x) = 6.0206·log2(x)` + /// + /// Maximum error: ~0.05 dB. Clamps input to ≥ 1e-9 (≈ -180 dB floor). + @inline(__always) static func linearToDb(_ linear: Float) -> Float { - 20 * log10(max(linear, 1e-9)) + // 20 / log2(10) = 20 / 3.321928 = 6.0205999 + 6.0205999 * fastLog2(max(linear, 1e-9)) } + /// Convert mean-square power to dB using fast log2 approximation. + /// + /// `10·log10(x) = (10/log2(10))·log2(x) = 3.0103·log2(x)` + /// + /// Maximum error: ~0.03 dB. Clamps input to ≥ 1e-12 (≈ -120 dB floor). + @inline(__always) static func meanSquareToDb(_ meanSquare: Float) -> Float { - 10 * log10(max(meanSquare, 1e-12)) + // 10 / log2(10) = 10 / 3.321928 = 3.0103000 + 3.0103000 * fastLog2(max(meanSquare, 1e-12)) } + // MARK: - Utilities (unchanged) + static func rmsFromMeanSquare(_ meanSquare: Float) -> Float { sqrt(max(meanSquare, 0)) } + @inline(__always) static func clamp(_ value: Float, min: Float, max: Float) -> Float { Swift.min(Swift.max(value, min), max) } + /// Compute one-pole filter coefficient from time constant. + /// Used only during init (not in RT path), so standard `exp` is fine. static func timeConstantCoefficient(timeMs: Float, stepMs: Float) -> Float { 1 - exp(-stepMs / max(timeMs, 1e-6)) } diff --git a/FineTune/Audio/Loudness/LoudnessEqualizerSettings.swift b/FineTune/Audio/Loudness/LoudnessEqualizerSettings.swift index 8a7cf213..6475cf52 100644 --- a/FineTune/Audio/Loudness/LoudnessEqualizerSettings.swift +++ b/FineTune/Audio/Loudness/LoudnessEqualizerSettings.swift @@ -1,22 +1,45 @@ nonisolated struct LoudnessEqualizerSettings: Codable, Equatable, Sendable { - var targetLoudnessDb: Float = -12 - var maxBoostDb: Float = 6 - var maxCutDb: Float = 4 - var compressionThresholdOffsetDb: Float = 6 - var compressionRatio: Float = 1.6 - var compressionKneeDb: Float = 8 - - var analysisWindowMs: Float = 400 - var analysisHopMs: Float = 100 - - var detectorAttackMs: Float = 25 - var detectorReleaseMs: Float = 600 - - var gainAttackMs: Float = 250 - var gainReleaseMs: Float = 3000 - - var noiseFloorThresholdDb: Float = -40 - var lowLevelMaxBoostDb: Float = 0.5 + /// Static input gain before AGC analysis. + var driveDb: Float = 24.0 + /// Target output level for average loudness. + var targetLevelDb: Float = -7.9 + /// Attack speed in dB/sec per 6 dB of signal overshoot above target. + var attackSpeedDbPerSecPer6Db: Float = 6.0 + /// Release speed in dB/sec per 6 dB of gain deficit below 0 dB. + var releaseSpeedDbPerSecPer6Db: Float = 2.45 + /// Instant fast-attack override for massive overshoots. + var suddenJumpProtectionEnabled: Bool = true + /// Silence gate threshold (dB). When the driven signal level falls below this, + /// the active release is frozen and gain slowly drifts to the idle gain target. + var silenceGateThresholdDb: Float = -16.0 + /// Gate slowdown threshold (dB). Between freeze and this level, active release is slowed. + var silenceGateSlowdownDb: Float = -12.0 + /// Gate slowdown multiplier. + /// 0.086 corresponds to an ~11x slower release, tuned by ear for conversational speech dynamics. + var gateSlowdownFactor: Float = 0.086 + /// Silence gate fallback recovery time in seconds. + /// 9.0s provides a very slow, unnoticeable drift back to idle gain when audio pauses. + var silenceGateFallbackTimeS: Float = 9.0 + /// Whether Sudden Drop Protection is enabled. + var suddenDropProtection: Bool = true + /// Threshold in dB below target to activate speedup. + var suddenDropThresholdDb: Float = 10.0 + /// Speedup factor for release under sudden drop. + var suddenDropSpeedup: Float = 2.5 + /// AGC window / dead zone (dB). A comfort zone around the target level where + /// the AGC holds its current gain. If |level - target| <= window/2, no adjustment. + var agcWindowSizeDb: Float = 4.5 + /// Whether Orban-style progressive compression ratio is enabled. + var progressiveRatioEnabled: Bool = true + /// Starting compression ratio near the window threshold. + var minRatio: Float = 2.0 + /// Maximum compression ratio for large overshoots. + var maxRatio: Float = Float.infinity + /// Speed of transition from minRatio to maxRatio per dB of overshoot. + var progressiveRate: Float = 0.15 + /// Silence gate fallback target level in dB (Orban Idle Gain). + var silenceGateIdleGainDb: Float = -24.0 + /// Whether AGC processing is active. var enabled: Bool = false } diff --git a/FineTune/Audio/Loudness/ParametricSidechainFilter.swift b/FineTune/Audio/Loudness/ParametricSidechainFilter.swift new file mode 100644 index 00000000..c217d0d4 --- /dev/null +++ b/FineTune/Audio/Loudness/ParametricSidechainFilter.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Custom parametric EQ sidechain filter mimicking Stereo Tool's preset. +/// +/// Uses 7 inline biquad stages (struct-based, stack-allocated) for maximum +/// RT-thread performance. Each stage is a direct-form II transposed biquad. +/// +/// **Performance**: Optimized via SIMD4 to process three signals (mono, bass, master) +/// in parallel inside a single execution pass. +/// +/// Lane layout: `[0]=mono, [1]=bass, [2]=master, [3]=unused` +struct ParametricSidechainFilter: @unchecked Sendable { + + // MARK: - Inline Biquad Stage (value type, stack-allocated, SIMD-enabled) + + /// A single biquad filter section using direct-form II transposed structure. + /// Processes a vector of 4 channels in parallel. + private struct Stage { + let b0: Float, b1: Float, b2: Float + let a1: Float, a2: Float + var z1: SIMD4 = .zero + var z2: SIMD4 = .zero + + init(coefficients: [Double]) { + precondition(coefficients.count == 5) + b0 = Float(coefficients[0]) + b1 = Float(coefficients[1]) + b2 = Float(coefficients[2]) + a1 = Float(coefficients[3]) + a2 = Float(coefficients[4]) + } + + @inline(__always) + mutating func process(_ x: SIMD4) -> SIMD4 { + let y = b0 * x + z1 + z1 = b1 * x - a1 * y + z2 + z2 = b2 * x - a2 * y + return y + } + + mutating func reset() { + z1 = .zero + z2 = .zero + } + } + + // MARK: - Fixed filter stages (no array, no heap) + + /// Stage 0: 38 Hz Butterworth High Pass (ITU Bass correction) + private var s0: Stage + /// Band 1: Gain -12.0 dB, Freq 23 Hz, Q 1.40 + private var s1: Stage + /// Band 2: Gain 1.0 dB, Freq 160 Hz, Q 1.00 + private var s2: Stage + /// Band 3: Gain -2.2 dB, Freq 240 Hz, Q 0.51 + private var s3: Stage + /// Band 4: Gain -8.1 dB, Freq 781 Hz, Q 1.30 + private var s4: Stage + /// Band 5: Gain 3.5 dB, Freq 1717 Hz, Q 0.63 + private var s5: Stage + /// Band 6: Gain -3.7 dB, Freq 10054 Hz, Q 1.74 + private var s6: Stage + + // MARK: - Init + + init(sampleRate: Float) { + let sRate = Double(sampleRate) + + s0 = Stage(coefficients: BiquadMath.highPassCoefficients( + frequency: 38.0, q: 1.0 / sqrt(2.0), sampleRate: sRate)) + + s1 = Stage(coefficients: BiquadMath.peakingEQCoefficients( + frequency: 23.0, gainDB: -12.0, q: 1.40, sampleRate: sRate)) + + s2 = Stage(coefficients: BiquadMath.peakingEQCoefficients( + frequency: 160.0, gainDB: 1.0, q: 1.00, sampleRate: sRate)) + + s3 = Stage(coefficients: BiquadMath.peakingEQCoefficients( + frequency: 240.0, gainDB: -2.2, q: 0.51, sampleRate: sRate)) + + s4 = Stage(coefficients: BiquadMath.peakingEQCoefficients( + frequency: 781.0, gainDB: -8.1, q: 1.30, sampleRate: sRate)) + + s5 = Stage(coefficients: BiquadMath.peakingEQCoefficients( + frequency: 1717.0, gainDB: 3.5, q: 0.63, sampleRate: sRate)) + + s6 = Stage(coefficients: BiquadMath.peakingEQCoefficients( + frequency: 10054.0, gainDB: -3.7, q: 1.74, sampleRate: sRate)) + } + + // MARK: - Processing + + /// Process three signals in parallel using SIMD4. + @inline(__always) + mutating func process( + mono: Float, + bass: Float, + master: Float + ) -> (weighted: Float, bassWeighted: Float, masterWeighted: Float) { + var x = SIMD4(mono, bass, master, 0.0) + x = s0.process(x) + x = s1.process(x) + x = s2.process(x) + x = s3.process(x) + x = s4.process(x) + x = s5.process(x) + x = s6.process(x) + return (x[0], x[1], x[2]) + } + + /// Fallback scalar processing for single sample streams. + @inline(__always) + mutating func processSample(_ sample: Float) -> Float { + let res = process(mono: sample, bass: 0.0, master: 0.0) + return res.weighted + } + + mutating func reset() { + s0.reset(); s1.reset(); s2.reset(); s3.reset() + s4.reset(); s5.reset(); s6.reset() + } +} diff --git a/FineTune/Audio/Loudness/PostAgcCompressor.swift b/FineTune/Audio/Loudness/PostAgcCompressor.swift new file mode 100644 index 00000000..9c059568 --- /dev/null +++ b/FineTune/Audio/Loudness/PostAgcCompressor.swift @@ -0,0 +1,199 @@ +// FineTune/Audio/Loudness/PostAgcCompressor.swift + +import Foundation + +/// Post-AGC dynamics compressor that catches transient overshoots the slow AGC +/// may miss (e.g., sudden loud peaks above the AGC target level). +/// +/// Operates strictly downward-only (attenuation) after the AGC stage: +/// 1. **Threshold** – signals above `thresholdDb` are compressed. +/// 2. **Ratio** – compression slope (7.6:1 default). +/// 3. **Knee** – soft-knee transition around threshold (0.1 dB default). +/// 4. **Attack/Release** – fast envelope follower to catch transients. +/// 5. **Exponential Release** – release slows as gain reduction approaches 0 dB, +/// preventing audible pumping. +/// +/// **RT-safety contract**: All mutable state is owned exclusively by the real-time +/// audio thread after init. Settings and sample-rate changes are handled by creating +/// a **new** instance on the main thread, atomically swapping the pointer in +/// ProcessTapController, and deferring destruction of the old instance by 500 ms. +final class PostAgcCompressor: @unchecked Sendable { + + // MARK: - Private state (exclusively RT-thread owned after init) + + private let settings: PostAgcCompressorSettings + + /// Linear threshold (10^(thresholdDb / 20)). + private let thresholdLinear: Float + + /// Compression slope: 1 - 1/ratio. + private let slope: Float + + /// Attack coefficient per sample (one-pole). + private let attackCoeff: Float + + /// Release coefficient per sample (one-pole). + private let releaseCoeff: Float + + /// Half the knee width in dB. + private let kneeHalfDb: Float + + /// Maximum release coefficient (capped by Max Release Speed). + /// Prevents the release coefficient from exceeding this cap, + /// which avoids excessively fast recovery at deep gain reduction. + private let maxReleaseCoeff: Float + + /// Release gate stop threshold in dB. When |GR| < gateStopDb + /// and desired GR is 0, release is held (frozen). + private let gateStopDb: Float + + /// Current gain reduction in dB (≤ 0). Initialized to 0 (no reduction). + private var gainReductionDb: Float = 0 + + // MARK: - Init + + init(settings: PostAgcCompressorSettings, sampleRate: Float) { + self.settings = settings + self.thresholdLinear = LoudnessEqualizerMath.dbToLinear(settings.thresholdDb) + self.slope = 1.0 - 1.0 / max(settings.ratio, 1.0) + self.kneeHalfDb = settings.kneeDb * 0.5 + + let samplePeriodMs: Float = 1000.0 / sampleRate + + // Attack (time to drop 86%) → one-pole time constant τ. + // For a one-pole system: 1 - exp(-attackMs / τ) = 0.86 + // → τ = attackMs / ln(1 / 0.14) = attackMs / 1.966 + let attackTau = settings.attackMs / 1.966 + self.attackCoeff = LoudnessEqualizerMath.timeConstantCoefficient( + timeMs: attackTau, stepMs: samplePeriodMs + ) + + // Release (time to rise 10 dB) is already equivalent to + // the one-pole time constant (the time needed to rise 10 dB from the + // initial rate), so it is used directly as τ. + self.releaseCoeff = LoudnessEqualizerMath.timeConstantCoefficient( + timeMs: settings.releaseMs, stepMs: samplePeriodMs + ) + + // Max Release Speed cap (default: 0.502502918). + // Caps the release coefficient to prevent fast recovery at deep GR. + // releaseMs / maxReleaseSpeed yields a shorter effective time constant, + // which we then convert to a coefficient cap. + let maxReleaseSpeed = max(settings.maxReleaseSpeed, 0.001) // avoid division by zero + self.maxReleaseCoeff = LoudnessEqualizerMath.timeConstantCoefficient( + timeMs: settings.releaseMs / maxReleaseSpeed, stepMs: samplePeriodMs + ) + + // Release Gate Stop: when |GR| falls below this threshold, freeze release. + // Prevents flutter near 0 dB GR. From preset: Release Gate Stop=0.978250563 → ~0.191 dB. + self.gateStopDb = settings.releaseGateStopDb + } + + // MARK: - Public API + + /// Whether compression is active. + var isEnabled: Bool { settings.enabled } + + /// The current settings snapshot (read from main thread for creating replacement instances). + var currentSettings: PostAgcCompressorSettings { settings } + + /// Process audio from an interleaved input buffer to an interleaved output buffer. + /// + /// - Parameters: + /// - input: Interleaved input: `input[f * channelCount + ch]` + /// - output: Interleaved output: `output[f * channelCount + ch]` + /// - frameCount: Number of frames per channel. + /// - channelCount: Number of channels. + /// + /// RT-safe: allocation-free, no logging. + func process( + input: UnsafePointer, + output: UnsafeMutablePointer, + frameCount: Int, + channelCount: Int + ) { + guard settings.enabled else { + if input != UnsafePointer(output) { + memcpy(output, input, frameCount * channelCount * MemoryLayout.size) + } + return + } + + let thresholdDb = settings.thresholdDb + let slopeVal = slope + let attack = attackCoeff + let release = releaseCoeff + let kneeDb = settings.kneeDb + let kneeHalf = kneeHalfDb + let expRelease = settings.exponentialRelease + + var grDb = gainReductionDb + + for frame in 0.. peak { peak = absVal } + } + + let levelDb = LoudnessEqualizerMath.linearToDb(peak) + + // 2. Desired gain reduction with optional soft knee + let desiredGrDb: Float + if levelDb > thresholdDb - kneeHalf && levelDb < thresholdDb + kneeHalf && kneeDb > 0 { + // Soft knee region + let x = levelDb - thresholdDb + kneeHalf + let kneeFactor = (x * x) / (2.0 * max(kneeDb, 1e-6)) + desiredGrDb = -slopeVal * kneeFactor + } else if levelDb >= thresholdDb + kneeHalf { + // Above threshold: compress + let overshootDb = levelDb - thresholdDb + desiredGrDb = -slopeVal * overshootDb + } else { + // Below threshold: no compression + desiredGrDb = 0 + } + + // 3. Envelope follower (attack/release) + if desiredGrDb < grDb { + // Attack: gain reduction increases (becomes more negative) + grDb += attack * (desiredGrDb - grDb) + } else { + // Release: gain reduction decreases (moves toward 0) + + // Release Gate Stop: if |GR| is below gate threshold and target is 0, hold. + // Prevents flutter near 0 dB GR. + if abs(grDb) < gateStopDb && desiredGrDb >= 0 { + // Hold — don't release further + } else { + var adjustedRelease = release + if expRelease > 0 { + // Exponential release: slower as we approach 0 dB gain reduction + let maxReleaseDb: Float = 12.0 + let normalized = min(abs(grDb) / maxReleaseDb, 1.0) + let expFactor = 1.0 - expRelease * (1.0 - normalized * normalized) + adjustedRelease = release * max(expFactor, 0.01) + } + // Cap release speed by Max Release Speed + adjustedRelease = min(adjustedRelease, maxReleaseCoeff) + grDb += adjustedRelease * (desiredGrDb - grDb) + } + } + + // Clamp to ≤ 0 (downward-only) + if grDb > 0 { grDb = 0 } + + // 4. Apply gain reduction + let gainLin = LoudnessEqualizerMath.dbToLinear(grDb) + for ch in 0.. { - Binding( - get: { - settings.appSettings.loudnessCompensationEnabled - && settings.appSettings.loudnessEqualizationEnabled - }, - set: { isEnabled in - settings.appSettings.setUnifiedLoudnessEnabled(isEnabled) - } - ) - } - var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -43,9 +31,15 @@ struct AudioTab: View { .onChange(of: settings.appSettings.loudnessCompensationEnabled) { _, newValue in audioEngine.setLoudnessCompensationEnabled(newValue) } + .onChange(of: settings.appSettings.loudnessCompensationIntensity) { _, newValue in + audioEngine.setLoudnessCompensationIntensity(newValue) + } .onChange(of: settings.appSettings.loudnessEqualizationEnabled) { _, newValue in audioEngine.setLoudnessEqualizationEnabled(newValue) } + .onChange(of: settings.appSettings.loudnessEqualizationIntensity) { _, newValue in + audioEngine.setLoudnessEqualizationIntensity(newValue) + } } // MARK: - Volume @@ -63,15 +57,49 @@ struct AudioTab: View { ) } SettingsRowDivider() + SettingsRow( + "Smart Volume", + description: "Normalize volume across apps and content" + ) { + Toggle("", isOn: $settings.appSettings.loudnessEqualizationEnabled) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + if settings.appSettings.loudnessEqualizationEnabled { + SettingsRow( + "Drive", + description: "Input gain driving the leveler" + ) { + VolumeSlider( + $settings.appSettings.loudnessEqualizationIntensity, + range: 0.0...1.0, + width: 280 + ) + } + } + SettingsRowDivider() SettingsRow( "Loudness Compensation", description: "Boost low frequencies at low volume" ) { - Toggle("", isOn: unifiedLoudnessToggleBinding) + Toggle("", isOn: $settings.appSettings.loudnessCompensationEnabled) .toggleStyle(.switch) .controlSize(.small) .labelsHidden() } + if settings.appSettings.loudnessCompensationEnabled { + SettingsRow( + "Boost", + description: "Amount of low-frequency compensation" + ) { + VolumeSlider( + $settings.appSettings.loudnessCompensationIntensity, + range: 0.0...3.0, + width: 280 + ) + } + } } } diff --git a/FineTuneTests/AgcPhonOffsetSmootherTests.swift b/FineTuneTests/AgcPhonOffsetSmootherTests.swift new file mode 100644 index 00000000..af88b307 --- /dev/null +++ b/FineTuneTests/AgcPhonOffsetSmootherTests.swift @@ -0,0 +1,78 @@ +// FineTuneTests/AgcPhonOffsetSmootherTests.swift + +import Testing +import Foundation +@testable import FineTune + +@Suite("AgcPhonOffsetSmootherTests") +struct AgcPhonOffsetSmootherTests { + + @Test("Initial state is 0 and reset returns state to 0") + func initialStateAndReset() { + let smoother = AgcPhonOffsetSmoother(pollIntervalMs: 200) + #expect(smoother.currentOffset == 0.0) + + smoother.process(-10.0) + #expect(smoother.currentOffset < 0.0) + + smoother.reset() + #expect(smoother.currentOffset == 0.0) + } + + @Test("Normal slow path coefficients for small changes") + func slowPathSmoothing() { + let smoother = AgcPhonOffsetSmoother(pollIntervalMs: 200) + + // 1. We manually set a start state. To do that without exposing internal vars, + // we can run a process step or design math from 0. + // Let's process a small negative change: 0 to -1.0. + // delta = -1.0 (gain decreasing). + // Since abs(delta) = 1.0 <= 6.0, it should use the 10s release coefficient: + // coeff = 1 - exp(-200 / 10000) = 1 - exp(-0.02) = 0.0198013 + // expected = 0 + coeff * (-1.0) = -0.0198013 + let offset1 = smoother.process(-1.0) + #expect(abs(offset1 - (-0.0198013)) < 1e-5) + + // 2. Now process a small positive change: -0.0198013 to 0.0. + // delta = 0.0198013 (gain increasing). + // Since abs(delta) <= 6.0, it should use the 3s attack coefficient: + // coeff = 1 - exp(-200 / 3000) = 1 - exp(-0.0666667) = 0.0644930 + // expected = -0.0198013 + coeff * (0 - (-0.0198013)) = -0.0198013 + 0.0644930 * 0.0198013 = -0.0185242 + let offset2 = smoother.process(0.0) + #expect(abs(offset2 - (-0.0185242)) < 1e-5) + } + + @Test("Fast path coefficients for large jumps (above 6 dB)") + func fastPathSmoothing() { + let smoother = AgcPhonOffsetSmoother(pollIntervalMs: 200) + + // 1. Large negative jump: 0 to -10.0. + // delta = -10.0 (gain decreasing). + // Since abs(delta) = 10.0 > 6.0, it should use the fast release coefficient (3s time constant): + // coeff = 1 - exp(-200 / 3000) = 0.0644930 + // expected = 0 + 0.0644930 * (-10.0) = -0.644930 + let offset1 = smoother.process(-10.0) + #expect(abs(offset1 - (-0.644930)) < 1e-5) + + // Let's reset. + smoother.reset() + + // 2. To test large positive jump, we first need to establish a negative value. + // We can force it by doing a large negative jump first, then doing a large positive jump. + // Let's process -10.0 multiple times to settle it near -10.0. + for _ in 0..<400 { + _ = smoother.process(-10.0) + } + let settledOffset = smoother.currentOffset + #expect(settledOffset < -9.5) // should be very close to -10 + + // Now process a large positive jump to 0.0. + // delta = 0.0 - settledOffset > 9.5 (> 6.0). + // Since delta > 6.0, it should use the fast attack coefficient (1s time constant): + // coeff = 1 - exp(-200 / 1000) = 1 - exp(-0.2) = 0.1812692 + // expected = settledOffset + 0.1812692 * (0.0 - settledOffset) + let expectedPositiveJumpOffset = settledOffset + 0.1812692 * (0.0 - settledOffset) + let offset2 = smoother.process(0.0) + #expect(abs(offset2 - expectedPositiveJumpOffset) < 1e-4) + } +} diff --git a/FineTuneTests/AudioEngineTapInitialStateTests.swift b/FineTuneTests/AudioEngineTapInitialStateTests.swift index 2873c470..3a58a2ef 100644 --- a/FineTuneTests/AudioEngineTapInitialStateTests.swift +++ b/FineTuneTests/AudioEngineTapInitialStateTests.swift @@ -20,7 +20,7 @@ final class RecordingProcessTapController: ProcessTapControlling { case updateEQSettings(EQSettings) case updateAutoEQProfile(profileID: String?) case setAutoEQPreampEnabled(Bool) - case updateLoudnessCompensation(volume: Float, enabled: Bool) + case updateLoudnessCompensation(volume: Float, enabled: Bool, intensity: Float) case updateLoudnessEqualization(LoudnessEqualizerSettings) case invalidate } @@ -84,8 +84,8 @@ final class RecordingProcessTapController: ProcessTapControlling { events.append(.setAutoEQPreampEnabled(enabled)) } - func updateLoudnessCompensation(volume: Float, enabled: Bool) { - events.append(.updateLoudnessCompensation(volume: volume, enabled: enabled)) + func updateLoudnessCompensation(volume: Float, enabled: Bool, intensity: Float) { + events.append(.updateLoudnessCompensation(volume: volume, enabled: enabled, intensity: intensity)) } func updateLoudnessEqualization(_ settings: LoudnessEqualizerSettings) { @@ -104,6 +104,7 @@ final class RecordingProcessTapController: ProcessTapControlling { func isHealthCheckEligible(minActiveSeconds: Double) -> Bool { false } func refreshTapSource(_ preferredDeviceUID: String?) async throws {} + func recreateForOutputRateChange() async throws {} } // MARK: - Process monitor stub diff --git a/FineTuneTests/ISO226ContoursTests.swift b/FineTuneTests/ISO226ContoursTests.swift index a5e4a436..c01c7ebb 100644 --- a/FineTuneTests/ISO226ContoursTests.swift +++ b/FineTuneTests/ISO226ContoursTests.swift @@ -184,6 +184,52 @@ struct LoudnessCompensatorHeadroomTests { #expect(maxAbsError <= 3.0, "Max fitted-response error should stay within 3 dB, got \(maxAbsError) dB") } + @Test("Headroom subtraction ensures cascade peak never exceeds 0 dBFS") + func headroomSubtractionKeepsPeakAtOrBelowZero() { + let sampleRate = 48_000.0 + // Test at multiple phon levels where compensation is active + for phon in [20.0, 30.0, 40.0, 50.0, 60.0] { + let fittedGains = LoudnessCompensator.fittedSectionGains(forPhon: phon, sampleRate: sampleRate) + let rawCoefficients = LoudnessCompensator.coefficientsForBands(gains: fittedGains, sampleRate: sampleRate) + + // Find the peak of the raw cascade response + var maxRawDB: Double = -200 + for index in 0..<96 { + let frequency = 20.0 * pow(20_000.0 / 20.0, Double(index) / 95.0) + let responseDB = cascadeResponseDB( + coefficients: rawCoefficients, + sectionCount: LoudnessCompensator.bandCount, + sampleRate: sampleRate, + frequency: frequency + ) + if responseDB > maxRawDB { maxRawDB = responseDB } + } + let headroom = max(maxRawDB, 0.0) + + // Subtract headroom from all gains and recompute coefficients + let adjustedGains = fittedGains.map { $0 - Float(headroom) } + let adjustedCoefficients = LoudnessCompensator.coefficientsForBands( + gains: adjustedGains, + sampleRate: sampleRate + ) + + // Verify the adjusted cascade peak is at or below 0 dBFS + var maxAdjustedDB: Double = -200 + for index in 0..<96 { + let frequency = 20.0 * pow(20_000.0 / 20.0, Double(index) / 95.0) + let responseDB = cascadeResponseDB( + coefficients: adjustedCoefficients, + sectionCount: LoudnessCompensator.bandCount, + sampleRate: sampleRate, + frequency: frequency + ) + if responseDB > maxAdjustedDB { maxAdjustedDB = responseDB } + } + #expect(maxAdjustedDB <= 0.1, + "At \(phon) phon, cascade peak \(maxAdjustedDB) dB should be ≤ 0 dBFS after headroom subtraction") + } + } + private func cascadeResponseDB( coefficients: [Double], sectionCount: Int, @@ -243,14 +289,14 @@ struct EstimatedPhonBoundaryTests { @Test("Zero volume maps to lower bound of phon range (20 phon)") func zeroVolumeMapsToLowerBound() { - // volume=0 → sqrt(0)=0 → 20 + 60*0 = 20 phon + // volume=0 → 20 + 60*0 = 20 phon let phon = ISO226Contours.estimatedPhon(fromSystemVolume: 0.0) expectClose(phon, 20.0, tolerance: 0.001) } @Test("Full volume maps to reference phon (80 phon)") func fullVolumeMapsToReferencePhon() { - // volume=1.0 → sqrt(1.0)=1.0 → 20 + 60*1.0 = 80 phon + // volume=1.0 → 20 + 60*1.0 = 80 phon let phon = ISO226Contours.estimatedPhon(fromSystemVolume: 1.0) expectClose(phon, ISO226Contours.defaultReferencePhon, tolerance: 0.001) } @@ -269,11 +315,11 @@ struct EstimatedPhonBoundaryTests { expectClose(phon, 20.0, tolerance: 0.001) } - @Test("Quarter volume maps to midpoint via square-root curve") + @Test("Quarter volume maps to 36.25 phon via linear curve") func quarterVolumeMidpoint() { - // volume=0.25 → sqrt(0.25)=0.5 → 20 + 60*0.5 = 50 phon + // volume=0.25 → 20 + 65*0.25 = 36.25 phon let phon = ISO226Contours.estimatedPhon(fromSystemVolume: 0.25) - expectClose(phon, 50.0, tolerance: 0.01) + expectClose(phon, 36.25, tolerance: 0.01) } } diff --git a/FineTuneTests/LoudnessEqualizerTests.swift b/FineTuneTests/LoudnessEqualizerTests.swift index b7b55c9f..4c22027e 100644 --- a/FineTuneTests/LoudnessEqualizerTests.swift +++ b/FineTuneTests/LoudnessEqualizerTests.swift @@ -10,23 +10,22 @@ struct LoudnessEqualizerTests { // MARK: - LoudnessEqualizerSettings - @Test("Settings default to approved MVP values") + @Test("Settings default to approved values") func settingsDefaults() { let s = LoudnessEqualizerSettings() - #expect(s.targetLoudnessDb == -12) - #expect(s.maxBoostDb == 6) - #expect(s.maxCutDb == 4) - #expect(s.compressionThresholdOffsetDb == 6) - #expect(s.compressionRatio == 1.6) - #expect(s.compressionKneeDb == 8) - #expect(s.analysisWindowMs == 400) - #expect(s.analysisHopMs == 100) - #expect(s.detectorAttackMs == 25) - #expect(s.detectorReleaseMs == 600) - #expect(s.gainAttackMs == 250) - #expect(s.gainReleaseMs == 3000) - #expect(s.noiseFloorThresholdDb == -40) - #expect(s.lowLevelMaxBoostDb == 0.5) + #expect(s.driveDb == 24.0) + #expect(s.targetLevelDb == -7.9) + #expect(s.attackSpeedDbPerSecPer6Db == 6.0) + #expect(s.releaseSpeedDbPerSecPer6Db == 2.45) + #expect(s.suddenJumpProtectionEnabled == true) + #expect(s.silenceGateThresholdDb == -16.0) + #expect(s.silenceGateSlowdownDb == -12.0) + #expect(s.agcWindowSizeDb == 4.5) + #expect(s.progressiveRatioEnabled == true) + #expect(s.minRatio == 2.0) + #expect(s.maxRatio == Float.infinity) + #expect(s.progressiveRate == 0.15) + #expect(s.silenceGateIdleGainDb == -24.0) #expect(s.enabled == false) } @@ -38,7 +37,7 @@ struct LoudnessEqualizerTests { for db in testValues { let linear = LoudnessEqualizerMath.dbToLinear(db) let roundTripped = LoudnessEqualizerMath.linearToDb(linear) - #expect(abs(roundTripped - db) < 0.001, + #expect(abs(roundTripped - db) < 0.05, "Round-trip failed for \(db) dB: got \(roundTripped)") } // 0 dB should map to linear 1.0 @@ -161,188 +160,6 @@ struct LoudnessEqualizerTests { "Gain at 2 kHz (\(gainAtFreq[2000.0]!) dB) should exceed gain at 1 kHz (\(gainAtFreq[1000.0]!) dB)") } - // MARK: - LoudnessDetector - - @Test("ingest() returns nil between hop boundaries and a level at boundaries") - func ingestReturnsAtHopBoundaries() { - var settings = LoudnessEqualizerSettings() - settings.analysisWindowMs = 30 - settings.analysisHopMs = 15 - let sampleRate: Float = 48000 - let detector = LoudnessDetector(settings: settings, sampleRate: sampleRate) - - // hopSamples = Int(15/1000 * 48000) = 720 - let hopSamples = 720 - let amplitude: Float = 0.5 - - var hopCount = 0 - var nilCount = 0 - - // Feed exactly 2 hops worth of samples - for i in 0..<(hopSamples * 2) { - let result = detector.ingest(weightedSample: amplitude) - if i == hopSamples - 1 || i == hopSamples * 2 - 1 { - // At hop boundaries, should return a value - #expect(result != nil, - "ingest() should return a level at hop boundary (sample \(i))") - hopCount += 1 - } else { - if result == nil { nilCount += 1 } - } - } - - #expect(hopCount == 2, "Should have exactly 2 hop boundaries in 1440 samples") - #expect(nilCount == hopSamples * 2 - 2, - "All non-boundary samples should return nil") - } - - @Test("ingest() converges to analytically expected RMS level for constant-amplitude DC signal") - func ingestConvergesToExpectedLevel() { - var settings = LoudnessEqualizerSettings() - settings.analysisWindowMs = 30 - settings.analysisHopMs = 15 - // Use fast attack to speed convergence in test - settings.detectorAttackMs = 10 - settings.detectorReleaseMs = 10 - let sampleRate: Float = 48000 - let detector = LoudnessDetector(settings: settings, sampleRate: sampleRate) - - let amplitude: Float = 0.5 - // Analytical expected level for DC signal: - // Mean square = A² = 0.25 - // Level = 10 * log10(0.25) = 10 * (-0.60206) = -6.0206 dB - let expectedLevelDb: Float = 10.0 * log10(amplitude * amplitude) - - // Feed enough samples for ring buffer to fill AND envelope to converge - // windowSamples=1440, hopSamples=720, need ~20 hops for convergence - let totalSamples = 720 * 25 - var lastLevel: Float = -120.0 - - for _ in 0.. releaseGain, - "Attack moved \(attackGain) dB in 5 steps; release moved \(releaseGain) dB — attack should be faster") - } - - // MARK: - GainComputer - - @Test("Gain computer boosts quiet material and softly cuts louder material") - func gainComputerClamps() { - var settings = LoudnessEqualizerSettings() - settings.targetLoudnessDb = -12 - settings.maxBoostDb = 4 - settings.maxCutDb = 6 - settings.compressionThresholdOffsetDb = 4 - settings.compressionRatio = 1.5 - settings.compressionKneeDb = 6 - settings.noiseFloorThresholdDb = -80 // disable noise floor for this test - let computer = GainComputer(settings: settings) - - let quietSignal = computer.desiredGainDb(forLevelDb: -18) - #expect(quietSignal == settings.maxBoostDb, "Quiet signals should still boost toward target") - - let withinKnee = computer.desiredGainDb(forLevelDb: -10) - #expect(withinKnee < 0, "Signals inside the soft knee should get a small cut") - #expect(withinKnee > -1.5, "Soft-knee cut should stay gentle near the threshold") - - let loudSignal = computer.desiredGainDb(forLevelDb: 12) - #expect(loudSignal == -settings.maxCutDb, "Very loud signals should clamp to maxCutDb") - } - - @Test("Gain computer limits boost below noise floor threshold") - func gainComputerNoiseFloorProtection() { - var settings = LoudnessEqualizerSettings() - settings.targetLoudnessDb = -20 - settings.maxBoostDb = 10 - settings.noiseFloorThresholdDb = -55 - settings.lowLevelMaxBoostDb = 4 - let computer = GainComputer(settings: settings) - - // Signal well below the noise floor threshold should have boost limited to lowLevelMaxBoostDb - let gainAtNoise = computer.desiredGainDb(forLevelDb: -70) - #expect(gainAtNoise <= settings.lowLevelMaxBoostDb, "Boost near noise floor should be capped at lowLevelMaxBoostDb") - } - - // MARK: - GainSmoother - - @Test("Gain smoother reduces gain faster than it recovers") - func gainSmootherAsymmetry() { - var settings = LoudnessEqualizerSettings() - settings.gainAttackMs = 30 // fast reduction (attack toward lower gain) - settings.gainReleaseMs = 700 // slow recovery (release toward higher gain) - let smoother = GainSmoother(settings: settings, sampleRate: 48000) - - // Seed smoother at 0 dB - smoother.reset(initialGainDb: 0) - - // Attack: target is -10 dB (reduction) - var attackLevel: Float = 0 - for _ in 0..<10 { - attackLevel = smoother.process(targetGainDb: -10) - } - - // Reset and seed at -10 dB, then release toward 0 dB - smoother.reset(initialGainDb: -10) - var releaseLevel: Float = -10 - for _ in 0..<10 { - releaseLevel = smoother.process(targetGainDb: 0) - } - - // In 10 steps the gain reduction (attack) should cover more distance than recovery (release) - let attackDistance = abs(attackLevel - 0) // how far from 0 dB after attack steps - let releaseDistance = abs(releaseLevel - (-10)) // how far from -10 dB after release steps - - #expect(releaseDistance < attackDistance, - "Gain smoother should recover (\(releaseDistance) dB) slower than it reduces (\(attackDistance) dB)") - } - // MARK: - LoudnessEqualizer @Test("Shared gain preserves left-right ratio for interleaved stereo buffers") @@ -386,7 +203,6 @@ struct LoudnessEqualizerTests { #expect(sumLeft > 0, "Left channel output should be non-zero") #expect(sumRight > 0, "Right channel output should be non-zero") - // Verify L/R ratio is preserved within 1% tolerance // Both channels receive the same gain factor, so ratio should stay 2:1 let ratio = sumLeft / sumRight #expect(abs(ratio - 2.0) < 0.02, @@ -486,4 +302,1236 @@ struct LoudnessEqualizerTests { "Disabling should clear residual gain immediately at sample \(index); expected \(quietInput[index]), got \(quietOutput[index])") } } + + // MARK: - Downward-only behavior + + @Test("Downward-only AGC attenuates loud signals to target level") + func loudSignalAttenuatedToTarget() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + // suddenJumpProtectionEnabled = true by default — ensures no peak exceeds target + let sampleRate: Float = 48000 + let frameCount = 65536 // ~1.36 seconds + let channelCount = 2 + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + let driveLinear = LoudnessEqualizerMath.dbToLinear(settings.driveDb) + let targetDb = settings.targetLevelDb // -7.9 + + // 1 kHz sine at amplitude 0.5 + var input = [Float](repeating: 0, count: frameCount * channelCount) + for frame in 0.. maxOutputLevelDbFS { maxOutputLevelDbFS = levelDb } + } + #expect(maxOutputLevelDbFS <= targetDb + 4.01, + "Output peak level \(maxOutputLevelDbFS) dBFS should not exceed target + 4 dB (\(targetDb + 4.0) dBFS)") + // Also verify attenuation is happening: output is below the raw driven peak level + let drivenPeak: Float = 0.5 * driveLinear // 8.015 + let drivenPeakDb = LoudnessEqualizerMath.linearToDb(drivenPeak) + #expect(maxOutputLevelDbFS < drivenPeakDb - 10, + "Output should be attenuated at least 10 dB below driven peak \(drivenPeakDb) dBFS") + } + + @Test("Downward-only AGC does not attenuate quiet signals (gain = 0 dB)") + func quietSignalNoAttenuation() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.releaseSpeedDbPerSecPer6Db = 100.0 // Fast release so gain settles from -24.1 dB to 0 dB quickly + settings.silenceGateSlowdownDb = -60.0 // Disable gate slowdown for this test + settings.silenceGateThresholdDb = -60.0 // Disable silence gate for this test + settings.silenceGateIdleGainDb = 0.0 // Idle gain at 0 dB + let sampleRate: Float = 48000 + let frameCount = 16384 // enough to verify gain stays at 0 + let channelCount = 2 + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + let driveLinear = LoudnessEqualizerMath.dbToLinear(settings.driveDb) + + // Very quiet 1 kHz sine at amplitude 0.001 + var input = [Float](repeating: 0, count: frameCount * channelCount) + for frame in 0.. -0.5, + "Quiet signal gain should be near 0 dB, got \(measuredGainDb) dB") + } + + // MARK: - Sudden Jump Protection + + @Test("Sudden Jump Protection prevents output from exceeding target level") + func suddenJumpProtectionLimitsPeaks() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.suddenJumpProtectionEnabled = true + let sampleRate: Float = 48000 + let channelCount = 2 + let silenceFrames = 4800 // 100ms of silence to settle gain to -24 dB + let transientFrames = 480 // 10ms transient block + let totalFrames = silenceFrames + transientFrames + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + var input = [Float](repeating: 0, count: totalFrames * channelCount) + // Silence fills the first silenceFrames (all zeros), then a loud 1 kHz sine wave + for frame in 0.. maxPeak { maxPeak = peak } + } + let maxPeakDb = LoudnessEqualizerMath.linearToDb(maxPeak) + let targetDb = settings.targetLevelDb + #expect(maxPeakDb <= targetDb + 6.5, + "Output peak level \(maxPeakDb) dBFS should be controlled near target + 6.5 dB by the end of SJP attack") + } + + // MARK: - Attack/Release asymmetry + + @Test("Attack rate exceeds release rate (asymmetric ballistics)") + func attackFasterThanRelease() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.suddenJumpProtectionEnabled = false // Disable to test ballistics asymmetry + settings.agcWindowSizeDb = 0.0 + settings.silenceGateThresholdDb = -60.0 // Disable silence gate for this test + settings.silenceGateSlowdownDb = -60.0 // Disable slowdown for this test + let sampleRate: Float = 48000 + let channelCount = 2 + let blockFrames = 480 // 10ms blocks for fine-grained measurement + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + let driveLinear = LoudnessEqualizerMath.dbToLinear(settings.driveDb) + + // Layout (gain starts at -24.1 dB; warmup lets it recover toward 0 dB): + // Warmup: frames 0-95999 — quiet signal (2s, 0.01 amplitude, lets gain recover) + // Phase 1: frames 96000-143999 — loud signal (1s, 0.5 amplitude) + // Phase 2: frames 144000-191999 — silence (1s, for envelope decay) + // Phase 3: frames 192000-239999 — quiet signal (1s, 0.01 amplitude, below target) + let totalFrames = 240000 + var input = [Float](repeating: 0, count: totalFrames * channelCount) + + // Warmup: quiet signal to let AGC settle from -24.1 dB toward 0 dB + for frame in 0..<96000 { + let phase = Float(2.0 * Double.pi * 1000.0 * Double(frame) / Double(sampleRate)) + let sample: Float = 0.01 * sin(phase) + let base = frame * channelCount + input[base] = sample + input[base + 1] = sample + } + // Phase 1: Loud + for frame in 96000..<144000 { + let phase = Float(2.0 * Double.pi * 1000.0 * Double(frame) / Double(sampleRate)) + let sample: Float = 0.5 * sin(phase) + let base = frame * channelCount + input[base] = sample + input[base + 1] = sample + } + // Phase 2: Silence — already zero + // Phase 3: Quiet (below target after drive) + for frame in 192000.. Float { + let endFrame = min(startFrame + blockFrames, totalFrames) + guard endFrame > startFrame else { return -120 } + var inSumSq: Double = 0 + var outSumSq: Double = 0 + var count = 0 + for frame in startFrame.. 1e-10 else { return -120 } + let gainLin = outRMS / (inRMS * driveLinear) + return LoudnessEqualizerMath.linearToDb(gainLin) + } + + // ── Attack measurement ── + // Phase 1 starts at frame 96000. Skip first 5000 frames for envelope follower + // + K-weighting settling. Then measure 20 consecutive blocks (200ms). + let attackStart = 96000 + 5000 + let numBlocks = 20 + var attackGains: [Float] = [] + for i in 0.. 0, + "Release should increase gain (avg delta = \(avgReleaseDelta) dB/block)") + + // The magnitude of attack rate should exceed release rate (asymmetric ballistics) + #expect(abs(avgAttackDelta) > abs(avgReleaseDelta), + "Attack rate \(abs(avgAttackDelta)) dB/block should exceed release rate \(abs(avgReleaseDelta)) dB/block") + } + + // MARK: - Silence Gate (Anti-Breathing) + + @Test("Silence gate freezes gain during quiet passages") + func silenceGateFreezesGain() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.suddenJumpProtectionEnabled = false // avoid interference + settings.silenceGateThresholdDb = 5.0 + settings.silenceGateFallbackTimeS = 0.0 // disable fallback so gain stays frozen + + let sampleRate: Float = 48000 + let channelCount = 2 + let blockFrames = 4800 // 100ms blocks + let driveLinear = LoudnessEqualizerMath.dbToLinear(settings.driveDb) + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + // Layout: + // Phase 1: frames 0-47999 — loud signal (1s, 0.5 amplitude) to push gain negative + // Phase 2: frames 48000-95999 — very quiet signal (1s, 0.0005 amplitude, level ≈ -45 dBFS, + // well below -40 dB silence gate threshold) + let totalFrames = 96000 + var input = [Float](repeating: 0, count: totalFrames * channelCount) + + for frame in 0..<48000 { + let phase = Float(2.0 * Double.pi * 1000.0 * Double(frame) / Double(sampleRate)) + let sample: Float = 0.5 * sin(phase) + let base = frame * channelCount + input[base] = sample + input[base + 1] = sample + } + for frame in 48000.. Float { + let endFrame = min(startFrame + blockFrames, totalFrames) + guard endFrame > startFrame else { return -120 } + var inSumSq: Double = 0 + var outSumSq: Double = 0 + var count = 0 + for frame in startFrame.. 1e-10 else { return -120 } + let gainLin = outRMS / (inRMS * driveLinear) + return LoudnessEqualizerMath.linearToDb(gainLin) + } + + // Gain at end of phase 1 (last 100ms of loud signal) + let gainPhase1 = blockGainDb(startFrame: 43200) + // Gain at end of phase 2 (last 100ms of quiet signal — should be frozen near phase1 value) + let gainPhase2 = blockGainDb(startFrame: 91200) + + #expect(gainPhase1 < -5, + "Gain after loud signal should be < -5 dB, got \(gainPhase1)") + #expect(gainPhase2 < -5, + "Gain after quiet passage should still be < -5 dB (frozen), got \(gainPhase2)") + // Gain should not have recovered significantly (silence gate prevents release) + #expect(abs(gainPhase2 - gainPhase1) < 3.0, + "Gain should be frozen by silence gate: change \(gainPhase2 - gainPhase1) dB, expected < 3 dB") + } + + // MARK: - AGC Window (Dead Zone) + + @Test("AGC window holds gain within comfort zone") + func agcWindowHoldsGainWithinComfortZone() { + let sampleRate: Float = 48000 + let channelCount = 2 + let driveLinear = LoudnessEqualizerMath.dbToLinear(LoudnessEqualizerSettings().driveDb) + + var settingsWideWindow = LoudnessEqualizerSettings() + settingsWideWindow.enabled = true + settingsWideWindow.suddenJumpProtectionEnabled = false + settingsWideWindow.agcWindowSizeDb = 20.0 // ±10 dB dead zone + settingsWideWindow.releaseSpeedDbPerSecPer6Db = 100.0 + settingsWideWindow.progressiveRatioEnabled = false + settingsWideWindow.silenceGateThresholdDb = -60.0 + settingsWideWindow.silenceGateSlowdownDb = -60.0 + settingsWideWindow.silenceGateIdleGainDb = 0.0 + + var settingsZeroWindow = LoudnessEqualizerSettings() + settingsZeroWindow.enabled = true + settingsZeroWindow.suddenJumpProtectionEnabled = false + settingsZeroWindow.agcWindowSizeDb = 0.0 // no dead zone + settingsZeroWindow.releaseSpeedDbPerSecPer6Db = 100.0 + settingsZeroWindow.progressiveRatioEnabled = false + settingsZeroWindow.silenceGateThresholdDb = -60.0 + settingsZeroWindow.silenceGateSlowdownDb = -60.0 + settingsZeroWindow.silenceGateIdleGainDb = 0.0 + + let eqWide = LoudnessEqualizer(settings: settingsWideWindow, sampleRate: sampleRate) + let eqZero = LoudnessEqualizer(settings: settingsZeroWindow, sampleRate: sampleRate) + + // Phase 1: Warmup with a low-level signal to release the initial gain from -24.1 dB toward 0 dB. + // Signal at amplitude 0.003 → driven peak ≈ 0.048 (~-26 dBFS). This is: + // - Below -7.9 dBFS target (so zero-window instance RELEASES) + // - Below -17.9 dBFS target - halfWindow (so wide-window instance RELEASES) + // - Above -40 dBFS silence gate threshold (so gain is NOT frozen) + // With proportional release (100 dB/s per 6 dB deficit), the gain reaches < -0.01 dB + // in ~16000 frames. We use 24000 frames (500 ms) for safety margin. + let warmupFrames = 24000 // 500 ms — plenty to release 24 dB + let testFrames = 72000 // 1.5 s — plenty of time for attack to manifest + let totalFrames = warmupFrames + testFrames + + // Test signal at amplitude 0.05 → driven peak ≈ 0.8, K-weighted envelope ≈ -2 dBFS. + // delta = -2 - (-7.9) ≈ 5.9 dB, well within the ±10 dB comfort zone of the wide window. + // For the zero-window instance, this delta triggers attack toward targetGainDb = -5.9 dB. + let testAmplitude: Float = 0.05 + + var input = [Float](repeating: 0, count: totalFrames * channelCount) + + // Warmup section + let warmupAmplitude: Float = 0.003 + for frame in 0.. Float { + guard endFrame > startFrame else { return -120 } + var inSumSq: Double = 0 + var outSumSq: Double = 0 + var count = 0 + for frame in startFrame.. 1e-10 else { return -120 } + return LoudnessEqualizerMath.linearToDb(outRMS / (inRMS * driveLinear)) + } + + // Measure gain over the last 100 ms of the test section + let measureStart = totalFrames - 4800 + let gainWide = measureGainDb(output: outputWide, startFrame: measureStart, endFrame: totalFrames) + let gainZero = measureGainDb(output: outputZero, startFrame: measureStart, endFrame: totalFrames) + + // After warmup, gain for both instances should be near 0 dB. + // Test signal (amplitude 0.05) is ~5.9 dB above target: + // - 20 dB window (±10 dB): |5.9| ≤ 10 → HOLD → gain stays near 0 dB + // - 0 dB window: 5.9 > 0 → ATTACK → gain goes toward targetGainDb = -5.9 dB + #expect(gainWide > -2.0, + "With 20 dB window, gain should stay near 0 dB (held by window), got \(gainWide) dB") + #expect(gainZero < -5.0, + "Without window, gain should be significantly negative (attack active), got \(gainZero) dB") + #expect(gainWide > gainZero, + "Wide window gain \(gainWide) dB should exceed zero window gain \(gainZero) dB") + } + + @Test("AGC window allows recovery/release when starting fully attenuated and input is inside window") + func agcWindowAllowsRecoveryWhenStartingAttenuated() { + let sampleRate: Float = 48000 + let channelCount = 2 + + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.driveDb = 24.0 + settings.targetLevelDb = -7.9 + settings.agcWindowSizeDb = 4.5 + settings.releaseSpeedDbPerSecPer6Db = 2.45 + settings.silenceGateThresholdDb = -16.0 + settings.silenceGateSlowdownDb = -12.0 + settings.silenceGateIdleGainDb = -24.0 + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + // Quiet signal: -30 dBFS peak + // With drive +24 dB, driven peak is -6.0 dBFS. + // The level is inside the comfort window, but the gain starts at -24.0 dB. + // We expect it to recover towards 0.0 dB (so it is not stuck at -24.0 dB). + let amplitude = LoudnessEqualizerMath.dbToLinear(-30.0) + let frameCount = 48000 // 1 second + + var input = [Float](repeating: 0, count: frameCount * channelCount) + for frame in 0.. -23.0, + "Gain should have recovered from -24.0 dB startup attenuation, got \(finalGain) dB") + } + + // MARK: - Silence Gate Fallback, Gate Slowdown, and Sudden Drop Protection + + @Test("Silence gate fallback slowly recovers gain during quiet passages") + func silenceGateFallbackSlowlyRecoversGain() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.suddenJumpProtectionEnabled = false // avoid interference + settings.silenceGateThresholdDb = 5.0 // set high threshold to trigger silence gate + settings.silenceGateFallbackTimeS = 0.5 // short fallback time for faster drift + settings.silenceGateIdleGainDb = 0.0 + + let sampleRate: Float = 48000 + let channelCount = 2 + let blockFrames = 4800 // 100ms blocks + let driveLinear = LoudnessEqualizerMath.dbToLinear(settings.driveDb) + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + // Phase 1: loud signal to push gain negative (0.5s) + // Phase 2: quiet signal (1s) to allow fallback recovery + let phase1Frames = 24000 + let totalFrames = 72000 + var input = [Float](repeating: 0, count: totalFrames * channelCount) + + for frame in 0.. Float { + let endFrame = min(startFrame + blockFrames, totalFrames) + guard endFrame > startFrame else { return -120 } + var inSumSq: Double = 0 + var outSumSq: Double = 0 + var count = 0 + for frame in startFrame.. 1e-10 else { return -120 } + let gainLin = outRMS / (inRMS * driveLinear) + return LoudnessEqualizerMath.linearToDb(gainLin) + } + + let gainPhase1 = blockGainDb(startFrame: phase1Frames - blockFrames) + let gainPhase2 = blockGainDb(startFrame: totalFrames - blockFrames) + + #expect(gainPhase1 < -5.0, "Gain after loud signal should be < -5 dB, got \(gainPhase1)") + #expect(gainPhase2 > gainPhase1 + 2.0, "Gain should recover under fallback drift: gainPhase1=\(gainPhase1), gainPhase2=\(gainPhase2)") + #expect(gainPhase2 <= 0.0, "Gain should not exceed 0 dB, got \(gainPhase2)") + } + + @Test("Gate slowdown reduces release speed when signal is in the transition zone") + func gateSlowdownSlowsRelease() { + var settingsNormal = LoudnessEqualizerSettings() + settingsNormal.enabled = true + settingsNormal.suddenJumpProtectionEnabled = false + settingsNormal.suddenDropProtection = false // disable drop protection + settingsNormal.silenceGateThresholdDb = -40.0 + settingsNormal.silenceGateSlowdownDb = -60.0 // disable slowdown for normal + settingsNormal.releaseSpeedDbPerSecPer6Db = 1.0 // slow release + + var settingsSlow = LoudnessEqualizerSettings() + settingsSlow.enabled = true + settingsSlow.suddenJumpProtectionEnabled = false + settingsSlow.suddenDropProtection = false // disable drop protection + settingsSlow.silenceGateThresholdDb = -40.0 + settingsSlow.silenceGateSlowdownDb = -20.0 // transition zone up to -20dB + settingsSlow.gateSlowdownFactor = 0.1 + settingsSlow.releaseSpeedDbPerSecPer6Db = 1.0 + + let sampleRate: Float = 48000 + let channelCount = 2 + let blockFrames = 4800 + + // Phase 1: loud signal to push gain negative (0.5s) + // Phase 2: level at -35 dBFS (inside slowdown zone for settingsSlow, above threshold for settingsNormal) (2.5s) + let phase1Frames = 24000 + let totalFrames = 144000 + var input = [Float](repeating: 0, count: totalFrames * channelCount) + + // Drive is 24.1 dB (x16.03). + // To make driven signal in Phase 2 have level -35 dBFS: + // target level is -35 dBFS, so raw amplitude should be 10^(-35/20) / 16.03 = 0.0177 / 16.03 ≈ 0.0011 + for frame in 0.. Float { + let driveLin = LoudnessEqualizerMath.dbToLinear(driveDb) + var inSumSq: Double = 0 + var outSumSq: Double = 0 + var count = 0 + for frame in startFrame.. 0.2, "Normal release should recover gain, got \(recoveryNormal) dB") + #expect(recoverySlow < recoveryNormal * 0.35, "Slow slowdown release (\(recoverySlow) dB) should be much slower than normal (\(recoveryNormal) dB)") + } + + @Test("Sudden drop protection accelerates release when level falls far below target") + func suddenDropProtectionSpeedsUpRelease() { + var settingsNormal = LoudnessEqualizerSettings() + settingsNormal.enabled = true + settingsNormal.suddenJumpProtectionEnabled = false + settingsNormal.suddenDropProtection = false // disable drop protection + settingsNormal.silenceGateSlowdownDb = -60.0 // disable slowdown + settingsNormal.silenceGateThresholdDb = -60.0 // disable silence gate + settingsNormal.releaseSpeedDbPerSecPer6Db = 1.0 // slow release + + var settingsDrop = LoudnessEqualizerSettings() + settingsDrop.enabled = true + settingsDrop.suddenJumpProtectionEnabled = false + settingsDrop.suddenDropProtection = true + settingsDrop.suddenDropThresholdDb = 5.0 // drop below target > 5 dB triggers speedup + settingsDrop.suddenDropSpeedup = 5.0 + settingsDrop.silenceGateSlowdownDb = -60.0 // disable slowdown + settingsDrop.silenceGateThresholdDb = -60.0 // disable silence gate + settingsDrop.releaseSpeedDbPerSecPer6Db = 1.0 + + let sampleRate: Float = 48000 + let channelCount = 2 + let blockFrames = 4800 + + // Phase 1: loud signal to push gain negative (0.5s) + // Phase 2: drop signal to well below target (e.g. 17 dB below target). + // Target is -7.9 dBFS. If driven level is -25 dBFS, delta is -17.1 dB. + // Raw amplitude = 10^(-25/20) / 16.03 = 0.056 / 16.03 ≈ 0.0035 + let phase1Frames = 24000 + let totalFrames = 144000 + var input = [Float](repeating: 0, count: totalFrames * channelCount) + + for frame in 0.. Float { + let driveLin = LoudnessEqualizerMath.dbToLinear(driveDb) + var inSumSq: Double = 0 + var outSumSq: Double = 0 + var count = 0 + for frame in startFrame.. recoveryNormal * 1.5, "Drop-protected release (\(recoveryDrop) dB) should be significantly faster than normal (\(recoveryNormal) dB)") + } + + // MARK: - Orban Progressive Ratio & Idle Gain Fallback + + @Test("Silence gate fallback drifts gain toward negative idle gain from below") + func silenceGateFallbackDriftsToIdleGainFromBelow() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.driveDb = 20.0 + settings.silenceGateThresholdDb = 5.0 // high threshold to trigger gate + settings.silenceGateFallbackTimeS = 0.1 // 100ms time constant + settings.silenceGateIdleGainDb = -10.0 + + let sampleRate: Float = 48000 + let channelCount = 2 + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + // Initial gain starts at -20.0 dB + #expect(abs(eq.currentGainDb - (-20.0)) < 0.01) + + // Process 1 second (48000 frames) of pure silence + let silenceFrames = 48000 + let input = [Float](repeating: 0.0, count: silenceFrames * channelCount) + var output = [Float](repeating: 0.0, count: silenceFrames * channelCount) + + input.withUnsafeBufferPointer { inPtr in + output.withUnsafeMutableBufferPointer { outPtr in + eq.process( + input: inPtr.baseAddress!, + output: outPtr.baseAddress!, + frameCount: silenceFrames, + channelCount: channelCount + ) + } + } + + // Final gain should have drifted up toward -10.0 dB + let finalGain = eq.currentGainDb + #expect(abs(finalGain - (-10.0)) < 0.1, "Expected gain to drift to -10.0 dB, got \(finalGain) dB") + } + + @Test("Silence gate fallback drifts gain toward negative idle gain from above") + func silenceGateFallbackDriftsToIdleGainFromAbove() { + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.driveDb = 0.0 + settings.silenceGateThresholdDb = 5.0 + settings.silenceGateFallbackTimeS = 0.1 + settings.silenceGateIdleGainDb = -10.0 + + let sampleRate: Float = 48000 + let channelCount = 2 + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + #expect(abs(eq.currentGainDb - 0.0) < 0.01) + + let silenceFrames = 48000 + let input = [Float](repeating: 0.0, count: silenceFrames * channelCount) + var output = [Float](repeating: 0.0, count: silenceFrames * channelCount) + + input.withUnsafeBufferPointer { inPtr in + output.withUnsafeMutableBufferPointer { outPtr in + eq.process( + input: inPtr.baseAddress!, + output: outPtr.baseAddress!, + frameCount: silenceFrames, + channelCount: channelCount + ) + } + } + + let finalGain = eq.currentGainDb + #expect(abs(finalGain - (-10.0)) < 0.1, "Expected gain to drift down to -10.0 dB, got \(finalGain) dB") + } + + @Test("Progressive ratio applies soft-knee compression slope") + func progressiveRatioAppliesSoftKnee() { + let sampleRate: Float = 48000 + let channelCount = 2 + + // Instance A: Brickwall AGC (Progressive Ratio disabled) + var settingsBrick = LoudnessEqualizerSettings() + settingsBrick.enabled = true + settingsBrick.driveDb = 0.0 + settingsBrick.targetLevelDb = -10.0 + settingsBrick.agcWindowSizeDb = 0.0 + settingsBrick.progressiveRatioEnabled = false + settingsBrick.attackSpeedDbPerSecPer6Db = 1000.0 // instant attack + settingsBrick.suddenJumpProtectionEnabled = false + + // Instance B: Progressive Ratio AGC + var settingsProg = LoudnessEqualizerSettings() + settingsProg.enabled = true + settingsProg.driveDb = 0.0 + settingsProg.targetLevelDb = -10.0 + settingsProg.agcWindowSizeDb = 0.0 + settingsProg.progressiveRatioEnabled = true + settingsProg.minRatio = 2.0 + settingsProg.maxRatio = Float.infinity + settingsProg.progressiveRate = 0.15 + settingsProg.attackSpeedDbPerSecPer6Db = 1000.0 // instant attack + settingsProg.suddenJumpProtectionEnabled = false + + let eqBrick = LoudnessEqualizer(settings: settingsBrick, sampleRate: sampleRate) + let eqProg = LoudnessEqualizer(settings: settingsProg, sampleRate: sampleRate) + + // Warm up / process a loud 1 kHz sine wave at amplitude 0.5 (~ -6 dBFS K-weighted/RMS). + // Since target is -10 dBFS, overshoot is ~4 dB. + let testFrames = 4800 + var input = [Float](repeating: 0.0, count: testFrames * channelCount) + for frame in 0.. gainBrick + 0.5, "Progressive ratio should compress less, got \(gainProg) dB vs brickwall \(gainBrick) dB") + #expect(gainProg < -1.0, "Progressive ratio should still compress, got \(gainProg) dB") + } + + @Test("Custom parametric sidechain filter alters gain response depending on frequency") + func customSidechainFilterAltersResponse() { + let sampleRate: Float = 48000 + let channelCount = 2 + + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.driveDb = 12.0 + settings.targetLevelDb = -12.0 + settings.agcWindowSizeDb = 0.0 + settings.attackSpeedDbPerSecPer6Db = 10.0 + settings.releaseSpeedDbPerSecPer6Db = 10.0 + settings.suddenJumpProtectionEnabled = false + + let eq781 = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + let eq2500 = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + let testFrames = 9600 // 0.2 seconds + var input781 = [Float](repeating: 0.0, count: testFrames * channelCount) + var input2500 = [Float](repeating: 0.0, count: testFrames * channelCount) + for frame in 0.. finalGain2500 + 1.0, "Custom filter should compress a 781Hz signal less than a 2500Hz signal due to its -8.1 dB peak cut: 781Hz \(finalGain781) dB vs 2500Hz \(finalGain2500) dB") + } + + @Test("Bass band gain is clamped to master band gain plus 3.0 dB under bass-light input") + func bassBandClampedToMaster() { + let sampleRate: Float = 48000 + let channelCount = 2 + + var settings = LoudnessEqualizerSettings() + settings.enabled = true + settings.driveDb = 24.0 + settings.targetLevelDb = -12.0 + settings.agcWindowSizeDb = 0.0 + settings.attackSpeedDbPerSecPer6Db = 10.0 + settings.releaseSpeedDbPerSecPer6Db = 10.0 + settings.suddenJumpProtectionEnabled = false + + let eq = LoudnessEqualizer(settings: settings, sampleRate: sampleRate) + + // Generate a 1000 Hz sine wave (bass-light signal, completely above 150 Hz crossover) + // at high amplitude to drive the master band into heavy compression. + let testFrames = 4800 // 0.1 seconds + var input = [Float](repeating: 0.0, count: testFrames * channelCount) + for frame in 0.. [Float] { + var signal = [Float](repeating: 0, count: frameCount) + let T = Double(frameCount) / sampleRate + let logRatio = log(endFreq / startFreq) + for i in 0.. Float { + let slice = signal.dropFirst(skipFirst) + guard !slice.isEmpty else { return 0 } + var sum: Float = 0 + for s in slice { sum += s * s } + return sqrt(sum / Float(slice.count)) + } + + private func ampToDB(_ value: Float) -> Float { + return 20.0 * log10(max(value, Float.leastNormalMagnitude)) + } + + @Test("LR2 Crossover sum is identical to input (allpass reconstruction)") + func crossoverReconstruction() { + let frameCount = 4096 + let skipTransient = 2048 + let input = generateSineSweep(startFreq: 20, endFreq: 20000, sampleRate: sampleRate, frameCount: frameCount) + + var crossover = LinkwitzRileyCrossover2(frequency: frequency, sampleRate: sampleRate) + + var summed = [Float](repeating: 0, count: frameCount) + for i in 0.. -10.0) + + // Disable compressor and verify passthrough + let disabledSettings = PostAgcCompressorSettings(thresholdDb: -10.0, maxReleaseSpeed: 1.0, releaseGateStopDb: 0.0, enabled: false) + let disabledCompressor = PostAgcCompressor(settings: disabledSettings, sampleRate: 48000) + var disabledOutput = [Float](repeating: 0, count: 2000) + disabledCompressor.process(input: &input, output: &disabledOutput, frameCount: 1000, channelCount: 2) + #expect(disabledOutput[0] == input[0]) + } + + @Test("Soft-knee transition math is correct") + func softKneeTransition() { + let settings = PostAgcCompressorSettings( + thresholdDb: -10.0, + ratio: 4.0, + attackMs: 0.01, // instant attack + kneeDb: 6.0, // 6 dB knee (-13 dBFS to -7 dBFS) + exponentialRelease: 0.0, + maxReleaseSpeed: 1.0, // no cap, for test simplicity + releaseGateStopDb: 0.0, // disabled, for test simplicity + enabled: true + ) + let compressor = PostAgcCompressor(settings: settings, sampleRate: 48000) + + // Signal inside knee region: e.g. -9.0 dBFS (overshoot > 0 but within knee) + // Let's test a couple of points and verify output changes smoothly + let ampInsideKnee = LoudnessEqualizerMath.dbToLinear(-9.0) + var input = [Float](repeating: ampInsideKnee, count: 200) + var output = [Float](repeating: 0, count: 200) + + for _ in 0..<100 { + compressor.process(input: &input, output: &output, frameCount: 100, channelCount: 2) + } + + let outputDb = LoudnessEqualizerMath.linearToDb(abs(output[0])) + // Since -9 dBFS is inside the soft-knee, it should have some gain reduction, + // but less than the full ratio would dictate. + #expect(outputDb < -9.0) + } + + @Test("Exponential release slows down as gain reduction approaches 0") + func exponentialReleaseBehavior() { + // High exponential release factor (1.0) vs linear/default (0.0) + let expSettings = PostAgcCompressorSettings( + thresholdDb: -20.0, + ratio: 10.0, + attackMs: 0.1, + releaseMs: 50.0, + kneeDb: 0.0, + exponentialRelease: 1.0, // strong exponential release + maxReleaseSpeed: 1.0, // no cap, for test simplicity + releaseGateStopDb: 0.0, // disabled, for test simplicity + enabled: true + ) + + let linSettings = PostAgcCompressorSettings( + thresholdDb: -20.0, + ratio: 10.0, + attackMs: 0.1, + releaseMs: 50.0, + kneeDb: 0.0, + exponentialRelease: 0.0, // standard release + maxReleaseSpeed: 1.0, // no cap, for test simplicity + releaseGateStopDb: 0.0, // disabled, for test simplicity + enabled: true + ) + + let expCompressor = PostAgcCompressor(settings: expSettings, sampleRate: 48000) + let linCompressor = PostAgcCompressor(settings: linSettings, sampleRate: 48000) + + // 1. Force both into compression with a loud signal (0 dBFS) + let loudAmp: Float = 1.0 + var inputLoud = [Float](repeating: loudAmp, count: 2000) + var output = [Float](repeating: 0, count: 2000) + for _ in 0..<10 { + expCompressor.process(input: &inputLoud, output: &output, frameCount: 1000, channelCount: 2) + linCompressor.process(input: &inputLoud, output: &output, frameCount: 1000, channelCount: 2) + } + + // 2. Feed silence to trigger release phase + var inputSilence = [Float](repeating: 0.0, count: 2000) + + // Process a few blocks of silence and compare the remaining attenuation (gain reduction) + // Since releaseMs is 50ms, let's step in small blocks, e.g., 240 frames (5ms) + var expOutputs: [Float] = [] + var linOutputs: [Float] = [] + + for _ in 0..<20 { // 100ms total release monitoring + // We use a tiny probe signal above 0 but below threshold to read the applied gain + var probeInput = [Float](repeating: 0.0001, count: 2) + var expProbeOutput = [Float](repeating: 0, count: 2) + var linProbeOutput = [Float](repeating: 0, count: 2) + + expCompressor.process(input: &inputSilence, output: &output, frameCount: 100, channelCount: 2) + linCompressor.process(input: &inputSilence, output: &output, frameCount: 100, channelCount: 2) + + expCompressor.process(input: &probeInput, output: &expProbeOutput, frameCount: 1, channelCount: 2) + linCompressor.process(input: &probeInput, output: &linProbeOutput, frameCount: 1, channelCount: 2) + + expOutputs.append(expProbeOutput[0] / probeInput[0]) + linOutputs.append(linProbeOutput[0] / probeInput[0]) + } + + // Under exponential release, release gets slower as we get closer to 1.0 gain (0 dB reduction). + // Therefore, the exponential release envelope should recover LESS of the gain toward 1.0 + // in the later stages compared to the linear release. + // Let's verify that the exponential release gain recovery curve is distinct. + // (i.e. exp gain should be lower/more compressed than lin gain at the end of the release phase) + #expect(expOutputs.last! < linOutputs.last!) + } + + @Test("NaN values in input do not propagate and are handled safely") + func nanSafety() { + let settings = PostAgcCompressorSettings(thresholdDb: -0.25, enabled: true) + let compressor = PostAgcCompressor(settings: settings, sampleRate: 48000) + + var input: [Float] = [Float.nan, Float.nan, 0.1, -0.1] + var output = [Float](repeating: 0, count: 4) + + // This should not crash, and output should be clean of NaNs for non-NaN inputs, or replaced safely. + compressor.process(input: &input, output: &output, frameCount: 2, channelCount: 2) + + // Check that non-NaN inputs produce non-NaN outputs + #expect(!output[2].isNaN) + #expect(!output[3].isNaN) + } +} diff --git a/FineTuneTests/ProcessingPipelineTests.swift b/FineTuneTests/ProcessingPipelineTests.swift index fb3b2733..d1aff97d 100644 --- a/FineTuneTests/ProcessingPipelineTests.swift +++ b/FineTuneTests/ProcessingPipelineTests.swift @@ -106,6 +106,7 @@ private func processWithDefaults( eqProc: EQProcessor? = nil, autoEQProc: AutoEQProcessor? = nil, loudnessEqualizerProc: LoudnessEqualizer? = nil, + postAgcCompressorProc: PostAgcCompressor? = nil, loudnessCompensatorProc: LoudnessCompensator? = nil ) { ProcessTapController.processMappedBuffers( @@ -121,6 +122,7 @@ private func processWithDefaults( eqProc: eqProc, autoEQProc: autoEQProc, loudnessEqualizerProc: loudnessEqualizerProc, + postAgcCompressorProc: postAgcCompressorProc, loudnessCompensatorProc: loudnessCompensatorProc ) } @@ -1074,12 +1076,12 @@ struct LoudnessIntegrationTests { let baselineRMS = sqrt(baselineSquaredSum / Double((endSample - startSample) / 2)) let compRMS = sqrt(compSquaredSum / Double((endSample - startSample) / 2)) - // Compensator at low volume boosts bass — output RMS should differ from baseline + // Compensator shapes the spectrum — output RMS should differ from baseline #expect(abs(compRMS - baselineRMS) > 0.001, "Compensated RMS (\(compRMS)) should differ measurably from baseline (\(baselineRMS))") - // At low volume, 60 Hz bass should be boosted (ISO 226 shows increased bass sensitivity loss at low phon) - #expect(compRMS > baselineRMS, - "60 Hz bass should be boosted at low volume: compensated RMS=\\(compRMS) vs baseline=\\(baselineRMS)") + // Note: After headroom-aware gain normalization, the compensator preserves spectral + // shape but shifts overall level down by the cascade peak. At 60 Hz the net effect + // may not be a pure RMS increase — we verify spectral change via the RMS difference above. } @Test("Loudness equalizer modifies output vs nil-processor baseline when enabled") diff --git a/FineTuneTests/SettingsManagerTests.swift b/FineTuneTests/SettingsManagerTests.swift index 6653789c..c8c19765 100644 --- a/FineTuneTests/SettingsManagerTests.swift +++ b/FineTuneTests/SettingsManagerTests.swift @@ -240,18 +240,6 @@ struct AppSettingsDefaultTests { #expect(decoded.loudnessEqualizationEnabled == true) } - @Test("Unified loudness toggle updates compensation and equalization together") - func unifiedLoudnessToggleSetsBothFlags() { - var settings = AppSettings() - - settings.setUnifiedLoudnessEnabled(true) - #expect(settings.loudnessCompensationEnabled == true) - #expect(settings.loudnessEqualizationEnabled == true) - - settings.setUnifiedLoudnessEnabled(false) - #expect(settings.loudnessCompensationEnabled == false) - #expect(settings.loudnessEqualizationEnabled == false) - } @Test("loudnessEqualizationEnabled persists via SettingsManager") @MainActor