Skip to content

feat(media): add option to duck media volume instead of pausing#459

Open
akar016012 wants to merge 12 commits into
altic-dev:mainfrom
akar016012:feature/duck-media-volume
Open

feat(media): add option to duck media volume instead of pausing#459
akar016012 wants to merge 12 commits into
altic-dev:mainfrom
akar016012:feature/duck-media-volume

Conversation

@akar016012

@akar016012 akar016012 commented Jun 28, 2026

Copy link
Copy Markdown

What

Adds a "Lower Volume Instead of Pausing" sub-option under the existing Pause Media During Transcription setting. When enabled, FluidVoice lowers the system output volume while you dictate and restores it afterward, instead of fully stopping playback — handy for keeping a video audible but quiet during narration/dictation.

Why

Today the only choice is to fully pause media during transcription. Some users would rather keep media playing at a lower volume than have it stop and resume.

Changes

  • SystemAudioVolumeController (new): a small CoreAudio wrapper that reads/sets the default output device's volume (master element with a per-channel fallback).
  • MediaPlaybackService: now either pauses or ducks based on the setting, recording which action it took so it reverts exactly what it applied. On restore it:
    • leaves the volume untouched if the user changed it mid-dictation, and
    • falls back to pausing if the volume can't be lowered.
  • Settings (SettingsStore + BackupService): adds duckMediaInsteadOfPausing (Bool) and duckMediaVolumeLevel (fraction of current volume, default 20%, clamped 5–100%), including backup/restore with backward-compatible decoding.
  • UI (SettingsView): nested toggle + level slider, shown only when media pausing is enabled.

Notes / trade-offs

  • CoreAudio output volume is system-wide, so ducking lowers all output from the default device, not just a single app. macOS does not expose per-app media ducking to third-party apps. The UI copy makes this explicit.
  • Behavior is arm64-only, consistent with the existing pause feature (which relies on MediaRemoteAdapter for playback detection). Intel remains a no-op.
  • No entitlement changes required (app is not sandboxed).

Testing

Verified the new CoreAudio file with swiftc -typecheck against the macOS SDK, and swiftc -parse on all edited files (clean). I was able to run the application using my team id on a M1 Pro chip MacBook Pro and verified the functionality

Type of Change

  • 🐞 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 🧹 Chore
  • 📝 Documentation update
image

Add a "Lower Volume Instead of Pausing" sub-option to "Pause Media During
Transcription". When enabled, FluidVoice lowers the system output volume
while you dictate and restores it afterward, instead of fully stopping
playback — useful for keeping a video audible but quiet during narration.

- Add SystemAudioVolumeController, a CoreAudio wrapper that reads/sets the
  default output device volume (master element with per-channel fallback).
- Extend MediaPlaybackService to either pause or duck based on the setting,
  tracking which action was taken so it reverts exactly what it applied. On
  restore it leaves the volume untouched if the user changed it mid-dictation,
  and falls back to pausing if the volume can't be lowered.
- Add duckMediaInsteadOfPausing and duckMediaVolumeLevel settings (level
  defaults to 20%, clamped 5–100%), including backup/restore support.
- Add the nested toggle and a level slider to Settings, shown only when
  media pausing is enabled.

Note: CoreAudio output volume is system-wide, so ducking lowers all output
from the default device, not just a single app. Behavior is arm64-only,
consistent with the existing pause feature.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0618a4e76d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/MediaPlaybackService.swift Outdated
…backs

MediaRemoteAdapter can fire the getTrackInfo callback more than once (the
existing resumeOnce one-shot already guards the continuation against this).
The duck side effect, however, ran on every callback before the gate. Since
ducking is not idempotent — it reads the current output volume as the
"original" — a duplicate callback re-ducked the already-lowered volume and
overwrote activeSuppression with the ducked value, so the later restore only
returned to the ducked level instead of the user's original volume.

Route applySuppression() through resumeOnce's one-shot gate so it runs exactly
once, for the winning callback, before the continuation resumes. Pause mode was
unaffected (pause() is idempotent); this only mattered for ducking.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32457dc15b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/SystemAudioVolumeController.swift Outdated
On output devices with no settable master volume, the controller fell back
to per-channel writes while reading volume as the average of the channels.
Ducking then captured that average as the "original" and, on restore, wrote
it back to every channel — permanently flattening a user's non-centered
left/right balance after a dictation session (and it did not self-heal).

