From 432d1ff121812f66529e55ea5ce51f4d6536f7a5 Mon Sep 17 00:00:00 2001 From: postoso Date: Tue, 1 Sep 2026 19:38:07 -0400 Subject: [PATCH 1/2] fix(audio): remove Independent Volume instead of writing system volume Independent Volume was the only code path that wrote the macOS output volume. It saved the current level, set the output to the selected cue level for the length of the cue, then restored it, which is what let a transcription cue change other applications' audio (#522). Compensating app-side instead does not preserve what the option promised. The CoreAudio volume scalar and AVAudioPlayer.volume are different gain domains, so a player gain derived from their ratio does not land on the selected level, and it caps at full gain once the selected level is above the current output. Removing the option leaves one cue-volume slider, and nothing writes the system volume any more. It also removes four failure modes that lived in the save/restore path: a failed CoreAudio read was saved as 1.0 and restored as maximum, the restore re-resolved the default output so switching outputs mid-cue wrote one device's baseline onto another, the delayed restore had no termination handler, and overlapping cues shared one saved-volume slot. The Independent Volume settings copy is removed with the toggle. It described the old behaviour ("stays constant regardless of system volume", "temporarily changes system volume during playback") and was wrong in both directions either way. --- Sources/Fluid/Persistence/BackupService.swift | 1 - Sources/Fluid/Persistence/SettingsStore.swift | 14 --- .../Services/TranscriptionSoundPlayer.swift | 113 +----------------- Sources/Fluid/UI/SettingsView.swift | 10 -- 4 files changed, 6 insertions(+), 132 deletions(-) diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 0dfdd165c..cd48fea40 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -50,7 +50,6 @@ struct SettingsBackupPayload: Codable, Equatable { let accentColorOption: SettingsStore.AccentColorOption let transcriptionStartSound: SettingsStore.TranscriptionStartSound let transcriptionSoundVolume: Float - let transcriptionSoundIndependentVolume: Bool let autoUpdateCheckEnabled: Bool let betaReleasesEnabled: Bool let enableDebugLogs: Bool diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index cd10fcdec..8d48c297e 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2388,17 +2388,6 @@ final class SettingsStore: ObservableObject { } } - var transcriptionSoundIndependentVolume: Bool { - get { - let value = self.defaults.object(forKey: Keys.transcriptionSoundIndependentVolume) - return value as? Bool ?? false - } - set { - objectWillChange.send() - self.defaults.set(newValue, forKey: Keys.transcriptionSoundIndependentVolume) - } - } - var transcriptionStartSound: TranscriptionStartSound { get { self.migrateTranscriptionStartSoundIfNeeded() @@ -3288,7 +3277,6 @@ final class SettingsStore: ObservableObject { accentColorOption: self.accentColorOption, transcriptionStartSound: self.transcriptionStartSound, transcriptionSoundVolume: self.transcriptionSoundVolume, - transcriptionSoundIndependentVolume: self.transcriptionSoundIndependentVolume, autoUpdateCheckEnabled: self.autoUpdateCheckEnabled, betaReleasesEnabled: self.betaReleasesEnabled, enableDebugLogs: self.enableDebugLogs, @@ -3422,7 +3410,6 @@ final class SettingsStore: ObservableObject { self.accentColorOption = payload.accentColorOption self.transcriptionStartSound = payload.transcriptionStartSound self.transcriptionSoundVolume = payload.transcriptionSoundVolume - self.transcriptionSoundIndependentVolume = payload.transcriptionSoundIndependentVolume self.autoUpdateCheckEnabled = payload.autoUpdateCheckEnabled self.betaReleasesEnabled = payload.betaReleasesEnabled self.enableDebugLogs = payload.enableDebugLogs @@ -5443,7 +5430,6 @@ private extension SettingsStore { static let enableTranscriptionSounds = "EnableTranscriptionSounds" static let transcriptionStartSound = "TranscriptionStartSound" static let transcriptionSoundVolume = "TranscriptionSoundVolume" - static let transcriptionSoundIndependentVolume = "TranscriptionSoundIndependentVolume" static let pressAndHoldMode = "PressAndHoldMode" static let hotkeyMode = "HotkeyMode" static let enableStreamingPreview = "EnableStreamingPreview" diff --git a/Sources/Fluid/Services/TranscriptionSoundPlayer.swift b/Sources/Fluid/Services/TranscriptionSoundPlayer.swift index 977b3f659..4adf42835 100644 --- a/Sources/Fluid/Services/TranscriptionSoundPlayer.swift +++ b/Sources/Fluid/Services/TranscriptionSoundPlayer.swift @@ -1,5 +1,4 @@ import AVFoundation -import CoreAudio import Foundation final class TranscriptionSoundPlayer { @@ -7,7 +6,6 @@ final class TranscriptionSoundPlayer { private let playbackQueue = DispatchQueue(label: "app.fluidvoice.transcription-sounds", qos: .userInteractive) private var players: [String: AVAudioPlayer] = [:] - private var savedSystemVolume: Float? private init() {} @@ -16,11 +14,7 @@ final class TranscriptionSoundPlayer { guard settings.enableTranscriptionSounds else { return } let selected = settings.transcriptionStartSound guard let soundName = selected.startSoundFileName else { return } - self.play( - soundName: soundName, - desiredVolume: settings.transcriptionSoundVolume, - independentVolume: settings.transcriptionSoundIndependentVolume - ) + self.play(soundName: soundName, desiredVolume: settings.transcriptionSoundVolume) } func playStopSound() { @@ -28,40 +22,24 @@ final class TranscriptionSoundPlayer { guard settings.enableTranscriptionSounds else { return } let selected = settings.transcriptionStartSound guard let soundName = selected.stopSoundFileName else { return } - self.play( - soundName: soundName, - desiredVolume: settings.transcriptionSoundVolume, - independentVolume: settings.transcriptionSoundIndependentVolume - ) + self.play(soundName: soundName, desiredVolume: settings.transcriptionSoundVolume) } /// Preview a specific sound at the current volume setting (used in Settings UI). func playPreview(sound: SettingsStore.TranscriptionStartSound) { guard let soundName = sound.startSoundFileName else { return } let settings = SettingsStore.shared - self.play( - soundName: soundName, - desiredVolume: settings.transcriptionSoundVolume, - independentVolume: settings.transcriptionSoundIndependentVolume - ) + self.play(soundName: soundName, desiredVolume: settings.transcriptionSoundVolume) } /// Preview current sound at a specific volume (used when slider is released). func playPreviewAtVolume(_ volume: Float) { let selected = SettingsStore.shared.transcriptionStartSound guard let soundName = selected.startSoundFileName else { return } - self.play( - soundName: soundName, - desiredVolume: volume, - independentVolume: SettingsStore.shared.transcriptionSoundIndependentVolume - ) + self.play(soundName: soundName, desiredVolume: volume) } - private func play( - soundName: String, - desiredVolume: Float, - independentVolume: Bool - ) { + private func play(soundName: String, desiredVolume: Float) { let startedAt = ProcessInfo.processInfo.systemUptime DebugLogger.shared.benchmark( "APP_BENCH", @@ -79,7 +57,6 @@ final class TranscriptionSoundPlayer { soundName: soundName, url: url, desiredVolume: desiredVolume, - independentVolume: independentVolume, startedAt: startedAt ) } @@ -89,17 +66,8 @@ final class TranscriptionSoundPlayer { soundName: String, url: URL, desiredVolume: Float, - independentVolume: Bool, startedAt: TimeInterval ) { - if independentVolume { - let currentSystemVol = Self.getSystemVolume() - guard currentSystemVol > 0.001 else { return } - // Save current system volume and temporarily set it to desired level - self.savedSystemVolume = currentSystemVol - Self.setSystemVolume(desiredVolume) - } - do { let player: AVAudioPlayer if let existing = self.players[soundName] { @@ -111,87 +79,18 @@ final class TranscriptionSoundPlayer { } player.currentTime = 0 - if independentVolume { - player.volume = 1.0 - } else { - player.volume = desiredVolume - } + player.volume = desiredVolume player.play() DebugLogger.shared.benchmark( "APP_BENCH", message: "sound_play_dispatched sound=\(soundName) elapsedMs=\(Int(((ProcessInfo.processInfo.systemUptime - startedAt) * 1000).rounded()))", source: "AppBenchmark" ) - - // Restore system volume after the sound finishes - if independentVolume, let saved = self.savedSystemVolume { - let duration = player.duration - self.playbackQueue.asyncAfter(deadline: .now() + duration + 0.05) { [weak self] in - Self.setSystemVolume(saved) - self?.savedSystemVolume = nil - } - } } catch { - // Restore system volume on error - if let saved = self.savedSystemVolume { - Self.setSystemVolume(saved) - self.savedSystemVolume = nil - } DebugLogger.shared.error( "Failed to play sound \(soundName).m4a: \(error.localizedDescription)", source: "TranscriptionSoundPlayer" ) } } - - // MARK: - System Volume via CoreAudio - - private static func getDefaultOutputDeviceID() -> AudioObjectID? { - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultOutputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - var deviceID = AudioObjectID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - AudioObjectID(kAudioObjectSystemObject), - &address, - 0, - nil, - &size, - &deviceID - ) - guard status == noErr, deviceID != kAudioObjectUnknown else { return nil } - return deviceID - } - - static func getSystemVolume() -> Float { - guard let deviceID = getDefaultOutputDeviceID() else { return 1.0 } - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, - mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - var volume: Float32 = 1.0 - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &volume) - guard status == noErr else { return 1.0 } - return volume - } - - private static func setSystemVolume(_ volume: Float) { - guard let deviceID = getDefaultOutputDeviceID() else { return } - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, - mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - var vol = Float32(max(0, min(1, volume))) - let size = UInt32(MemoryLayout.size) - let status = AudioObjectSetPropertyData(deviceID, &address, 0, nil, size, &vol) - if status != noErr { - DebugLogger.shared.error("Failed to set system volume: OSStatus \(status)", source: "TranscriptionSoundPlayer") - } - } } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 49ccd8574..a36daa929 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -371,16 +371,6 @@ struct SettingsView: View { } .frame(width: 150) } - - self.settingsToggleRow( - title: "Independent Volume", - description: "Sound volume stays constant regardless of system volume. Mute is still respected.", - footnote: "Temporarily changes system volume during playback, which may briefly affect other audio.", - isOn: Binding( - get: { SettingsStore.shared.transcriptionSoundIndependentVolume }, - set: { SettingsStore.shared.transcriptionSoundIndependentVolume = $0 } - ) - ) } Divider().opacity(0.2) From db4e09d97e7452c0d608d5f451fe49d2cec67ecc Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 4 Sep 2026 01:07:19 -0400 Subject: [PATCH 2/2] fix(backup): keep deprecated independent-volume key in schema 1.0 exports Dropping transcriptionSoundIndependentVolume from SettingsBackupPayload broke downgrades: the previous app version's synthesized decoder still requires that key, and the schema stays 1.0, so a backup written by this build looks compatible and is reported as invalid JSON instead. Encode the key again with a fixed false and decode it tolerantly. Nothing reads it back into settings, so Independent Volume stays removed. --- Sources/Fluid/Persistence/BackupService.swift | 3 +++ Sources/Fluid/Persistence/SettingsStore.swift | 1 + .../DictationE2ETests.swift | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index cd48fea40..8772c0c66 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -50,6 +50,9 @@ struct SettingsBackupPayload: Codable, Equatable { let accentColorOption: SettingsStore.AccentColorOption let transcriptionStartSound: SettingsStore.TranscriptionStartSound let transcriptionSoundVolume: Float + // Independent Volume was removed, but the key is still written (always false) so backups + // from this build decode on app versions that require it. Ignored on restore. + let transcriptionSoundIndependentVolume: Bool? let autoUpdateCheckEnabled: Bool let betaReleasesEnabled: Bool let enableDebugLogs: Bool diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 8d48c297e..73040f42c 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -3277,6 +3277,7 @@ final class SettingsStore: ObservableObject { accentColorOption: self.accentColorOption, transcriptionStartSound: self.transcriptionStartSound, transcriptionSoundVolume: self.transcriptionSoundVolume, + transcriptionSoundIndependentVolume: false, autoUpdateCheckEnabled: self.autoUpdateCheckEnabled, betaReleasesEnabled: self.betaReleasesEnabled, enableDebugLogs: self.enableDebugLogs, diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 0e9628adc..bda31ea5a 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -2850,6 +2850,19 @@ extension DictationE2ETests { settings.restore(from: legacyBackup.settings) XCTAssertEqual(settings.spokenFormattingActionRules, normalizedRulesBeforeLegacyRestore) } + + func testBackupKeepsDeprecatedIndependentVolumeKeyAndDecodesWithoutIt() async throws { + let document = try await BackupService.shared.makeBackupDocument() + let encoded = try BackupService.shared.encode(document) + var root = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + var encodedSettings = try XCTUnwrap(root["settings"] as? [String: Any]) + XCTAssertEqual(encodedSettings["transcriptionSoundIndependentVolume"] as? Bool, false) + + encodedSettings.removeValue(forKey: "transcriptionSoundIndependentVolume") + root["settings"] = encodedSettings + let strippedBackup = try BackupService.shared.decode(JSONSerialization.data(withJSONObject: root)) + XCTAssertNil(strippedBackup.settings.transcriptionSoundIndependentVolume) + } } @MainActor