Skip to content
2 changes: 2 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ struct SettingsBackupPayload: Codable, Equatable {
let continuousDictationSpacingEnabled: Bool?
let contextAwareCapitalizationEnabled: Bool?
let pauseMediaDuringTranscription: Bool
let duckMediaInsteadOfPausing: Bool?
let duckMediaVolumeLevel: Double?
let vocabularyBoostingEnabled: Bool
let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry]
let selectedDictationPromptID: String?
Expand Down
31 changes: 31 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2764,6 +2764,8 @@ final class SettingsStore: ObservableObject {
continuousDictationSpacingEnabled: self.continuousDictationSpacingEnabled,
contextAwareCapitalizationEnabled: self.contextAwareCapitalizationEnabled,
pauseMediaDuringTranscription: self.pauseMediaDuringTranscription,
duckMediaInsteadOfPausing: self.duckMediaInsteadOfPausing,
duckMediaVolumeLevel: self.duckMediaVolumeLevel,
vocabularyBoostingEnabled: self.vocabularyBoostingEnabled,
customDictionaryEntries: self.customDictionaryEntries,
selectedDictationPromptID: self.selectedDictationPromptID,
Expand Down Expand Up @@ -2862,6 +2864,10 @@ final class SettingsStore: ObservableObject {
self.continuousDictationSpacingEnabled = payload.continuousDictationSpacingEnabled ?? restoredContinuousDictationModeEnabled
self.contextAwareCapitalizationEnabled = payload.contextAwareCapitalizationEnabled ?? restoredContinuousDictationModeEnabled
self.pauseMediaDuringTranscription = payload.pauseMediaDuringTranscription
self.duckMediaInsteadOfPausing = payload.duckMediaInsteadOfPausing ?? false
if let duckLevel = payload.duckMediaVolumeLevel {
self.duckMediaVolumeLevel = duckLevel
}
self.vocabularyBoostingEnabled = payload.vocabularyBoostingEnabled
self.customDictionaryEntries = payload.customDictionaryEntries

Expand Down Expand Up @@ -3531,6 +3537,29 @@ final class SettingsStore: ObservableObject {
}
}

/// When enabled (and `pauseMediaDuringTranscription` is on), lowers the system
/// output volume during transcription instead of fully pausing playback.
var duckMediaInsteadOfPausing: Bool {
get { self.defaults.object(forKey: Keys.duckMediaInsteadOfPausing) as? Bool ?? false }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.duckMediaInsteadOfPausing)
}
}

/// Target output volume while ducking, expressed as a fraction (0.05–1.0) of
/// the volume at the moment transcription starts. Defaults to 0.2 (20%).
var duckMediaVolumeLevel: Double {
get {
let stored = self.defaults.object(forKey: Keys.duckMediaVolumeLevel) as? Double ?? 0.2
return min(1.0, max(0.05, stored))
}
set {
objectWillChange.send()
self.defaults.set(min(1.0, max(0.05, newValue)), forKey: Keys.duckMediaVolumeLevel)
}
}

// MARK: - Custom Dictionary

