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
3 changes: 2 additions & 1 deletion FineTune/Audio/AutoEQ/AutoEQProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ final class AutoEQProfileManager {
/// Normalized names for fuzzy search (parallel array with sortedEntries).
private var normalizedNames: [String] = []

init(loader: AutoEQProfileLoader = AutoEQProfileLoader(), fetcher: AutoEQFetcher? = nil) {
init(loader: AutoEQProfileLoader = AutoEQProfileLoader(), fetcher: AutoEQFetcher? = nil, loadAutomatically: Bool = true) {
self.loader = loader
self.fetcher = fetcher ?? AutoEQFetcher()
guard loadAutomatically else { return }

// Imported profiles are small — load synchronously
let imported = loader.loadImportedProfiles()
Expand Down
104 changes: 91 additions & 13 deletions FineTune/Audio/Engine/AudioEngine.swift

Large diffs are not rendered by default.

80 changes: 77 additions & 3 deletions FineTune/Audio/Monitors/DeviceVolumeMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,57 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
/// The current default input device UID (cached to avoid redundant Core Audio calls)
private(set) var defaultInputDeviceUID: String?

private struct ControlReadbackKey: Hashable {
let deviceID: AudioDeviceID
let input: Bool
}
private var controlReadability: [ControlReadbackKey: (volume: Bool, mute: Bool, backend: VolumeControlTier)] = [:]
private let hardwareVolumeAvailable: @MainActor (AudioDeviceID) -> Bool
private let hardwareReadability: @MainActor (AudioDeviceID, Bool) -> (volume: Bool, mute: Bool)

/// Cached values only; helper fallbacks (unity/false for unsupported HAL properties)
/// are not readback. In particular, a pending DDC probe is not 50% volume.
func controlReadback(for deviceID: AudioDeviceID, input: Bool) -> (volume: Float?, muted: Bool?, source: String?) {
guard let readable = controlReadability[ControlReadbackKey(deviceID: deviceID, input: input)] else {
return (nil, nil, nil)
}
if !input {
let backend = readable.backend
if backend == .software { return (volumes[deviceID], muteStates[deviceID], "software") }
#if !APP_STORE
if backend == .ddc {
let volume = ddcController?.getVolume(for: deviceID).map { Float($0) / 100 }
return (volume, muteStates[deviceID], volume == nil ? nil : "ddc")
}
#endif
}
let volume = readable.volume ? (input ? inputVolumes[deviceID] : volumes[deviceID]) : nil
let muted = readable.mute ? (input ? inputMuteStates[deviceID] : muteStates[deviceID]) : nil
return (volume, muted, volume == nil && muted == nil ? nil : "hardware")
}

func refreshControlReadability(for deviceID: AudioDeviceID, input: Bool, backend: VolumeControlTier? = nil) {
let selected = backend ?? (input ? .hardware : outputVolumeBackend(for: deviceID))
let readable = selected == .hardware ? hardwareReadability(deviceID, input) : (volume: false, mute: false)
controlReadability[ControlReadbackKey(deviceID: deviceID, input: input)] = (readable.volume, readable.mute, selected)
}

static func readControlHardwareMetadata(_ deviceID: AudioDeviceID, _ input: Bool) -> (volume: Bool, mute: Bool) {
let scope = input ? kAudioObjectPropertyScopeInput : kAudioObjectPropertyScopeOutput
func readable(_ selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) -> Bool {
var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element)
guard AudioObjectHasProperty(deviceID, &address) else { return false }
var value: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
return AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &value) == noErr
}
return (
volume: readable(kAudioHardwareServiceDeviceProperty_VirtualMainVolume) ||
readable(kAudioDevicePropertyVolumeScalar) || readable(kAudioDevicePropertyVolumeScalar, element: 1),
mute: readable(kAudioDevicePropertyMute)
)
}

/// Called when any input device's volume changes (deviceID, newVolume)
var onInputVolumeChanged: ((AudioDeviceID, Float) -> Void)?

Expand Down Expand Up @@ -144,15 +195,27 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
)