Replace the scalar get/set API with an OutputVolumeSnapshot that records each
element (master or individual channels) and its level:
- capture the full per-channel state before ducking,
- duck by scaling every channel by the same factor (preserving balance),
- restore each channel to its captured value.

The snapshot also carries the device id, so restore and the "did the user
change it?" check operate on the device that was actually ducked even if the
default output device changes mid-dictation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d421045fb6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/MediaPlaybackService.swift
TranscriptionSoundPlayer's independent-volume mode also writes the system
output volume and schedules a fire-and-forget async restore. That made it a
second, uncoordinated owner of the same volume the new ducking feature
controls, with two race outcomes:

- the start cue (played just before asr.start()) sets system volume and the
  duck then captures that transient level as the "original"; the cue's late
  restore undoes the duck mid-session;
- the stop cue (played while ducked) saves the ducked level and, if its
  restore fires after resumeIfWePaused, overwrites the final restore and
  leaves the Mac stuck at the ducked level.

When ducking is the active media behavior (pause + lower-volume both on), it
now owns the system volume: the cue plays at its own AVAudioPlayer volume and
no longer hijacks/restores system volume, eliminating the race. Independent
cue volume is unaffected when ducking is off.
…ked device

Two edge-case fixes found in self-review of the ducking path:

- captureOutputVolume() preferred the master element whenever it was *readable*,
  but apply() requires it to be *settable*. On a device with a read-only master
  and settable per-channel volumes, ducking would capture the master, fail to
  apply, and needlessly fall back to pausing. Capture now records only settable
  elements (master if settable, otherwise the settable channels), keeping capture
  and restore symmetric.

- Replaced the post-duck re-capture and the restore-time change check, which
  re-resolved the *default* output device, with reread(snapshot:) that reads the
  snapshot's own device/elements. This keeps the quantization read-back accurate
  and is unaffected if the default output device changes mid-session.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef88004076

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/SystemAudioVolumeController.swift Outdated
Comment thread Sources/Fluid/Services/TranscriptionSoundPlayer.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16d4ebe173

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/MediaPlaybackService.swift
The ducking-precedence guard was keyed on settings alone, so it also forced
independent cue volume off for Settings previews — which never run during a
dictation session and have no ducking conflict to avoid.

Scope the precedence to the actual session start/stop cues via an
enforceDuckingPrecedence flag; previews pass false and always honor the
independent-volume setting. Session cues still defer to ducking, since the
start cue fires before playback state is known.
stop() sets isRunning=false before its final transcription pass and only
reverts media afterwards, so a new dictation can start during that window.
With a single shared activeSuppression, the second session would capture the
already-ducked volume as its "original," the first session's revert would
then clear the newer snapshot, and the Mac could be left stuck at the ducked
level.

pauseIfPlaying() now bails out (returning false, "no new action") when a
suppression is already active, so the in-flight revert from the prior session
remains the sole owner of the system volume and restores the true original.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9169d9f175

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/MediaPlaybackService.swift
@akar016012

Copy link
Copy Markdown
Author

@altic-dev Could you take a look at this PR and approve if you want to go ahead with this feature? Please release a new version so that people can start testing or maybe release a pre-release version so that people can start testing and let me know if there are any issues and I'll work on it right away.

@altic-dev

Copy link
Copy Markdown
Owner

I think there was a stale PR on this. Did you check if it's the same stuff? Would love to merge it once its cleared up and can release as part of next version later :)

Don't think this could be a release by itself ahah.

@akar016012

Copy link
Copy Markdown
Author

I think there was a stale PR on this. Did you check if it's the same stuff? Would love to merge it once its cleared up and can release as part of next version later :)

Don't think this could be a release by itself ahah.

Next release would do just fine, thank you 😊. I can build and run this locally until the next release.

I will look into the stale PR and let you know.

@akar016012

Copy link
Copy Markdown
Author

