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
4 changes: 3 additions & 1 deletion Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ struct SettingsBackupPayload: Codable, Equatable {
let accentColorOption: SettingsStore.AccentColorOption
let transcriptionStartSound: SettingsStore.TranscriptionStartSound
let transcriptionSoundVolume: Float
let transcriptionSoundIndependentVolume: Bool
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the deprecated field in same-schema backups

When a user exports a backup from this build and then restores it with the immediately preceding app version (for example, after downgrading), decoding fails because that version's synthesized SettingsBackupPayload decoder still requires transcriptionSoundIndependentVolume. The backup schema remains 1.0, so these files appear compatible but are reported as invalid JSON; retain the field in the encoded payload with a harmless fixed value, or introduce explicit schema migration/version handling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7dd082. The payload writes transcriptionSoundIndependentVolume again, always false, and decodes it whether or not the key is present, so the schema stays 1.0 and a backup from this build still restores on the previous version. false was that version's default and the branch that never touched system volume, so a downgraded restore lands on the safe behavior. Nothing reads the value back into settings, so Independent Volume stays removed. There is a test for both halves.

let betaReleasesEnabled: Bool
let enableDebugLogs: Bool
Expand Down
15 changes: 1 addition & 14 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2374,17 +2374,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()
Expand Down Expand Up @@ -3274,7 +3263,7 @@ final class SettingsStore: ObservableObject {
accentColorOption: self.accentColorOption,
transcriptionStartSound: self.transcriptionStartSound,
transcriptionSoundVolume: self.transcriptionSoundVolume,
transcriptionSoundIndependentVolume: self.transcriptionSoundIndependentVolume,
transcriptionSoundIndependentVolume: false,
autoUpdateCheckEnabled: self.autoUpdateCheckEnabled,
betaReleasesEnabled: self.betaReleasesEnabled,
enableDebugLogs: self.enableDebugLogs,
Expand Down Expand Up @@ -3406,7 +3395,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
Expand Down Expand Up @@ -5353,7 +5341,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"
Expand Down
113 changes: 6 additions & 107 deletions Sources/Fluid/Services/TranscriptionSoundPlayer.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import AVFoundation
import CoreAudio
import Foundation

final class TranscriptionSoundPlayer {
static let shared = TranscriptionSoundPlayer()

private let playbackQueue = DispatchQueue(label: "app.fluidvoice.transcription-sounds", qos: .userInteractive)
private var players: [String: AVAudioPlayer] = [:]
private var savedSystemVolume: Float?

private init() {}

Expand All @@ -16,52 +14,32 @@ 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() {
let settings = SettingsStore.shared
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",
Expand All @@ -79,7 +57,6 @@ final class TranscriptionSoundPlayer {
soundName: soundName,
url: url,
desiredVolume: desiredVolume,
independentVolume: independentVolume,
startedAt: startedAt
)
}
Expand All @@ -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] {
Expand All @@ -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<AudioObjectID>.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<Float32>.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<Float32>.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")
}
}
}
10 changes: 0 additions & 10 deletions Sources/Fluid/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -374,16 +374,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)
Expand Down
13 changes: 13 additions & 0 deletions Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2424,6 +2424,19 @@ extension DictationE2ETests {
settings.restore(from: legacyBackup.settings)
XCTAssertEqual(settings.spokenFormattingActionRules, normalizedRulesBeforeLegacyRestore)
}

func testBackupKeepsDeprecatedIndependentVolumeKeyAndDecodesWithoutIt() async throws {
let document = 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
Expand Down
Loading