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: 3 additions & 0 deletions FineTune/Audio/DDC/DDCController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions FineTune/Audio/Keys/MediaKeyMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,15 @@ final class MediaKeyMonitor {
deviceID: AudioDeviceID,
tier: VolumeControlTier,
deviceName: String,
currentVolume: Float,
currentVolume: @autoclosure () -> Float,
currentMute: Bool,
setVolume: (AudioDeviceID, Float) -> Void,
setMute: (AudioDeviceID, Bool) -> Void,
playFeedback: (Float) -> Void = { _ in }
) {
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):
Expand Down Expand Up @@ -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()
}
Expand Down
7 changes: 3 additions & 4 deletions FineTune/Utilities/URLHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)%")
}

Expand Down
40 changes: 38 additions & 2 deletions FineTuneTests/MediaKeyMonitorHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -60,7 +61,7 @@ struct MediaKeyMonitorHandlerTests {
audioEngine: engine,
settingsManager: settings,
accessibility: MockAccessibilityTrustProviding(isTrusted: true),
hudController: hud,
hudController: presenter ?? hud,
popupVisibility: popup,
mediaKeyStatus: mediaKeyStatus
)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}
49 changes: 49 additions & 0 deletions FineTuneTests/URLHandlerTests.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
6 changes: 3 additions & 3 deletions guide/url-schemes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down