Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions FineTune/Audio/EQ/BiquadMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
71 changes: 62 additions & 9 deletions FineTune/Audio/Engine/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,8 @@ final class AudioEngine {
}
tap.updateLoudnessCompensation(
volume: self.effectiveLoudnessVolume(for: tap),
enabled: loudnessEnabled
enabled: loudnessEnabled,
intensity: self.settingsManager.appSettings.loudnessCompensationIntensity
)
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
}
}
Expand Down Expand Up @@ -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
)
}
}

Expand All @@ -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 {
Expand All @@ -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
)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading