Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ struct SettingsBackupPayload: Codable, Equatable {
let removeFillerWordsEnabled: Bool
let gaavModeEnabled: Bool
let pauseMediaDuringTranscription: Bool
/// Lossless capture of the unified media-behaviour enum (none / pause /
/// duck). Optional so that backups created by older builds (which only
/// wrote the legacy bool) still decode cleanly. New builds prefer this
/// field on restore and fall back to the bool only when it's nil.
let mediaBehaviorDuringTranscription: SettingsStore.MediaBehaviorDuringTranscription?
let vocabularyBoostingEnabled: Bool
Comment on lines 63 to 69

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 Include keep-awake preference in backup payload

The new preventSleepDuringTranscription setting is exposed in UI and persisted locally, but it is not part of SettingsBackupPayload, so backup/restore silently drops the user’s choice and reverts to the default (true) after restore. Since this commit explicitly updates backup format for adjacent media settings, omitting this new toggle makes restore behavior inconsistent for users migrating machines.

Useful? React with 👍 / 👎.

let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry]
let selectedDictationPromptID: String?
Expand Down
80 changes: 75 additions & 5 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2261,6 +2261,7 @@ final class SettingsStore: ObservableObject {
removeFillerWordsEnabled: self.removeFillerWordsEnabled,
gaavModeEnabled: self.gaavModeEnabled,
pauseMediaDuringTranscription: self.pauseMediaDuringTranscription,
mediaBehaviorDuringTranscription: self.mediaBehaviorDuringTranscription,
vocabularyBoostingEnabled: self.vocabularyBoostingEnabled,
customDictionaryEntries: self.customDictionaryEntries,
selectedDictationPromptID: self.selectedDictationPromptID,
Expand Down Expand Up @@ -2332,7 +2333,15 @@ final class SettingsStore: ObservableObject {
self.fillerWords = payload.fillerWords
self.removeFillerWordsEnabled = payload.removeFillerWordsEnabled
self.gaavModeEnabled = payload.gaavModeEnabled
self.pauseMediaDuringTranscription = payload.pauseMediaDuringTranscription
// Prefer the lossless enum if the backup carried it (new builds);
// fall back to the legacy bool for backups from older versions.
// Either way the assignment is deterministic — current state on the
// restoring machine never decides the outcome.
if let mode = payload.mediaBehaviorDuringTranscription {
self.mediaBehaviorDuringTranscription = mode
} else {
self.mediaBehaviorDuringTranscription = payload.pauseMediaDuringTranscription ? .pause : .none
}
self.vocabularyBoostingEnabled = payload.vocabularyBoostingEnabled
self.customDictionaryEntries = payload.customDictionaryEntries

Expand Down Expand Up @@ -2899,13 +2908,72 @@ final class SettingsStore: ObservableObject {

// MARK: - Media Playback Control

/// When enabled, automatically pauses system media playback when transcription starts.
/// Only resumes if FluidVoice was the one that paused it.
/// What FluidVoice does to system media playback when transcription starts.
enum MediaBehaviorDuringTranscription: String, Codable, CaseIterable, Identifiable {
/// Leave media alone.
case none
/// Pause currently playing media; resume on stop if FluidVoice paused it.
case pause
/// Drop the system output volume to a low value during transcription
/// and restore it on stop. Music keeps playing, just quietly.
case duck

var id: String { self.rawValue }

var displayName: String {
switch self {
case .none: return "Leave Playing"
case .pause: return "Pause"
case .duck: return "Lower Volume"
}
}
}

/// What to do with system media playback while transcribing.
/// New unified setting; reads migrate cleanly from the legacy
/// `pauseMediaDuringTranscription` boolean if present.
var mediaBehaviorDuringTranscription: MediaBehaviorDuringTranscription {
get {
if let raw = self.defaults.string(forKey: Keys.mediaBehaviorDuringTranscription),
let mode = MediaBehaviorDuringTranscription(rawValue: raw) {
return mode
}
// Migrate from legacy bool key on first read.
if self.defaults.object(forKey: Keys.pauseMediaDuringTranscription) != nil {
return self.defaults.bool(forKey: Keys.pauseMediaDuringTranscription) ? .pause : .none
}
return .none
}
set {
objectWillChange.send()
self.defaults.set(newValue.rawValue, forKey: Keys.mediaBehaviorDuringTranscription)
// Keep the legacy bool in sync so backup/restore round-trips don't
// surprise users who roll back to an older build.
self.defaults.set(newValue == .pause, forKey: Keys.pauseMediaDuringTranscription)
}
}

/// Legacy boolean view of `mediaBehaviorDuringTranscription`. Kept so
/// `BackupService`'s payload (which still exports a `Bool` for backward
/// compatibility with older builds) round-trips through the same key.
/// Deterministic in both directions: `true` selects `.pause`, `false`
/// selects `.none`. Restore paths should prefer the lossless enum field
/// on the payload when available so `.duck` survives a round trip.
var pauseMediaDuringTranscription: Bool {
get { self.defaults.object(forKey: Keys.pauseMediaDuringTranscription) as? Bool ?? false }
get { self.mediaBehaviorDuringTranscription == .pause }
set {
self.mediaBehaviorDuringTranscription = newValue ? .pause : .none
}
}

/// When enabled, FluidVoice creates an `IOPMAssertion` while a recording
/// is active so the display doesn't sleep and the screen doesn't lock
/// mid-dictation. Released as soon as recording stops.
var preventSleepDuringTranscription: Bool {
get { self.defaults.object(forKey: Keys.preventSleepDuringTranscription) as? Bool ?? true }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.pauseMediaDuringTranscription)
self.defaults.set(newValue, forKey: Keys.preventSleepDuringTranscription)
}
}

Expand Down Expand Up @@ -3670,6 +3738,8 @@ private extension SettingsStore {

/// Media Playback Control
static let pauseMediaDuringTranscription = "PauseMediaDuringTranscription"
static let mediaBehaviorDuringTranscription = "MediaBehaviorDuringTranscription"
static let preventSleepDuringTranscription = "PreventSleepDuringTranscription"

/// Custom Dictation Prompt
static let customDictationPrompt = "CustomDictationPrompt"
Expand Down
96 changes: 49 additions & 47 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,9 +506,9 @@ final class ASRService: ObservableObject {
private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000
private var isRecoveringAudioRoute = false

/// Tracks whether we paused system media for this recording session.
/// Used to resume playback only if we were the ones who paused it.
private var didPauseMediaForThisSession: Bool = false
/// What `MediaPlaybackService` did at the start of this session (paused,
/// ducked, or nothing). Used to undo that action on stop.
private var mediaSessionAction: MediaSessionAction = .none

private var audioLevelSubject = PassthroughSubject<CGFloat, Never>()
var audioLevelPublisher: AnyPublisher<CGFloat, Never> { self.audioLevelSubject.eraseToAnyPublisher() }
Expand Down Expand Up @@ -751,8 +751,8 @@ final class ASRService: ObservableObject {
return
}

// Reset media pause state for this session
self.didPauseMediaForThisSession = false
// Reset media session action for this session
self.mediaSessionAction = .none
self.audioRouteRecoveryTask?.cancel()
self.audioRouteRecoveryTask = nil
self.isRecoveringAudioRoute = false
Expand Down Expand Up @@ -788,14 +788,28 @@ final class ASRService: ObservableObject {
try self.setupEngineTap()
DebugLogger.shared.debug("✅ Engine tap setup complete", source: "ASRService")

// Pause system media AFTER successful audio setup but BEFORE setting isRunning
// This ensures we only pause media when we know recording will succeed
if SettingsStore.shared.pauseMediaDuringTranscription {
// Apply media behaviour AFTER successful audio setup but BEFORE setting isRunning
// This ensures we only touch media when we know recording will succeed
switch SettingsStore.shared.mediaBehaviorDuringTranscription {
case .none:
self.mediaSessionAction = .none
case .pause:
let didPause = await MediaPlaybackService.shared.pauseIfPlaying()
self.didPauseMediaForThisSession = didPause
self.mediaSessionAction = didPause ? .paused : .none
if didPause {
DebugLogger.shared.info("🎵 Paused system media for transcription", source: "ASRService")
}
case .duck:
if let previousVolume = MediaPlaybackService.shared.duckSystemVolume() {
self.mediaSessionAction = .ducked(previousVolume: previousVolume)
} else {
self.mediaSessionAction = .none
}
}

// Hold the display awake while recording, if the user opted in.
if SettingsStore.shared.preventSleepDuringTranscription {
SleepPreventionService.shared.preventSleep()
}

self.isRunning = true
Expand All @@ -821,12 +835,11 @@ final class ASRService: ObservableObject {
} catch {
DebugLogger.shared.error("Failed to start ASR session: \(error)", source: "ASRService")

// Resume media if we paused it before the failure
if self.didPauseMediaForThisSession {
await MediaPlaybackService.shared.resumeIfWePaused(true)
self.didPauseMediaForThisSession = false
DebugLogger.shared.info("🎵 Resumed system media after start failure", source: "ASRService")
}
// Undo any media action we took before the failure.
await MediaPlaybackService.shared.restore(from: self.mediaSessionAction)
self.mediaSessionAction = .none
// Always release any sleep assertion we created.
SleepPreventionService.shared.allowSleep()

// Provide user-friendly error feedback
let errorMessage: String
Expand Down Expand Up @@ -894,9 +907,14 @@ final class ASRService: ObservableObject {
self.audioRouteRecoveryTask = nil
self.isRecoveringAudioRoute = false

// Capture media pause state before we reset it, for resuming at the end
let shouldResumeMedia = SettingsStore.shared.pauseMediaDuringTranscription && self.didPauseMediaForThisSession
self.didPauseMediaForThisSession = false // Reset for next session
// Capture the media action so we can undo it at every exit path.
let pendingMediaRestore = self.mediaSessionAction
self.mediaSessionAction = .none // Reset for next session

// Always release the sleep assertion at the start of stop — recording
// is over from the user's point of view, even if transcription is
// still running.
SleepPreventionService.shared.allowSleep()

DebugLogger.shared.debug("📍 Preparing final transcription", source: "ASRService")

Expand Down Expand Up @@ -928,6 +946,12 @@ final class ASRService: ObservableObject {
// New engine will be lazily created on next access via computed property
DebugLogger.shared.debug("✅ Engine instance recreated", source: "ASRService")

// Restore media as soon as the audio engine is fully torn down — there's
// no risk of recording the volume bump now that capture has stopped, and
// it lines the volume restore up with the moment the user lifts the
// hotkey rather than the moment transcription finishes.
await MediaPlaybackService.shared.restore(from: pendingMediaRestore)

// CRITICAL FIX: Await completion of streaming task AND any pending transcriptions
// This prevents use-after-free crashes (EXC_BAD_ACCESS) when clearing buffer
DebugLogger.shared.debug("⏳ Awaiting stopStreamingTimerAndAwait()...", source: "ASRService")
Expand Down Expand Up @@ -955,10 +979,6 @@ final class ASRService: ObservableObject {
"Final ASR result | provider=\(self.transcriptionProvider.name) | samples=0 | textChars=0 | confidence=nil | reason=no_audio",
source: "ASRService"
)
if shouldResumeMedia {
await MediaPlaybackService.shared.resumeIfWePaused(true)
DebugLogger.shared.info("🎵 Resumed system media after empty audio", source: "ASRService")
}
return ""
}

Expand All @@ -984,11 +1004,6 @@ final class ASRService: ObservableObject {

guard self.transcriptionProvider.isReady else {
DebugLogger.shared.error("Transcription provider is not ready", source: "ASRService")
// Resume media playback if we paused it
if shouldResumeMedia {
await MediaPlaybackService.shared.resumeIfWePaused(true)
DebugLogger.shared.info("🎵 Resumed system media after provider not ready", source: "ASRService")
}
return ""
}

Expand Down Expand Up @@ -1026,12 +1041,6 @@ final class ASRService: ObservableObject {
self.recordWordBoostHitIfAny(transcribedText: cleanedText)
DebugLogger.shared.debug("After post-processing: '\(cleanedText)'", source: "ASRService")

// Resume media playback if we paused it
if shouldResumeMedia {
await MediaPlaybackService.shared.resumeIfWePaused(true)
DebugLogger.shared.info("🎵 Resumed system media after transcription", source: "ASRService")
}

return cleanedText
} catch {
DebugLogger.shared.error("ASR transcription failed: \(error)", source: "ASRService")
Expand All @@ -1055,12 +1064,6 @@ final class ASRService: ObservableObject {
// (e.g., accidental hotkey press) and would disrupt the user's workflow.
// Errors are logged for debugging purposes.

// Resume media playback if we paused it
if shouldResumeMedia {
await MediaPlaybackService.shared.resumeIfWePaused(true)
DebugLogger.shared.info("🎵 Resumed system media after transcription failure", source: "ASRService")
}

return ""
}
}
Expand All @@ -1073,9 +1076,12 @@ final class ASRService: ObservableObject {
self.audioRouteRecoveryTask = nil
self.isRecoveringAudioRoute = false

// Capture media pause state before we reset it, for resuming at the end
let shouldResumeMedia = SettingsStore.shared.pauseMediaDuringTranscription && self.didPauseMediaForThisSession
self.didPauseMediaForThisSession = false // Reset for next session
// Capture the media action so we can undo it after teardown.
let pendingMediaRestore = self.mediaSessionAction
self.mediaSessionAction = .none // Reset for next session

// Release the sleep assertion as soon as recording stops.
SleepPreventionService.shared.allowSleep()

DebugLogger.shared.info("🛑 Stopping recording - releasing audio devices", source: "ASRService")

Expand Down Expand Up @@ -1117,11 +1123,7 @@ final class ASRService: ObservableObject {
self.lastStreamingChunkFailureAnalyticsAt = nil
self.refreshWordBoostStatus()

// Resume media playback if we paused it
if shouldResumeMedia {
await MediaPlaybackService.shared.resumeIfWePaused(true)
DebugLogger.shared.info("🎵 Resumed system media after stopping without transcription", source: "ASRService")
}
await MediaPlaybackService.shared.restore(from: pendingMediaRestore)
}

private func configureSession() throws {
Expand Down
Loading