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
2 changes: 2 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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 @@ -2875,6 +2875,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 @@ -2990,6 +2992,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 @@ -3667,6 +3673,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 @@ -4570,6 +4599,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
145 changes: 130 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,104 @@ 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.
// Mute is checked separately from the tolerance: a deeply ducked level can
// sit within 0.02 of zero, and restoring over a mute would turn the user's
// audio back on against their explicit intent.
let current = self.volumeController.reread(applied)?.averageLevel
let userMuted = current.map { $0 <= 0.001 && applied.averageLevel > 0.001 } ?? false
if let current, userMuted || abs(current - applied.averageLevel) > 0.02 {
DebugLogger.shared.info(
"MediaPlaybackService: Output volume changed during dictation (\(applied.averageLevel) -> \(current)\(userMuted ? ", muted" : "")), leaving as-is",
source: "MediaPlaybackService"
)
} else {
DebugLogger.shared.info(
"MediaPlaybackService: Restoring output volume to \(original.averageLevel) (we ducked it)",
source: "MediaPlaybackService"
)
if !self.volumeController.apply(original) {
DebugLogger.shared.warning(
"MediaPlaybackService: Failed to restore output volume — the ducked output device may no longer be available",
source: "MediaPlaybackService"
)
}
}
Comment thread
akar016012 marked this conversation as resolved.

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
Loading