Comparison: this PR (#459) vs #325

Both PRs implement the same core feature — duck (lower) system volume during transcription instead of fully pausing media — landing on overlapping files.

Overview

#459 (this PR) #325
Title feat(media): add option to duck media volume instead of pausing Music duck and keep-awake behaviour during dictation
Author akar016012 AndrewBeniston
Created 2026-06-28 2026-05-06 (~7 wks earlier)
State Open, MERGEABLE Open, CONFLICTING (needs rebase)
Commits 7 3
Diff size +414 / −21, 6 files +610 / −64, 8 files
Scope Ducking only Ducking + Keep Mac Awake (2 features)

Feature & UX

Dimension #459 (this PR) #325
Settings UI Nested sub-toggle + slider under existing "Pause Media" toggle (keeps the Bool) Replaces the Bool with a 3-mode picker: Leave Playing / Pause / Lower Volume
Duck level Configurable slider, default 20%, clamped 5–100% Hardcoded 10%
Fade Instant set (no ramp) Animated fade down/up over 200ms on a detached Task; new fade cancels in-flight one
Snappiness work Hoists duck to top of beginDictationRecording + synchronous half-step so fade lines up with hotkey/start sound
Extra feature None Keep Mac Awake via IOPMAssertion (prevents display sleep while recording)

Implementation

Dimension #459 (this PR) #325
New CoreAudio file SystemAudioVolumeController.swift (196 ln) SystemVolumeController.swift (186 ln) — nearly identical concept
Other new file SleepPreventionService.swift (68 ln)
Per-channel balance OutputVolumeSnapshot records each element, scales by factor, preserves L/R balance SystemVolumeSnapshot enum (master scalar or distinct L/R)
Trigger point Via MediaPlaybackService apply/restore suppression Threaded through ASRService.start() as preAppliedMediaAction
Restore timing On suppression revert; refuses new suppression while one active Fires the moment audio engine tears down (snaps back on hotkey release, not after transcription)

Persistence / robustness

Dimension #459 (this PR) #325
Backup/restore duckMediaInsteadOfPausing + duckMediaVolumeLevel fully backed up with backward-compat decoding ⚠️ Duck setting does not survive a backup round-trip (deliberate, called out as acceptable)
Legacy migration N/A (keeps existing Bool, adds alongside) Migrates pauseMediaDuringTranscription Bool → MediaBehaviorDuringTranscription enum, keeps Bool as a shim
Edge cases hardened Duplicate getTrackInfo re-duck; cue-volume race vs TranscriptionSoundPlayer; settable-only elements; concurrent sessions; Settings-preview scoping Skips duck if volume already ≤10%; restores on start() failure paths; tap-and-release fade handoff

Bottom line

@akar016012

Copy link
Copy Markdown
Author

@altic-dev Do you want me to fix anything else before you can merge this? I have been testing this last couple of days in my M1 Pro MacBook running MacOS Tahoe 26.5.1 and I really love this feature + it works very good.

@akar016012

Copy link
Copy Markdown
Author

@altic-dev What's the verdict on this? Do you want me to spend time on resolving the conflict?

Resolves the conflict in TranscriptionSoundPlayer.swift by keeping
upstream's queue-based playback refactor and re-applying the ducking
precedence for session start/stop cues at the call sites: the cues skip
independent-volume mode while media ducking owns the system volume.
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a "Lower Volume Instead of Pausing" sub-option to the existing media-pause-during-transcription feature, letting users duck system audio to a configurable fraction of its current level while dictating rather than stopping playback entirely.

  • SystemAudioVolumeController (new): thin CoreAudio wrapper with a three-tier capture strategy (settable master scalar → per-channel stereo scalars → virtual main volume) and per-channel snapshot preservation for L/R balance, directly addressing prior review feedback on device compatibility.
  • MediaPlaybackService: tracks the active suppression action (ActiveSuppression.paused / .ducked) and reverts exactly what it applied; includes in-flight guard against double-suppression and handles user-muted / user-changed-volume / device-disconnected edge cases at restore time.
  • TranscriptionSoundPlayer, SettingsStore, BackupService, and SettingsView: wired up with snapshot-based session precedence (preventing mid-dictation toggle races), backward-compatible backup fields, clamped settings storage, and a properly nested settings UI.

Confidence Score: 5/5

Safe to merge. All previously flagged issues have been addressed and the new code handles the notable edge cases (device disconnection, user volume change mid-dictation, double-suppression window, mid-session settings toggle) correctly.

The three-tier volume capture resolves the device-compatibility gap from prior review. The activeSuppression guard prevents the double-capture race, sessionDuckingPrecedence eliminates the start/stop cue ownership conflict, and the restore path now logs failures explicitly. No logic holes or data-loss paths were found across all six changed files.

No files require special attention.

Reviews (3): Last reviewed commit: "fix(media): log when a duck restore fail..." | Re-trigger Greptile

Comment thread Sources/Fluid/Services/SystemAudioVolumeController.swift
Comment thread Sources/Fluid/Services/TranscriptionSoundPlayer.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7bac0d2a8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/MediaPlaybackService.swift Outdated
… are not settable

On some output devices (certain Bluetooth routes, devices whose volume
lives on non-stereo elements) neither the master nor the per-channel
kAudioDevicePropertyVolumeScalar is settable, so ducking silently fell
back to pausing even though the HAL's virtual main volume — the control
the macOS volume HUD drives — would have worked. Snapshot channels now
record which selector they were captured through, and capture falls back
to kAudioHardwareServiceDeviceProperty_VirtualMainVolume as a last
resort. The virtual control preserves device balance itself, so a
single-element snapshot suffices on that tier.
duckingOwnsSystemVolume read live settings at each cue, so toggling
"Lower Volume Instead of Pausing" off mid-dictation made the stop cue
re-enable its independent-volume system-volume override while the duck's
revert still owned the volume — the two async restores raced and the
loser's level stuck. The start cue now captures the precedence decision
for the session and the stop cue reuses it, keeping both cues consistent
with what MediaPlaybackService actually applied. If no start cue played,
the stop cue falls back to the live read (previous behavior).
The restore-time "did the user change the volume" check used a flat
0.02 tolerance, but a deeply ducked level (e.g. 20% of an original at
10% or less) sits within 0.02 of zero — so muting during dictation was
classified as unchanged and the restore turned the user's audio back
on. Mute (current ~0 while the applied duck was nonzero) is now checked
explicitly and always leaves the volume as the user set it.
Comment thread Sources/Fluid/Services/MediaPlaybackService.swift
The restore path discarded apply()'s result, so a failure (e.g. the
ducked output device was disconnected mid-dictation) left the volume
at the ducked level with no trace in the logs. The failure is now
logged as a warning. No behavioral fallback: pausing at restore time
would stop the user's media instead of restoring anything, and a
disconnected device cannot be written to — macOS re-applies its own
per-device volume memory on reconnect.
@akar016012