/// A custom dictionary entry that maps multiple misheard/alternate spellings to a correct replacement.
Expand Down Expand Up @@ -4436,6 +4465,8 @@ private extension SettingsStore {

/// Media Playback Control
static let pauseMediaDuringTranscription = "PauseMediaDuringTranscription"
static let duckMediaInsteadOfPausing = "DuckMediaInsteadOfPausing"
static let duckMediaVolumeLevel = "DuckMediaVolumeLevel"

/// Custom Dictation Prompt
static let customDictationPrompt = "CustomDictationPrompt"
Expand Down
137 changes: 122 additions & 15 deletions Sources/Fluid/Services/MediaPlaybackService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,62 @@ final class MediaPlaybackService {

#if arch(arm64)
private let mediaController = MediaController()
private let volumeController = SystemAudioVolumeController()

/// Tracks the action we took for the current transcription session so that
/// `resumeIfWePaused(_:)` can revert exactly what was applied.
private enum ActiveSuppression {
/// We sent a pause command and should send play() to restore.
case paused
/// We lowered the output volume. `original` is the pre-duck snapshot to
/// restore (preserving per-channel balance); `applied` is what the device
/// actually snapped to, used to detect user changes mid-dictation.
case ducked(original: OutputVolumeSnapshot, applied: OutputVolumeSnapshot)
}

private var activeSuppression: ActiveSuppression?
#endif

private init() {}

// MARK: - Public API

#if arch(arm64)
/// Pauses system media playback if something is currently playing.
/// Suppresses system media while transcription is active, if something is
/// currently playing.
///
/// - Returns: `true` if we successfully paused playback, `false` if nothing was playing
/// or if we couldn't determine playback state.
/// Depending on `SettingsStore.duckMediaInsteadOfPausing`, this either fully
/// pauses playback or lowers the system output volume ("ducking"). The action
/// taken is recorded so `resumeIfWePaused(_:)` can revert exactly what was done.
///
/// - Returns: `true` if we took an action (pause or duck) that must later be
/// reverted, `false` if nothing was playing or if we couldn't determine
/// playback state.
///
/// - Note: Uses a local one-shot gate to protect against `MediaRemoteAdapter`
/// firing the `getTrackInfo` callback more than once, which would otherwise
/// crash with `EXC_BREAKPOINT` (SIGTRAP) due to double-resume of a
/// `CheckedContinuation`.
func pauseIfPlaying() async -> Bool {
// A suppression from a previous session may not have been reverted yet: stop() flips
// isRunning false before its (slow) final transcription pass, and only reverts media
// afterwards, so a new dictation can start during that window. Don't begin a second
// suppression — capturing the already-ducked volume would lose the true original and
// could leave the Mac stuck at the ducked level. Report no new action; the in-flight
// revert from the prior session restores the original volume.
guard self.activeSuppression == nil else {
DebugLogger.shared.warning(
"MediaPlaybackService: Suppression already active from a prior session, not starting another",
source: "MediaPlaybackService"
)
return false
Comment thread
akar016012 marked this conversation as resolved.
}

return await withCheckedContinuation { continuation in
let resumeLock = NSLock()
var didResume = false

func resumeOnce(_ value: Bool) {
func resumeOnce(_ value: Bool, beforeResuming: (() -> Void)? = nil) {
var shouldResume = false

resumeLock.lock()
Expand All @@ -53,6 +87,9 @@ final class MediaPlaybackService {
return
}

// Runs exactly once, only for the winning callback, before resuming the
// continuation — so any side effect is gated by the same one-shot.
beforeResuming?()
continuation.resume(returning: value)
}

Expand Down Expand Up @@ -97,12 +134,11 @@ final class MediaPlaybackService {
)

if isPlaying {
DebugLogger.shared.info(
"MediaPlaybackService: Media is playing, sending pause command",
source: "MediaPlaybackService"
)
self.mediaController.pause()
resumeOnce(true)
// Gate the suppression behind the same one-shot as the resume.
// MediaRemoteAdapter can fire this callback more than once, and ducking
// is not idempotent (it reads the current volume as the "original"), so a
// duplicate must not re-duck or overwrite `activeSuppression`.
resumeOnce(true) { self.applySuppression() }
} else {
DebugLogger.shared.debug(
"MediaPlaybackService: Media is not playing, no action needed",
Expand All @@ -114,25 +150,96 @@ final class MediaPlaybackService {
}
}

/// Resumes media playback only if we were the ones who paused it.
/// Reverts the media suppression applied for this session — resuming playback
/// if we paused it, or restoring the output volume if we ducked it.
///
/// - Parameter wePaused: `true` if `pauseIfPlaying()` returned `true` for this session.
func resumeIfWePaused(_ wePaused: Bool) async {
guard wePaused else {
DebugLogger.shared.debug(
"MediaPlaybackService: We didn't pause media, not resuming",
"MediaPlaybackService: We didn't suppress media, nothing to revert",
source: "MediaPlaybackService"
)
return
}

self.revertSuppression()
}

// MARK: - Suppression helpers

/// Either pauses playback or ducks the system output volume, based on the
/// user's setting, and records what was done in `activeSuppression`.
private func applySuppression() {
// Ducking: lower the output volume instead of stopping playback entirely.
if SettingsStore.shared.duckMediaInsteadOfPausing,
let original = self.volumeController.captureOutputVolume()
{
let level = Float(SettingsStore.shared.duckMediaVolumeLevel)
let target = original.scaled(by: level)
if self.volumeController.apply(target) {
Comment thread
akar016012 marked this conversation as resolved.
// Re-read what the device actually snapped to (volume can be quantized to
// coarse steps) so the restore-time change check is accurate.
let applied = self.volumeController.reread(target) ?? target
self.activeSuppression = .ducked(original: original, applied: applied)
Comment thread
akar016012 marked this conversation as resolved.
DebugLogger.shared.info(
"MediaPlaybackService: Ducked output volume \(original.averageLevel) -> \(applied.averageLevel) for transcription",
source: "MediaPlaybackService"
)
return
}

DebugLogger.shared.warning(
"MediaPlaybackService: Failed to lower output volume, falling back to pausing media",
source: "MediaPlaybackService"
)
}

DebugLogger.shared.info(
"MediaPlaybackService: Resuming media playback (we paused it)",
"MediaPlaybackService: Media is playing, sending pause command",
source: "MediaPlaybackService"
)
self.mediaController.pause()
self.activeSuppression = .paused
}

/// Reverts whatever `applySuppression()` did for the current session.
private func revertSuppression() {
switch self.activeSuppression {
case .paused:
DebugLogger.shared.info(
"MediaPlaybackService: Resuming media playback (we paused it)",
source: "MediaPlaybackService"
)
// Use explicit play() command - never toggle
self.mediaController.play()

case let .ducked(original, applied):
// Only restore if the volume is still roughly where we left it. If the
// user adjusted it during dictation, respect their choice and leave it.
if let current = self.volumeController.reread(applied)?.averageLevel,
abs(current - applied.averageLevel) > 0.02
{
DebugLogger.shared.info(
"MediaPlaybackService: Output volume changed during dictation (\(applied.averageLevel) -> \(current)), leaving as-is",
source: "MediaPlaybackService"
)
} else {
DebugLogger.shared.info(
"MediaPlaybackService: Restoring output volume to \(original.averageLevel) (we ducked it)",
source: "MediaPlaybackService"
)
self.volumeController.apply(original)
}

case .none:
DebugLogger.shared.debug(
"MediaPlaybackService: No active suppression to revert",
source: "MediaPlaybackService"
)
}

// Use explicit play() command - never toggle
self.mediaController.play()
self.activeSuppression = nil
}
#else
// Intel Mac stub - media control not available
Expand Down
Loading