#if !APP_STORE
init(deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager, ddcController: DDCController? = nil) {
init(
deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager, ddcController: DDCController? = nil,
hardwareVolumeAvailable: @escaping @MainActor (AudioDeviceID) -> Bool = { $0.hasOutputVolumeControl() },
hardwareReadability: @escaping @MainActor (AudioDeviceID, Bool) -> (volume: Bool, mute: Bool) = DeviceVolumeMonitor.readControlHardwareMetadata
) {
self.deviceMonitor = deviceMonitor
self.settingsManager = settingsManager
self.ddcController = ddcController
self.hardwareVolumeAvailable = hardwareVolumeAvailable
self.hardwareReadability = hardwareReadability
}
#else
init(deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager) {
init(
deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager,
hardwareVolumeAvailable: @escaping @MainActor (AudioDeviceID) -> Bool = { $0.hasOutputVolumeControl() },
hardwareReadability: @escaping @MainActor (AudioDeviceID, Bool) -> (volume: Bool, mute: Bool) = DeviceVolumeMonitor.readControlHardwareMetadata
) {
self.deviceMonitor = deviceMonitor
self.settingsManager = settingsManager
self.hardwareVolumeAvailable = hardwareVolumeAvailable
self.hardwareReadability = hardwareReadability
}
#endif

Expand All @@ -173,7 +236,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
}

private func autoDetectBackend(for deviceID: AudioDeviceID) -> VolumeControlTier {
if deviceID.hasOutputVolumeControl() {
if hardwareVolumeAvailable(deviceID) {
return .hardware
}

Expand Down Expand Up @@ -332,6 +395,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
cancelAllVolumeLogTasks()

volumes.removeAll()
controlReadability.removeAll()
muteStates.removeAll()
systemDeviceID = .unknown
systemDeviceUID = nil
Expand Down Expand Up @@ -770,6 +834,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
for deviceID in staleVolumeIDs {
removeVolumeListener(for: deviceID)
volumes.removeValue(forKey: deviceID)
controlReadability.removeValue(forKey: ControlReadbackKey(deviceID: deviceID, input: false))
cancelBluetoothOutputConfirmation(for: deviceID)
}

Expand Down Expand Up @@ -879,6 +944,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
#endif

let newVolume = clampedVolume(deviceID.readOutputVolumeScalar())
refreshControlReadability(for: deviceID, input: false)

// Deduplicate: HAL often fires L/R channel notifications for the same volume value.
// Both values come from the same CoreAudio API (not computed), so == is safe for Float32.
Expand Down Expand Up @@ -938,6 +1004,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
guard deviceID.isValid else { return }
if outputVolumeBackend(for: deviceID) == .software { return }
let newMuteState = deviceID.readMuteState()
refreshControlReadability(for: deviceID, input: false)
muteStates[deviceID] = newMuteState
onMuteChanged?(deviceID, newMuteState)
logger.debug("Mute changed for device \(deviceID): \(newMuteState)")
Expand All @@ -961,6 +1028,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {

let backend = outputVolumeBackend(for: deviceID)

refreshControlReadability(for: deviceID, input: false, backend: backend)
if backend == .software {
let muted = settingsManager.getSoftwareDeviceMuteState(for: device.uid)
let defaultVolume: Float = muted ? 0 : 1.0
Expand Down Expand Up @@ -1106,6 +1174,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
for deviceID in staleVolumeIDs {
removeInputVolumeListener(for: deviceID)
inputVolumes.removeValue(forKey: deviceID)
controlReadability.removeValue(forKey: ControlReadbackKey(deviceID: deviceID, input: true))
cancelBluetoothInputConfirmation(for: deviceID)
}

Expand Down Expand Up @@ -1158,6 +1227,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
private func handleInputVolumeChanged(for deviceID: AudioDeviceID) {
guard deviceID.isValid else { return }
let newVolume = deviceID.readInputVolumeScalar()
refreshControlReadability(for: deviceID, input: true)

// Deduplicate: same logic as output volume
if let currentVolume = inputVolumes[deviceID], currentVolume == newVolume { return }
Expand Down Expand Up @@ -1206,6 +1276,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
private func handleInputMuteChanged(for deviceID: AudioDeviceID) {
guard deviceID.isValid else { return }
let newMuteState = deviceID.readInputMuteState()
refreshControlReadability(for: deviceID, input: true)
inputMuteStates[deviceID] = newMuteState
onInputMuteChanged?(deviceID, newMuteState)
logger.debug("Input mute changed for device \(deviceID): \(newMuteState)")
Expand All @@ -1218,6 +1289,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
guard device.id.isDeviceAlive() else { continue }

let volume = device.id.readInputVolumeScalar()
refreshControlReadability(for: device.id, input: true)
inputVolumes[device.id] = volume

let muted = device.id.readInputMuteState()
Expand Down Expand Up @@ -1263,6 +1335,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
try? await Task.sleep(for: .milliseconds(300))
guard let self, !Task.isCancelled, self.volumes.keys.contains(deviceID) else { return }
let confirmedVolume = self.clampedVolume(deviceID.readOutputVolumeScalar())
self.refreshControlReadability(for: deviceID, input: false)
let confirmedMute = deviceID.readMuteState()
self.volumes[deviceID] = confirmedVolume
self.muteStates[deviceID] = confirmedMute
Expand All @@ -1284,6 +1357,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding {
try? await Task.sleep(for: .milliseconds(300))
guard let self, !Task.isCancelled, self.inputVolumes.keys.contains(deviceID) else { return }
let confirmedVolume = deviceID.readInputVolumeScalar()
self.refreshControlReadability(for: deviceID, input: true)
let confirmedMute = deviceID.readInputMuteState()
self.inputVolumes[deviceID] = confirmedVolume
self.inputMuteStates[deviceID] = confirmedMute
Expand Down
7 changes: 6 additions & 1 deletion FineTune/Audio/Permission/AudioRecordingPermission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ final class AudioRecordingPermission {

var status: AudioCapturePermissionStatus = .unknown

init() {
init(status: AudioCapturePermissionStatus? = nil) {
// Tests can supply a status without querying TCC or registering app observers.
if let status {
self.status = status
return
}
refreshStatus()
registerForActivation()
}
Expand Down
130 changes: 130 additions & 0 deletions FineTune/ControlAPI/AudioEngine+Control.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Foundation

extension AudioEngine: ControlEngine {
func controlState() -> ControlJSON {
let appRows = apps.sorted {
if $0.persistenceIdentifier == $1.persistenceIdentifier { return $0.id < $1.id }
return $0.persistenceIdentifier < $1.persistenceIdentifier
}.map { app -> ControlJSON in
let requested: ControlJSON
if getDeviceSelectionMode(for: app) == .multi {
requested = .object([
"mode": .string("multi"),
"deviceUIDs": .array(getSelectedDeviceUIDs(for: app).sorted().map(ControlJSON.string))
])
} else if isFollowingDefault(for: app) || getDeviceUID(for: app) == nil {
requested = .object(["mode": .string("followDefault")])
} else {
requested = .object(["mode": .string("single"), "deviceUID": .optional(getDeviceUID(for: app))])
}
let applied = controlAppliedDeviceUIDs(for: app.id)
let routing = controlRouting[app.id]
return .object([
"appId": .string(app.persistenceIdentifier), "pids": .array([.number(Double(app.id))]),
"name": .string(app.name), "active": .bool(true),
"volume": .number(Double(getVolume(for: app))), "boost": .number(Double(getBoost(for: app).rawValue)),
"muted": .bool(getMute(for: app)),
"routing": .object([
"requested": requested,
"appliedDeviceUIDs": applied.map { .array($0.map(ControlJSON.string)) } ?? .null,
"status": .string(routing?.status ?? (applied == nil ? "unavailable" : "applied")),
"error": routing?.error?.json ?? .null
])
])
}
var devices: [ControlJSON] = []
for (direction, connected) in [("input", inputDevices), ("output", outputDevices)] {
for device in connected.sorted(by: { $0.uid < $1.uid }) {
let input = direction == "input"
let readback = (deviceVolumeMonitor as? DeviceVolumeMonitor)?.controlReadback(for: device.id, input: input)
devices.append(.object([
"uid": .string(device.uid), "name": .string(device.name), "direction": .string(direction),
"isDefault": .bool(device.uid == (input ? deviceVolumeMonitor.defaultInputDeviceUID : deviceVolumeMonitor.defaultDeviceUID)),
"volume": .optional(readback?.volume), "muted": .optional(readback?.muted),
"volumeSource": .optional(readback?.source),
"capabilities": .object([
"volumeReadable": .bool(readback?.volume != nil),
"muteReadable": .bool(readback?.muted != nil), "selectable": .bool(true)
])
]))
}
}
let permissionStatus: String
switch permission.status {
case .unknown: permissionStatus = "unknown"
case .authorized: permissionStatus = "authorized"
case .denied: permissionStatus = "denied"
}
#if ENABLE_TCC_SPI
let permissionSource = "preflight"
#else
let permissionSource = "assumed"
#endif
return .object([
"apps": .array(appRows), "devices": .array(devices),
"defaultOutput": .optional(deviceVolumeMonitor.defaultDeviceUID),
"defaultInput": .optional(deviceVolumeMonitor.defaultInputDeviceUID),
"inputPolicy": .object([
"lockEnabled": .bool(settingsManager.appSettings.lockInputDevice),
"preferredDeviceUID": .optional(settingsManager.preferredInputDeviceUID),
"lockedDeviceUID": .optional(settingsManager.lockedInputDeviceUID)
]),
"permission": .object(["audioCapture": .string(permissionStatus), "source": .string(permissionSource)])
])
}

func performControlCommand(_ command: ControlCommand) throws {
func resolve(_ appID: String, _ pid: Int32?) throws -> AudioApp {
let matches = apps.filter { $0.persistenceIdentifier == appID }
if let pid {
guard let app = matches.first(where: { $0.id == pid }) else {
throw ControlFailure(code: "staleApp", message: "The process no longer matches this app")
}
return app
}
guard !matches.isEmpty else { throw ControlFailure(code: "appNotFound", message: "App is not active") }
guard matches.count == 1 else { throw ControlFailure(code: "ambiguousApp", message: "Specify a current process ID") }
return matches[0]
}
func requireCapture() throws {
guard permission.status == .authorized else {
throw ControlFailure(code: "permissionDenied", message: "Audio capture permission is required")
}
}
switch command {
case .getState, .subscribe: break
case .volume(let appID, let pid, let volume):
let app = try resolve(appID, pid)
try requireCapture()
setVolume(for: app, to: volume)
case .mute(let appID, let pid, let muted):
let app = try resolve(appID, pid)
try requireCapture()
setMute(for: app, to: muted)
case .routing(let appID, let pid, let uid):
let app = try resolve(appID, pid)
try requireCapture()
guard getDeviceSelectionMode(for: app) != .multi else {
throw ControlFailure(code: "unsupportedRouting", message: "Multi-output routing is read-only")
}
if let uid, !outputDevices.contains(where: { $0.uid == uid }) {
throw ControlFailure(code: "deviceNotFound", message: "Output device is not connected")
}
setDevice(for: app, deviceUID: uid)
case .defaultOutput(let uid):
guard let device = outputDevices.first(where: { $0.uid == uid }) else {
throw ControlFailure(code: "deviceNotFound", message: "Output device is not connected")
}
guard setDefaultOutputDevice(device.id) else {
throw ControlFailure(code: "commandFailed", message: "Unable to select output device")
}
case .preferredInput(let uid):
guard let device = inputDevices.first(where: { $0.uid == uid }) else {
throw ControlFailure(code: "deviceNotFound", message: "Input device is not connected")
}
guard setLockedInputDevice(device) else {
throw ControlFailure(code: "commandFailed", message: "Unable to select input device")
}
}
}
}
Loading