Copy link
Copy Markdown
Author

@altic-dev What's the verdict on this? Do you want me to spend time on resolving the conflict?

I did it anyway :) please check.

@altic-dev

altic-dev commented Jul 11, 2026 via email

Copy link
Copy Markdown
Owner

@akar016012

Copy link
Copy Markdown
Author

Hey man, sorry for the delay. Working on several things now and asked Rohith to take care of PRs Overall looks like a good feature and alignment. Did you take care of the review bots? Added greptile recently as well so more things to help and worry bout haha.

On Sat, Jul 11, 2026 at 12:46 PM Aditya Kar @.> wrote: akar016012 left a comment (altic-dev/FluidVoice#459) <#459 (comment)> @altic-dev https://github.com/altic-dev What's the verdict on this? Do you want me to spend time on resolving the conflict? I did it anyway :) please check. — Reply to this email directly, view it on GitHub <#459?email_source=notifications&email_token=BVSOW2RY6G25N32QAENU6MD5EKKRHA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTIOJUHA2TEMZWGMZ2M4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-4948523633>, or unsubscribe https://github.com/notifications/unsubscribe-auth/BVSOW2X7AZYETXGBNQZS6C35EKKRHAVCNFSNUABGKJSXA33TNF2G64TZHMYTANRRGMZDOMZRGE5US43TOVSTWNBXGYZDQNJQHA2DNILWAI . You are receiving this because you were mentioned.Message ID: @.>

No worries at all. Yes, I took care of the findings 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants