From a641e500fa84322a7188cfda2b79ca9f6cb6f54d Mon Sep 17 00:00:00 2001 From: Guillaume Cerquant Date: Thu, 2 Apr 2026 12:01:36 +0200 Subject: [PATCH] Set an audio device output level via URL scheme --- FineTune/Audio/Engine/AudioEngine.swift | 4 + FineTune/Utilities/URLHandler.swift | 107 +++++++++++++++++++----- guide/url-schemes.md | 14 +++- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index dfb0102b..83fa9850 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -392,6 +392,10 @@ final class AudioEngine { var apps: [AudioApp] { processMonitor.activeApps } + + var audioDevices: [AudioDevice] { + deviceMonitor.outputDevices + } // MARK: - Displayable Apps (Active + Pinned Inactive) diff --git a/FineTune/Utilities/URLHandler.swift b/FineTune/Utilities/URLHandler.swift index c4f885ad..19965d1e 100644 --- a/FineTune/Utilities/URLHandler.swift +++ b/FineTune/Utilities/URLHandler.swift @@ -7,6 +7,7 @@ import os @MainActor protocol URLHandlerEngine { var apps: [AudioApp] { get } + var audioDevices: [AudioDevice] { get } var settingsManager: SettingsManager { get } func setVolume(for app: AudioApp, to volume: Float) func getVolume(for app: AudioApp) -> Float @@ -21,6 +22,11 @@ protocol URLHandlerEngine { /// Handles URL scheme actions for FineTune (finetune://...) @MainActor final class URLHandler { + private enum VolumeTarget { + case app(String) + case device(String) + } + private let audioEngine: any URLHandlerEngine private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "FineTune", category: "URLHandler") @@ -63,32 +69,70 @@ final class URLHandler { // MARK: - Volume Actions - /// Set volumes for one or more apps - /// URL format: finetune://set-volumes?app=com.a&volume=100&app=com.b&volume=50 + /// Set volumes for one or more apps or audio device + /// URL format: app=identifier&volume=value OR device=name&volume=value + /// You may set multiple app or device at once. + /// + /// Examples: + /// + /// finetune://set-volumes?app=com.a&volume=100 + /// finetune://set-volumes?app=com.a&volume=100&app=com.b&volume=50 + /// + /// finetune://set-volumes?device=MacBook Pro Speakers&volume=75 + /// finetune://set-volumes?device=MacBook Pro Speakers&volume=75&device=AirPods ✌️✌️&volume=25 + /// + /// finetune://set-volumes?device=MacBook Pro Speakers&volume=75&app=com.a&volume=100 + /// + /// /// Volume is percentage: 0-100 (gain only, boost is per-app and set separately) private func handleSetVolumes(queryItems: [URLQueryItem]) { - var pairs: [(identifier: String, volume: Int)] = [] + var pairs: [(target: VolumeTarget, volume: Int)] = [] var currentApp: String? + var currentDevice: String? + + // Parse app/volume pairs in order for item in queryItems { switch item.name.lowercased() { case "app": currentApp = item.value + case "device": + currentDevice = item.value case "volume": - guard let app = currentApp else { - logger.warning("set-volumes: volume parameter without preceding app") + let app: String? = currentApp + let device: String? = currentDevice + + guard app != nil || device != nil else { + logger.warning("set-volumes: volume parameter without preceding app or device") continue } - guard let volumeStr = item.value, - let volume = Int(volumeStr), + + guard let volumeStr: String = item.value, + let volume: Int = Int(volumeStr), (0...100).contains(volume) else { - logger.warning("set-volumes: invalid volume '\(item.value ?? "nil")' for app \(app) (valid range: 0-100)") + let appOrDevice: String + if let app = app { + appOrDevice = "app \(app)" + } else { + appOrDevice = "device \(device ?? "")" + } + + logger.warning("set-volumes: invalid volume '\(item.value ?? "nil")' for \(appOrDevice) (valid range: 0-100)") currentApp = nil + currentDevice = nil continue } - pairs.append((app, volume)) - currentApp = nil + + if let app = app { + pairs.append((target: .app(app), volume: volume)) + currentApp = nil + currentDevice = nil + } else if let device = device { + pairs.append((target: .device(device), volume: volume)) + currentApp = nil + currentDevice = nil + } default: continue } @@ -104,17 +148,27 @@ final class URLHandler { return } - for (identifier, volumePercent) in pairs { - // Linear conversion: volume=100 → gain 1.0 - let gain = Float(volumePercent) / 100.0 + for (target, volumePercent) in pairs { + let gain: Float = Float(volumePercent) / 100.0 - if let app = findApp(by: identifier) { - audioEngine.setVolume(for: app, to: gain) - logger.info("Set volume for \(app.name) to \(volumePercent)%") - } else { - // App not active - persist for when it launches - audioEngine.setVolumeForInactive(identifier: identifier, to: gain) - logger.info("Set volume for inactive app \(identifier) to \(volumePercent)%") + switch target { + case .app(let identifier): + if let app = findApp(by: identifier) { + audioEngine.setVolume(for: app, to: gain) + logger.info("Set volume for \(app.name) to \(volumePercent)%") + } else { + audioEngine.setVolumeForInactive(identifier: identifier, to: gain) + logger.info("Set volume for inactive app \(identifier) to \(volumePercent)%") + } + + case .device(let identifier): + if let foundDevice = findDevice(by: identifier) { + // TODO: Implement device volume control when available in the engine. + _ = foundDevice.id.setOutputVolumeScalar(gain) + logger.info("Set volume for device \(foundDevice.id) to \(volumePercent)%") + } else { + logger.warning("Device not found: \(identifier)") + } } } } @@ -294,7 +348,19 @@ final class URLHandler { private func findApp(by identifier: String) -> AudioApp? { audioEngine.apps.first { $0.persistenceIdentifier == identifier } } + + private func findDevice(by name: String) -> AudioDevice? { + let audioDevice:AudioDevice? = audioEngine.audioDevices.first { $0.name == name } + + if audioDevice == nil { + logger.info("Searching for device with name: '\(name)'") + logger.info("all devices names: \(self.audioEngine.audioDevices.map { $0.name })") + } + + return audioDevice + } + /// Parse boolean from string (supports true/false, 1/0, yes/no) private func parseBool(_ value: String) -> Bool? { switch value.lowercased() { @@ -304,3 +370,4 @@ final class URLHandler { } } } + diff --git a/guide/url-schemes.md b/guide/url-schemes.md index 087d4e9f..408ffb5c 100644 --- a/guide/url-schemes.md +++ b/guide/url-schemes.md @@ -6,7 +6,8 @@ Control FineTune from Terminal, shell scripts, [Shortcuts](https://support.apple | Action | Format | Description | |--------|--------|-------------| -| Set volume | `finetune://set-volumes?app=BUNDLE_ID&volume=PERCENT` | Set volume (0–100, or up to 400 with boost) | +| Set volume of an application | `finetune://set-volumes?app=BUNDLE_ID&volume=PERCENT` | Set volume (0–100, or up to 400 with boost) | +| Set volume of audio device | `finetune://set-volumes?device=NAME&volume=PERCENT` | Set volume (0–100) | | Step volume | `finetune://step-volume?app=BUNDLE_ID&direction=up` | Nudge volume up or down by ~5% | | Set mute | `finetune://set-mute?app=BUNDLE_ID&muted=true` | Mute or unmute an app | | Toggle mute | `finetune://toggle-mute?app=BUNDLE_ID` | Toggle mute state | @@ -28,6 +29,17 @@ open "finetune://set-mute?app=com.spotify.client&muted=true&app=com.apple.Music& # Step Discord volume down open "finetune://step-volume?app=com.hnc.Discord&direction=down" +# Set volume of Macbook Pro Speakers to 75% +open "finetune://set-volumes?device=MacBook Pro Speakers&volume=75" + +# Set volume of AirPods to 30% +open "finetune://set-volumes?device=AirPods ✌️✌️&volume=30" + + +# Set Macbook Pro Speakers and AirPods to 10% +open "finetune://set-volumes?device=MacBook Pro Speakers&volume=10&device=AirPods ✌️✌️&volume=30" + + # Route an app to a specific device open "finetune://set-device?app=com.spotify.client&device=YOUR_DEVICE_UID"