From 64aab92afadf1d150e1653ecbf9130d0ee4db236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BC=8D=E6=8B=BE=E6=9F=92?= Date: Sat, 12 Sep 2026 00:17:36 +0800 Subject: [PATCH] fix(audio): correct URL volume, unmute HUD, and DDC restore Map URL percentages through the existing slider curve, read restored volume after unmute, and respect non-DDC backend overrides during probe restoration. Refs ronitsingh10/FineTune#434, ronitsingh10/FineTune#376, ronitsingh10/FineTune#374. --- FineTune/Audio/DDC/DDCController.swift | 3 ++ FineTune/Audio/Keys/MediaKeyMonitor.swift | 9 ++-- FineTune/Utilities/URLHandler.swift | 7 ++- .../MediaKeyMonitorHandlerTests.swift | 40 ++++++++++++++- FineTuneTests/URLHandlerTests.swift | 49 +++++++++++++++++++ guide/url-schemes.md | 6 +-- 6 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 FineTuneTests/URLHandlerTests.swift diff --git a/FineTune/Audio/DDC/DDCController.swift b/FineTune/Audio/DDC/DDCController.swift index 3abada56..26ca2bc5 100644 --- a/FineTune/Audio/DDC/DDCController.swift +++ b/FineTune/Audio/DDC/DDCController.swift @@ -286,6 +286,9 @@ final class DDCController { for (deviceID, uid) in matchedUIDsSnapshot { if let savedVolume = self.settingsManager.getDDCVolume(for: uid) { self.cachedVolumes[deviceID] = savedVolume + // Keep DDC discovery, but honor an explicitly selected non-DDC backend. + let override = self.settingsManager.getDeviceVolumeTierOverride(for: uid) + guard override == nil || override == .ddc else { continue } // Restore saved volume to the display let service = matchedSnapshot[deviceID] self.ddcQueue.async { diff --git a/FineTune/Audio/Keys/MediaKeyMonitor.swift b/FineTune/Audio/Keys/MediaKeyMonitor.swift index e305dff9..20f20123 100644 --- a/FineTune/Audio/Keys/MediaKeyMonitor.swift +++ b/FineTune/Audio/Keys/MediaKeyMonitor.swift @@ -258,7 +258,7 @@ final class MediaKeyMonitor { deviceID: AudioDeviceID, tier: VolumeControlTier, deviceName: String, - currentVolume: Float, + currentVolume: @autoclosure () -> Float, currentMute: Bool, setVolume: (AudioDeviceID, Float) -> Void, setMute: (AudioDeviceID, Bool) -> Void, @@ -266,7 +266,7 @@ final class MediaKeyMonitor { ) { let shouldShowHUD = !popupVisibility.isVisible let sliderDelta = settingsManager.appSettings.volumeHotkeyStep.sliderDelta - let currentSlider = VolumeMapping.sliderFraction(forSystemGain: currentVolume, tier: tier) + let currentSlider = VolumeMapping.sliderFraction(forSystemGain: currentVolume(), tier: tier) switch event { case .volumeUp(let isRepeat): @@ -312,7 +312,10 @@ final class MediaKeyMonitor { let newMute = !currentMute setMute(deviceID, newMute) if shouldShowHUD { - hudController.show(sliderFraction: currentSlider, mute: newMute, deviceName: deviceName) + // Software unmute restores the saved gain; read it after the write. + let slider = newMute ? currentSlider + : VolumeMapping.sliderFraction(forSystemGain: currentVolume(), tier: tier) + hudController.show(sliderFraction: slider, mute: newMute, deviceName: deviceName) } iconCoordinator?.flashDevice() } diff --git a/FineTune/Utilities/URLHandler.swift b/FineTune/Utilities/URLHandler.swift index c4f885ad..579cb8ae 100644 --- a/FineTune/Utilities/URLHandler.swift +++ b/FineTune/Utilities/URLHandler.swift @@ -65,7 +65,7 @@ final class URLHandler { /// Set volumes for one or more apps /// URL format: finetune://set-volumes?app=com.a&volume=100&app=com.b&volume=50 - /// Volume is percentage: 0-100 (gain only, boost is per-app and set separately) + /// Volume is the displayed slider percentage: 0-100 (boost is set separately). private func handleSetVolumes(queryItems: [URLQueryItem]) { var pairs: [(identifier: String, volume: Int)] = [] var currentApp: String? @@ -105,8 +105,7 @@ final class URLHandler { } for (identifier, volumePercent) in pairs { - // Linear conversion: volume=100 → gain 1.0 - let gain = Float(volumePercent) / 100.0 + let gain = VolumeMapping.sliderToGain(Double(volumePercent) / 100.0) if let app = findApp(by: identifier) { audioEngine.setVolume(for: app, to: gain) @@ -154,7 +153,7 @@ final class URLHandler { let newGain = VolumeMapping.sliderToGain(sliderPosition) audioEngine.setVolume(for: app, to: newGain) - let newPercent = Int(round(newGain * 100)) + let newPercent = Int(round(sliderPosition * 100)) logger.info("Stepped volume \(direction) for \(app.name) to \(newPercent)%") } diff --git a/FineTuneTests/MediaKeyMonitorHandlerTests.swift b/FineTuneTests/MediaKeyMonitorHandlerTests.swift index 406467d4..b29adbdf 100644 --- a/FineTuneTests/MediaKeyMonitorHandlerTests.swift +++ b/FineTuneTests/MediaKeyMonitorHandlerTests.swift @@ -28,7 +28,8 @@ struct MediaKeyMonitorHandlerTests { private func makeMonitor( hudController: HUDWindowController? = nil, - popupVisible: Bool = false + popupVisible: Bool = false, + presenter: (any MediaKeyHUDPresenting)? = nil ) -> (monitor: MediaKeyMonitor, hud: HUDWindowController, popup: PopupVisibilityService, settingsManager: SettingsManager) { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -60,7 +61,7 @@ struct MediaKeyMonitorHandlerTests { audioEngine: engine, settingsManager: settings, accessibility: MockAccessibilityTrustProviding(isTrusted: true), - hudController: hud, + hudController: presenter ?? hud, popupVisibility: popup, mediaKeyStatus: mediaKeyStatus ) @@ -69,6 +70,30 @@ struct MediaKeyMonitorHandlerTests { // MARK: - Volume step arithmetic + @Test("Mute HUD uses the restored volume on unmute and the original level on mute", + arguments: [VolumeControlTier.software, .hardware, .ddc]) + func muteHUDVolume(tier: VolumeControlTier) throws { + let hud = RecordingMediaKeyHUD() + let (monitor, _, _, _) = makeMonitor(presenter: hud) + var volume: Float = tier == .software ? 0.36 : 0.6 + let restoredVolume = volume + + for wasMuted in [false, true] { + monitor.handleCore( + event: .muteToggle, deviceID: 1, tier: tier, + deviceName: "Test Device", currentVolume: volume, currentMute: wasMuted, + setVolume: { _, _ in Issue.record("Mute must not directly set volume") }, + setMute: { _, muted in + #expect(muted == !wasMuted) + if tier == .software { volume = muted ? 0 : restoredVolume } + } + ) + let presentation = try #require(hud.lastPresentation) + #expect(abs(presentation.fraction - 0.6) < 0.00001) + #expect(presentation.muted == !wasMuted) + } + } + @Test("volumeUp on software tier steps in slider domain (x² taper)") func volumeUpStep() { let (monitor, _, _, _) = makeMonitor() @@ -594,3 +619,14 @@ struct MediaKeyMonitorHandlerTests { #expect(feedbackCalls == 1) } } + +@MainActor +private final class RecordingMediaKeyHUD: MediaKeyHUDPresenting { + var lastPresentation: (fraction: Double, muted: Bool)? + + func swallowObserved() {} + + func show(sliderFraction: Double, mute: Bool, deviceName: String) { + lastPresentation = (sliderFraction, mute) + } +} diff --git a/FineTuneTests/URLHandlerTests.swift b/FineTuneTests/URLHandlerTests.swift new file mode 100644 index 00000000..636a2081 --- /dev/null +++ b/FineTuneTests/URLHandlerTests.swift @@ -0,0 +1,49 @@ +import AppKit +import Testing +@testable import FineTune + +@Suite("URL volume percentages") +@MainActor +struct URLHandlerTests { + @Test("URL volume matches the displayed percentage for active and inactive apps", + arguments: [0, 10, 20, 30, 50, 100]) + func displayedPercentage(percent: Int) throws { + let engine = RecordingURLEngine() + let handler = URLHandler(audioEngine: engine) + handler.handleURL(try #require(URL(string: + "finetune://set-volumes?app=com.test.active&volume=\(percent)&app=com.test.inactive&volume=\(percent)" + ))) + + #expect(engine.volumes.count == 2) + for identifier in ["com.test.active", "com.test.inactive"] { + let gain = try #require(engine.volumes[identifier]) + #expect(abs(VolumeMapping.gainToSlider(gain) * 100 - Double(percent)) < 0.0001) + } + } + + @Test("Invalid percentages are skipped without losing subsequent valid pairs") + func invalidPercentages() throws { + let engine = RecordingURLEngine() + URLHandler(audioEngine: engine).handleURL(try #require(URL(string: + "finetune://set-volumes?app=negative&volume=-1&app=over&volume=101&app=text&volume=bad&app=com.test.active&volume=50" + ))) + #expect(engine.volumes.count == 1) + #expect(engine.volumes["com.test.active"] == 0.25) + } +} + +@MainActor +private final class RecordingURLEngine: URLHandlerEngine { + let settingsManager = SettingsManager(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) + let apps = [AudioApp(id: 42424, processObjectIDs: [], name: "Test App", icon: NSImage(), bundleID: "com.test.active")] + var volumes: [String: Float] = [:] + + func setVolume(for app: AudioApp, to volume: Float) { volumes[app.persistenceIdentifier] = volume } + func getVolume(for app: AudioApp) -> Float { volumes[app.persistenceIdentifier] ?? 1 } + func setVolumeForInactive(identifier: String, to volume: Float) { volumes[identifier] = volume } + func setMute(for app: AudioApp, to muted: Bool) { Issue.record("Unexpected mute write") } + func getMute(for app: AudioApp) -> Bool { false } + func setDevice(for app: AudioApp, deviceUID: String?) { Issue.record("Unexpected routing write") } + func setMuteForInactive(identifier: String, to muted: Bool) { Issue.record("Unexpected mute write") } + func getMuteForInactive(identifier: String) -> Bool { false } +} diff --git a/guide/url-schemes.md b/guide/url-schemes.md index 52bae670..52a270cc 100644 --- a/guide/url-schemes.md +++ b/guide/url-schemes.md @@ -6,7 +6,7 @@ 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 | `finetune://set-volumes?app=BUNDLE_ID&volume=PERCENT` | Set the displayed volume percentage (0–100; boost is set separately in the app) | | 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 | @@ -49,10 +49,10 @@ open "finetune://set-mute?app=com.spotify.client&muted=true&app=com.apple.Music& open "finetune://set-volumes?app=com.spotify.client&volume=30&app=com.apple.systemuiserver&volume=0" ``` -**Gaming setup** — Boost a game and lower Discord: +**Gaming setup** — Set a game to full volume and lower Discord. Configure the game's boost separately in FineTune: ```bash -open "finetune://set-volumes?app=com.game.example&volume=400&app=com.hnc.Discord&volume=40" +open "finetune://set-volumes?app=com.game.example&volume=100&app=com.hnc.Discord&volume=40" ``` These commands work in Terminal, shell scripts, Automator, Raycast script commands, macOS Shortcuts (using "Open URL"), and any other tool that can open URLs.