Skip to content
Open
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
5 changes: 4 additions & 1 deletion FineTune/Audio/EQ/EQProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ final class EQProcessor: BiquadProcessor, @unchecked Sendable {
// MARK: - Settings Update

/// Update EQ settings (call from main thread).
/// Disables biquad processing entirely when all bands are at 0 dB
/// to avoid wasting CPU on identity transforms (#245).
func updateSettings(_ settings: EQSettings) {
setEnabled(settings.isEnabled)
let effective = settings.isEnabled && !settings.isFlat
setEnabled(effective)
_currentSettings = settings

let coefficients = BiquadMath.coefficientsForAllBands(
Expand Down
60 changes: 39 additions & 21 deletions FineTune/Audio/Engine/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,12 @@ final class AudioEngine {
}
}

/// Active audio apps that are actively streaming (isRunning=true).
/// Non-streaming apps are excluded to avoid creating unnecessary taps
/// that waste CPU on silent identity-processed audio (#245).
var apps: [AudioApp] {
processMonitor.activeApps
.filter { $0.processObjectIDs.contains { $0.readProcessIsRunning() } }
}

// MARK: - Displayable Apps (Active + Pinned Inactive)
Expand Down Expand Up @@ -940,11 +944,13 @@ final class AudioEngine {
guard let targetUID = appDeviceRouting[app.id] else { return }
let preferredTapSourceUID = preferredTapSourceDeviceUID(forOutputUIDs: [targetUID], isFollowsDefault: followsDefault.contains(app.id))
if let tap = taps[app.id] {
Task {
Task { [weak self] in
guard let self else { return }
do {
try await tap.switchDevice(to: targetUID, preferredTapSourceDeviceUID: preferredTapSourceUID)
let profile = self.autoEQProfileForActivation(deviceUID: targetUID)
try await tap.switchDevice(to: targetUID, preferredTapSourceDeviceUID: preferredTapSourceUID, autoEQProfile: profile)
self.applyTapOutputState(to: tap, for: app.id, deviceUIDs: [targetUID])
self.applyAutoEQToTap(tap)
if profile == nil { self.applyAutoEQToTap(tap) }
self.logger.debug("Switched \(app.name) to device: \(targetUID)")
} catch {
self.logger.error("Failed to switch device for \(app.name): \(error.localizedDescription)")
Expand Down Expand Up @@ -1035,11 +1041,12 @@ final class AudioEngine {
if tap.currentDeviceUIDs != deviceUIDs {
do {
let preferredTapSourceUID = preferredTapSourceDeviceUID(forOutputUIDs: deviceUIDs, isFollowsDefault: followsDefault.contains(app.id))
try await tap.updateDevices(to: deviceUIDs, preferredTapSourceDeviceUID: preferredTapSourceUID)
let profile = autoEQProfileForActivation(deviceUID: deviceUIDs.first ?? "")
try await tap.updateDevices(to: deviceUIDs, preferredTapSourceDeviceUID: preferredTapSourceUID, autoEQProfile: profile)
applyTapOutputState(to: tap, for: app.id, deviceUIDs: deviceUIDs)
if profile == nil { applyAutoEQToTap(tap) }
logger.debug("Updated \(app.name) to \(deviceUIDs.count) device(s)")
} catch {
logger.error("Failed to update devices for \(app.name): \(error.localizedDescription)")
}
}
} else {
Expand Down Expand Up @@ -1176,11 +1183,13 @@ final class AudioEngine {
// after the default changed while it was absent), switch it.
if let existingTap = taps[app.id], existingTap.currentDeviceUIDs != [deviceUID] {
let preferredSource = preferredTapSourceDeviceUID(forOutputUIDs: [deviceUID], isFollowsDefault: followsDefault.contains(app.id))
Task {
Task { [weak self] in
guard let self else { return }
do {
try await existingTap.switchDevice(to: deviceUID, preferredTapSourceDeviceUID: preferredSource)
let profile = self.autoEQProfileForActivation(deviceUID: deviceUID)
try await existingTap.switchDevice(to: deviceUID, preferredTapSourceDeviceUID: preferredSource, autoEQProfile: profile)
self.applyTapOutputState(to: existingTap, for: app.id, deviceUIDs: [deviceUID])
self.applyAutoEQToTap(existingTap)
if profile == nil { self.applyAutoEQToTap(existingTap) }
} catch {
self.logger.error("Failed to re-route \(app.name) to \(deviceUID): \(error.localizedDescription)")
}
Expand Down Expand Up @@ -1319,13 +1328,15 @@ final class AudioEngine {
}
guard !tapsToSwitch.isEmpty else { return }

Task {
Task { [weak self] in
guard let self else { return }
let profile = self.autoEQProfileForActivation(deviceUID: targetUID)
for (app, tap) in tapsToSwitch {
do {
let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: [targetUID], isFollowsDefault: true)
try await tap.switchDevice(to: targetUID, preferredTapSourceDeviceUID: preferredTapSourceUID)
try await tap.switchDevice(to: targetUID, preferredTapSourceDeviceUID: preferredTapSourceUID, autoEQProfile: profile)
self.applyTapOutputState(to: tap, for: app.id, deviceUIDs: [targetUID])
self.applyAutoEQToTap(tap)
if profile == nil { self.applyAutoEQToTap(tap) }
} catch {
self.logger.error("Failed to switch \(app.name) to \(targetUID): \(error.localizedDescription)")
}
Expand Down Expand Up @@ -1398,14 +1409,16 @@ final class AudioEngine {

// Execute device switches
if !singleModeTapsToSwitch.isEmpty || !multiModeTapsToUpdate.isEmpty {
Task {
Task { [weak self] in
guard let self else { return }
// Handle single-mode switches — source device is dead, skip crossfade
for (tap, fallbackUID) in singleModeTapsToSwitch {
do {
let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: [fallbackUID], isFollowsDefault: true)
try await tap.switchDevice(to: fallbackUID, preferredTapSourceDeviceUID: preferredTapSourceUID, sourceDeviceDead: true)
let profile = self.autoEQProfileForActivation(deviceUID: fallbackUID)
try await tap.switchDevice(to: fallbackUID, preferredTapSourceDeviceUID: preferredTapSourceUID, sourceDeviceDead: true, autoEQProfile: profile)
self.applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: [fallbackUID])
self.applyAutoEQToTap(tap)
if profile == nil { self.applyAutoEQToTap(tap) }
} catch {
self.logger.error("Failed to switch \(tap.app.name) to fallback: \(error.localizedDescription)")
}
Expand All @@ -1416,11 +1429,12 @@ final class AudioEngine {
for (tap, remainingUIDs) in multiModeTapsToUpdate {
do {
let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: remainingUIDs, isFollowsDefault: self.followsDefault.contains(tap.app.id))
try await tap.updateDevices(to: remainingUIDs, preferredTapSourceDeviceUID: preferredTapSourceUID, sourceDeviceDead: true)
let profile = self.autoEQProfileForActivation(deviceUID: remainingUIDs.first ?? "")
try await tap.updateDevices(to: remainingUIDs, preferredTapSourceDeviceUID: preferredTapSourceUID, sourceDeviceDead: true, autoEQProfile: profile)
self.applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: remainingUIDs)
if profile == nil { self.applyAutoEQToTap(tap) }
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)")
}
}
}
Expand Down Expand Up @@ -1471,13 +1485,15 @@ final class AudioEngine {
}

if !tapsToSwitch.isEmpty {
Task {
Task { [weak self] in
guard let self else { return }
let profile = self.autoEQProfileForActivation(deviceUID: deviceUID)
for tap in tapsToSwitch {
do {
let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: [deviceUID], isFollowsDefault: false)
try await tap.switchDevice(to: deviceUID, preferredTapSourceDeviceUID: preferredTapSourceUID)
try await tap.switchDevice(to: deviceUID, preferredTapSourceDeviceUID: preferredTapSourceUID, autoEQProfile: profile)
self.applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: [deviceUID])
self.applyAutoEQToTap(tap)
if profile == nil { self.applyAutoEQToTap(tap) }
} catch {
self.logger.error("Failed to switch \(tap.app.name) back to \(deviceName): \(error.localizedDescription)")
}
Expand Down Expand Up @@ -1905,8 +1921,10 @@ final class AudioEngine {
guard tap.isHealthCheckEligible(minActiveSeconds: 5.0) else { continue }

// Only health-check apps that are actively streaming (isRunning=true).
// Paused apps have no callbacks, which is normal — not a health signal.
let isActivelyStreaming = self.processMonitor.activeApps.contains { $0.id == pid }
// Paused apps can still be present in activeApps for proactive tap setup,
// but no callbacks while paused is normal — not a health signal.
let isActivelyStreaming = self.apps.first { $0.id == pid }?
.processObjectIDs.contains { $0.readProcessIsRunning() } ?? false
guard isActivelyStreaming else {
consecutiveMisses[pid] = 0
continue
Expand Down
30 changes: 17 additions & 13 deletions FineTune/Audio/Engine/ProcessTapController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -704,15 +704,13 @@ final class ProcessTapController: ProcessTapControlling {

/// Switch to a single device (convenience for backward compatibility).
/// - Parameter sourceDeviceDead: If true, skips crossfade (source has no audio to blend from).
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String? = nil, sourceDeviceDead: Bool = false) async throws {
try await updateDevices(to: [newDeviceUID], preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: sourceDeviceDead)
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String? = nil, sourceDeviceDead: Bool = false, autoEQProfile: AutoEQProfile? = nil) async throws {
try await updateDevices(to: [newDeviceUID], preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: sourceDeviceDead, autoEQProfile: autoEQProfile)
}

/// Updates output devices using crossfade for seamless transition.
/// Creates a second tap+aggregate for the new device set, crossfades, then destroys the old one.
/// - Parameter sourceDeviceDead: If true, skips crossfade and uses destructive switch
/// (the source device is disconnected, so there's no audio to blend from).
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String? = nil, sourceDeviceDead: Bool = false) async throws {
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String? = nil, sourceDeviceDead: Bool = false, autoEQProfile: AutoEQProfile? = nil) async throws {
precondition(!newDeviceUIDs.isEmpty, "Must have at least one target device")
self.preferredTapSourceDeviceUID = preferredTapSourceDeviceUID

Expand Down Expand Up @@ -740,7 +738,7 @@ final class ProcessTapController: ProcessTapControlling {
} else {
crossfadeTask?.cancel()
crossfadeTask = Task {
try await performCrossfadeSwitch(to: primaryDeviceUID, allDeviceUIDs: newDeviceUIDs)
try await performCrossfadeSwitch(to: primaryDeviceUID, allDeviceUIDs: newDeviceUIDs, autoEQProfile: autoEQProfile)
}
do {
try await crossfadeTask!.value
Expand All @@ -752,7 +750,7 @@ final class ProcessTapController: ProcessTapControlling {
guard primaryResources.tapDescription != nil else {
throw CrossfadeError.noTapDescription
}
try await performDestructiveDeviceSwitch(to: primaryDeviceUID, allDeviceUIDs: newDeviceUIDs)
try await performDestructiveDeviceSwitch(to: primaryDeviceUID, allDeviceUIDs: newDeviceUIDs, autoEQProfile: autoEQProfile)
}
crossfadeTask = nil
}
Expand Down Expand Up @@ -873,9 +871,8 @@ final class ProcessTapController: ProcessTapControlling {

// MARK: - Crossfade Operations

private func performCrossfadeSwitch(to primaryDeviceUID: String, allDeviceUIDs: [String]? = nil) async throws {
private func performCrossfadeSwitch(to primaryDeviceUID: String, allDeviceUIDs: [String]? = nil, autoEQProfile: AutoEQProfile? = nil) async throws {
let deviceUIDs = allDeviceUIDs ?? [primaryDeviceUID]

// Re-entrant guard (ORCH-001): if already switching, tear down in-progress secondary
if isSwitching {
logger.warning("[CROSSFADE] Re-entrant switch detected — tearing down in-progress secondary")
Expand All @@ -901,7 +898,7 @@ final class ProcessTapController: ProcessTapControlling {
crossfadeState.beginWarmup()

logger.info("[CROSSFADE] Step 3: Creating secondary tap for \(deviceUIDs.count) device(s)")
try createSecondaryTap(for: deviceUIDs)
try createSecondaryTap(for: deviceUIDs, autoEQProfile: autoEQProfile)

// LIFE-004/005: Ensure secondary tap is cleaned up if crossfade fails or is cancelled
var crossfadeCompleted = false
Expand Down Expand Up @@ -961,7 +958,7 @@ final class ProcessTapController: ProcessTapControlling {
logger.info("[CROSSFADE] Complete")
}

private func createSecondaryTap(for outputUIDs: [String]) throws {
private func createSecondaryTap(for outputUIDs: [String], autoEQProfile: AutoEQProfile? = nil) throws {
precondition(!outputUIDs.isEmpty, "Must have at least one output device")

let (tapDesc, tapID) = try createProcessTap(preferredDeviceUID: preferredTapSourceDeviceUID)
Expand Down Expand Up @@ -1021,7 +1018,9 @@ final class ProcessTapController: ProcessTapControlling {
secondaryEQProcessor = secEQ

let secAutoEQ = AutoEQProcessor(sampleRate: sampleRate)
if let profile = autoEQProcessor?.currentProfile {
if let profile = autoEQProfile {
secAutoEQ.updateProfile(profile)
} else if let profile = autoEQProcessor?.currentProfile {
secAutoEQ.updateProfile(profile)
}
secondaryAutoEQProcessor = secAutoEQ
Expand Down Expand Up @@ -1135,7 +1134,7 @@ final class ProcessTapController: ProcessTapControlling {
/// Performs a destructive (non-crossfade) device switch with silence padding.
/// - Parameter sourceAlreadySilent: If true (e.g. source device disconnected), skips the
/// pre-switch silence wait and uses a shorter post-switch settle time.
private func performDestructiveDeviceSwitch(to primaryDeviceUID: String, allDeviceUIDs: [String]? = nil, sourceAlreadySilent: Bool = false) async throws {
private func performDestructiveDeviceSwitch(to primaryDeviceUID: String, allDeviceUIDs: [String]? = nil, sourceAlreadySilent: Bool = false, autoEQProfile: AutoEQProfile? = nil) async throws {
let deviceUIDs = allDeviceUIDs ?? [primaryDeviceUID]
let originalVolume = _volume

Expand All @@ -1158,6 +1157,11 @@ final class ProcessTapController: ProcessTapControlling {
// Post-switch settle: shorter when source was already silent (no old audio to drain)
let settleMs = sourceAlreadySilent ? 80 : 150
try await Task.sleep(for: .milliseconds(settleMs))
// Apply destination AutoEQ profile before unmuting output.
// _forceSilence is true so no audio leaks during profile application.
if let profile = autoEQProfile {
autoEQProcessor?.updateProfile(profile)
}

_forceSilence = false

Expand Down
16 changes: 8 additions & 8 deletions FineTune/Audio/Engine/ProcessTapControlling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ protocol ProcessTapControlling: AnyObject, Sendable {
func setAutoEQPreampEnabled(_ enabled: Bool)
func updateLoudnessCompensation(volume: Float, enabled: Bool)
func updateLoudnessEqualization(_ settings: LoudnessEqualizerSettings)
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool, autoEQProfile: AutoEQProfile?) async throws
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool, autoEQProfile: AutoEQProfile?) async throws
func hasRecentAudioCallback(within seconds: Double) -> Bool
func isHealthCheckEligible(minActiveSeconds: Double) -> Bool

Expand All @@ -42,14 +42,14 @@ extension ProcessTapControlling {
try activate(initial: TapInitialState())
}

/// Convenience: defaults sourceDeviceDead to false.
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?) async throws {
try await switchDevice(to: newDeviceUID, preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: false)
/// Convenience: defaults sourceDeviceDead to false, autoEQProfile to nil.
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?, autoEQProfile: AutoEQProfile?) async throws {
try await switchDevice(to: newDeviceUID, preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: false, autoEQProfile: autoEQProfile)
}

/// Convenience: defaults sourceDeviceDead to false.
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?) async throws {
try await updateDevices(to: newDeviceUIDs, preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: false)
/// Convenience: defaults sourceDeviceDead to false, autoEQProfile to nil.
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?, autoEQProfile: AutoEQProfile?) async throws {
try await updateDevices(to: newDeviceUIDs, preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: false, autoEQProfile: autoEQProfile)
}

func invalidateAsync() async {
Expand Down
Loading