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
39 changes: 39 additions & 0 deletions FineTune/Audio/Engine/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ final class AudioEngine {
let autoEQProfileManager: AutoEQProfileManager
let permission: AudioRecordingPermission
let appListCoordinator: AppListCoordinator
/// Holds a ducking exemption on every output device so call apps can't turn FineTune's output down.
let callDuckingGuard: CallDuckingGuard

#if !APP_STORE
let ddcController: DDCController
Expand Down Expand Up @@ -180,6 +182,7 @@ final class AudioEngine {
deviceVolumeMonitor: (any DeviceVolumeProviding)? = nil,
tapFactory: (@MainActor (AudioApp, [String], String?) throws -> any ProcessTapControlling)? = nil,
isAlive: ((AudioDeviceID) -> Bool)? = nil,
callDuckingGuard: CallDuckingGuard? = nil,
startMonitorsAutomatically: Bool = true
) {
self.permission = permission
Expand All @@ -188,6 +191,8 @@ final class AudioEngine {
self.appListCoordinator = AppListCoordinator(settingsManager: manager)
self.autoEQProfileManager = autoEQProfileManager
self.volumeState = VolumeState(settingsManager: manager)
self.callDuckingGuard = callDuckingGuard
?? CallDuckingGuard(isEnabled: manager.appSettings.keepVolumeDuringCalls)
self.isAliveCheck = isAlive ?? { $0.isDeviceAlive() }

// If a custom deviceProvider is given, use it directly.
Expand Down Expand Up @@ -279,6 +284,9 @@ final class AudioEngine {
// Start device volume monitor AFTER deviceMonitor.start() populates devices
self.deviceVolumeMonitor.start()

// Devices are enumerated now — exempt FineTune's output from call ducking on each.
self.refreshCallDuckingProtection()

self.applyPersistedSettings()
self.registerNewDevicesInPriority()
// Seed the confirmed default from whatever macOS has at startup
Expand Down Expand Up @@ -570,6 +578,7 @@ final class AudioEngine {
processMonitor.start()
}
deviceMonitor.start()
refreshCallDuckingProtection()
applyPersistedSettings()
if permission.status == .authorized {
startHealthMonitor()
Expand All @@ -591,6 +600,7 @@ final class AudioEngine {
tap.invalidate()
}
taps.removeAll()
callDuckingGuard.releaseAll()
logger.info("AudioEngine stopped")
}

Expand All @@ -603,6 +613,23 @@ final class AudioEngine {
logger.info("AudioEngine shutdown complete")
}

// MARK: - Call Ducking Protection

/// Settings → Audio → Keep Volume During Calls. Turning it off takes effect at once:
/// a running call ducks FineTune's output again. See `CallDuckingGuard`.
func setCallDuckingProtectionEnabled(_ enabled: Bool) {
callDuckingGuard.setEnabled(enabled)
refreshCallDuckingProtection()
}

/// Holds the exemption on every connected output device (a no-op per device once held)
/// and forgets devices that went away. Cheap enough to run from the health monitor.
private func refreshCallDuckingProtection() {
let connectedIDs = deviceMonitor.outputDevices.map(\.id)
callDuckingGuard.retain(only: Set(connectedIDs))
callDuckingGuard.hold(connectedIDs)
}

// MARK: - Settings Reset

/// Resets all persisted settings and synchronizes in-memory engine state.
Expand Down Expand Up @@ -633,6 +660,9 @@ final class AudioEngine {
// 6. Re-apply from clean settings (re-establishes routing to system default)
applyPersistedSettings()

// 7. Settings are back to defaults, so call-ducking protection is on again
setCallDuckingProtectionEnabled(settingsManager.appSettings.keepVolumeDuringCalls)

logger.info("Settings reset: engine state synchronized")
}

Expand Down Expand Up @@ -1445,6 +1475,11 @@ final class AudioEngine {
// Register newly connected device in priority list
settingsManager.ensureDeviceInPriority(deviceUID)

// A (re)connected device is a new HAL client — exempt it from call ducking too
if let device = deviceMonitor.device(for: deviceUID) {
callDuckingGuard.hold(device.id)
}

var affectedApps: [AudioApp] = []
var tapsToSwitch: [any ProcessTapControlling] = []

Expand Down Expand Up @@ -1888,6 +1923,10 @@ final class AudioEngine {
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled, let self else { return }

// Safety net: hold the call-ducking exemption on any output device that showed up
// without a connect event (e.g. after coreaudiod restarted). No-op once held.
self.refreshCallDuckingProtection()

// Skip entirely when no taps exist — avoids unnecessary work at idle (#176)
guard !self.taps.isEmpty else { continue }

Expand Down
129 changes: 129 additions & 0 deletions FineTune/Audio/Engine/CallDuckingGuard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// FineTune/Audio/Engine/CallDuckingGuard.swift
import CoreAudio
import Darwin
import os

/// Keeps FineTune's output at full volume while a call app ducks "other audio".
///
/// When a voice-processing client (FaceTime's `avconferenced`, WhatsApp, …) starts a
/// call, coreaudiod ducks every other HAL client on the output device to a combined
/// volume scalar of 0.178 (-15 dB). FineTune renders every tapped app through its own
/// HAL client, so the whole mixer drops to ~18% for the length of the call — and so does
/// the call itself whenever FineTune taps it (#67, #113, #156, #241, #262, #360, #426).
///
/// coreaudiod never ducks a client that holds a duck request of its own: its log calls
/// such clients "duckers" and gives them a scalar of 1.0 no matter what anybody else
/// requests. This guard holds a request of 0.999 (-0.01 dB, inaudible) on every output
/// device, which exempts FineTune's replay while leaving the call's ducking of audio that
/// bypasses FineTune (ignored apps, system sounds) exactly as macOS intends.
///
/// The request belongs to the process ↔ device pair, not to an IO proc: it can be made
/// before any aggregate exists and it survives tearing the private aggregates down and
/// recreating them (checked on macOS 26.6 against `_UpdateClientVolumeScalarDucking` in
/// coreaudiod's log). A level of exactly 1.0 withdraws the request again.
///
/// `AudioDeviceDuck` is exported by CoreAudio but absent from its public headers (WebKit
/// and Chromium call it the same way). It is resolved with `dlsym`, so a macOS release
/// that drops it degrades to "calls duck FineTune as before" instead of a failed launch.
@MainActor
final class CallDuckingGuard {
/// `(deviceID, level) -> OSStatus`. Tests inject a recorder; production wraps `AudioDeviceDuck`.
typealias DuckFunction = @Sendable (AudioDeviceID, Float32) -> OSStatus

/// Below 1.0 so coreaudiod keeps the request on file, close enough to unity that the
/// -0.01 dB it imposes on clients outside FineTune is inaudible.
static let holdLevel: Float32 = 0.999
/// Exactly 1.0 makes coreaudiod drop this client's request ("unducking" in its log).
static let releaseLevel: Float32 = 1.0

private let logger = Logger(subsystem: "com.finetuneapp.FineTune", category: "CallDuckingGuard")
private let duck: DuckFunction?

/// Devices that currently carry FineTune's request.
private(set) var heldDeviceIDs: Set<AudioDeviceID> = []
/// Devices whose last request failed. Logged once; retried on the next `hold`.
private var failedDeviceIDs: Set<AudioDeviceID> = []

private(set) var isEnabled: Bool

init(isEnabled: Bool, duckFunction: DuckFunction? = CallDuckingGuard.systemDuckFunction) {
self.isEnabled = isEnabled
self.duck = duckFunction
}

/// False when the private symbol could not be resolved on this macOS.
var isAvailable: Bool { duck != nil }

/// Turning the guard off withdraws every request at once, so a running call's ducking
/// applies to FineTune again. Turning it on holds nothing by itself: the caller passes
/// the current output devices to `hold` next.
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
if !enabled {
releaseAll()
}
}

/// Holds a request on `deviceID`. No-op when disabled, unavailable, or already held.
func hold(_ deviceID: AudioDeviceID) {
guard isEnabled, let duck, deviceID.isValid, !heldDeviceIDs.contains(deviceID) else { return }

let status = duck(deviceID, Self.holdLevel)
if status == noErr {
heldDeviceIDs.insert(deviceID)
failedDeviceIDs.remove(deviceID)
logger.info("Holding ducking exemption on output device \(deviceID)")
} else if failedDeviceIDs.insert(deviceID).inserted {
logger.warning("AudioDeviceDuck(\(deviceID), \(Self.holdLevel)) failed: OSStatus \(status)")
}
}

func hold(_ deviceIDs: some Sequence<AudioDeviceID>) {
for deviceID in deviceIDs {
hold(deviceID)
}
}

/// Withdraws the request on `deviceID`. No-op when nothing is held there.
func release(_ deviceID: AudioDeviceID) {
guard heldDeviceIDs.remove(deviceID) != nil, let duck else { return }

let status = duck(deviceID, Self.releaseLevel)
if status == noErr {
logger.info("Released ducking exemption on output device \(deviceID)")
} else {
// The device may already be gone; coreaudiod drops the request with it.
logger.debug("AudioDeviceDuck(\(deviceID), \(Self.releaseLevel)) failed: OSStatus \(status)")
}
}

func releaseAll() {
for deviceID in heldDeviceIDs {
release(deviceID)
}
}

/// Forgets devices that are no longer connected. coreaudiod discards a request together
/// with its device, and a reconnected device comes back under a new ID anyway.
func retain(only connectedDeviceIDs: Set<AudioDeviceID>) {
heldDeviceIDs.formIntersection(connectedDeviceIDs)
failedDeviceIDs.formIntersection(connectedDeviceIDs)
}

// MARK: - Private symbol

private typealias AudioDeviceDuckFunction = @convention(c) (
AudioDeviceID, Float32, UnsafePointer<AudioTimeStamp>?, Float32
) -> OSStatus

/// Resolved once per process. RTLD_DEFAULT (-2) searches every loaded image, CoreAudio included.
/// The nil start time and zero ramp apply the level immediately, as WebKit does.
nonisolated static let systemDuckFunction: DuckFunction? = {
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AudioDeviceDuck") else {
return nil
}
let audioDeviceDuck = unsafeBitCast(symbol, to: AudioDeviceDuckFunction.self)
return { deviceID, level in audioDeviceDuck(deviceID, level, nil, 0) }
}()
}
4 changes: 4 additions & 0 deletions FineTune/Settings/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ nonisolated struct AppSettings: Codable, Equatable {
var loudnessCompensationEnabled: Bool = false // ISO 226:2023 equal-loudness contour compensation
var loudnessEqualizationEnabled: Bool = false // Real-time loudness equalization

// Calls
var keepVolumeDuringCalls: Bool = true // Hold a ducking exemption so call apps can't turn FineTune's output down

// Media Keys & HUD
var hudStyle: HUDStyle = .tahoe // Visual style of the volume HUD
var mediaKeyControlEnabled: Bool = true // Intercept F10/F11/F12 to drive the default output device
Expand Down Expand Up @@ -72,6 +75,7 @@ nonisolated struct AppSettings: Codable, Equatable {
showDeviceDisconnectAlerts = try c.decodeIfPresent(Bool.self, forKey: .showDeviceDisconnectAlerts) ?? true
loudnessCompensationEnabled = try c.decodeIfPresent(Bool.self, forKey: .loudnessCompensationEnabled) ?? false
loudnessEqualizationEnabled = try c.decodeIfPresent(Bool.self, forKey: .loudnessEqualizationEnabled) ?? false
keepVolumeDuringCalls = try c.decodeIfPresent(Bool.self, forKey: .keepVolumeDuringCalls) ?? true
hudStyle = try c.decodeIfPresent(HUDStyle.self, forKey: .hudStyle) ?? .tahoe
mediaKeyControlEnabled = try c.decodeIfPresent(Bool.self, forKey: .mediaKeyControlEnabled) ?? true
volumeHotkeyStep = try c.decodeIfPresent(VolumeHotkeyStep.self, forKey: .volumeHotkeyStep) ?? .normal
Expand Down
13 changes: 13 additions & 0 deletions FineTune/Views/Settings/Tabs/AudioTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ struct AudioTab: View {
.onChange(of: settings.appSettings.loudnessEqualizationEnabled) { _, newValue in
audioEngine.setLoudnessEqualizationEnabled(newValue)
}
.onChange(of: settings.appSettings.keepVolumeDuringCalls) { _, newValue in
audioEngine.setCallDuckingProtectionEnabled(newValue)
}
}

// MARK: - Volume
Expand All @@ -72,6 +75,16 @@ struct AudioTab: View {
.controlSize(.small)
.labelsHidden()
}
SettingsRowDivider()
SettingsRow(
"Keep Volume During Calls",
description: "Stop FaceTime and other calls from turning apps down"
) {
Toggle("", isOn: $settings.appSettings.keepVolumeDuringCalls)
.toggleStyle(.switch)
.controlSize(.small)
.labelsHidden()
}
}
}

Expand Down
33 changes: 33 additions & 0 deletions FineTuneTests/AppSettingsKeepVolumeDuringCallsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// FineTuneTests/AppSettingsKeepVolumeDuringCallsTests.swift
// Tests for the keepVolumeDuringCalls setting's default and persistence.

import Testing
import Foundation
@testable import FineTune

@Suite("AppSettings — keepVolumeDuringCalls")
struct AppSettingsKeepVolumeDuringCallsTests {

@Test("Defaults to on for fresh settings")
func defaultIsOn() {
#expect(AppSettings().keepVolumeDuringCalls == true)
}

@Test("Settings saved by an older version decode with the setting on")
func missingKeyDecodesAsOn() throws {
let legacy = #"{"launchAtLogin":false,"defaultNewAppVolume":0.5}"#
let decoded = try JSONDecoder().decode(AppSettings.self, from: Data(legacy.utf8))
#expect(decoded.keepVolumeDuringCalls == true)
#expect(decoded.defaultNewAppVolume == 0.5)
}

@Test("An explicit off survives a JSON round-trip")
func offRoundTrips() throws {
var settings = AppSettings()
settings.keepVolumeDuringCalls = false
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(AppSettings.self, from: data)
#expect(decoded.keepVolumeDuringCalls == false)
#expect(decoded == settings)
}
}
Loading