Skip to content

Add adjustable audio ducking during recording (closes #683) - #684

Open
dhryniewicz wants to merge 5 commits into
Beingpax:mainfrom
dhryniewicz:feature/audio-ducking
Open

Add adjustable audio ducking during recording (closes #683)#684
dhryniewicz wants to merge 5 commits into
Beingpax:mainfrom
dhryniewicz:feature/audio-ducking

Conversation

@dhryniewicz

@dhryniewicz dhryniewicz commented May 4, 2026

Copy link
Copy Markdown

Summary

Implements the feature requested in #683: a slider for adjustable volume reduction (ducking) during recording, instead of only binary mute/unmute. Users who listen to background music or video while dictating can now keep audio audible at a reduced level rather than silencing it completely.

Behavior

  • New setting Ducking Amount (0–100%, step 5) lives inside the existing Mute Audio While Recording expandable row.
  • Default is 100%, which preserves the previous full-mute behavior — no migration needed for existing users.
  • 100% uses the original kAudioDevicePropertyMute path, so the macOS volume slider keeps its visual position (just shows the muted icon), and existing user-mute detection still applies.
  • <100% captures the current output volume, reduces it via kAudioDevicePropertyVolumeScalar to currentVolume × (100 − percent) / 100, and restores the original volume on stop (respecting the existing audioResumptionDelay).
  • 0% is treated as a no-op while the toggle is on.

Implementation

  • MediaController.swift: added audioDuckingPercent (UserDefaults-backed), a DuckingMode enum to track which path was used (mute vs. volume scalar) for correct restore, and getSystemVolume() / setSystemVolume() helpers mirroring the existing CoreAudio mute helpers.
  • SettingsView.swift: added a Slider + percentage label + InfoTip inside the existing Mute row.
  • AppDefaults.swift: registered audioDuckingPercent: 100 so existing users continue to get full mute.

The unmuteSystemAudio() cancellation/generation logic is preserved; volume restore is gated by the same muteGeneration check so quickly-retriggered recordings don't race.

Test plan

  • make local — builds cleanly with no new warnings
  • Manual: 100% (default) — system audio fully mutes during recording, restores after (no regression vs. main)
  • Manual: 50% — music continues at reduced volume during recording, restores to original level after stop
  • Manual: 0% — no audio change while toggle is on
  • Manual: with audioResumptionDelay > 0, volume restore is correctly delayed
image

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="VoiceInk/MediaController.swift">

<violation number="1" location="VoiceInk/MediaController.swift:51">
P2: `activeDuckingMode` can stay stale across a mute cycle, so a later stop may restore an old saved volume even when the current recording never ducked audio.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread VoiceInk/MediaController.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="VoiceInk/MediaController.swift">

<violation number="1" location="VoiceInk/MediaController.swift:44">
P1: Clearing `activeDuckingMode` before a new ducking action succeeds can discard the only restore state after canceling the previous pending unmute, leaving system audio stuck in the prior muted/ducked state.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread VoiceInk/MediaController.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="VoiceInk/MediaController.swift">

<violation number="1" location="VoiceInk/MediaController.swift:109">
P2: Clears ducking ownership/state even if the immediate restore fails, so a cancelled prior cycle can leave audio modified with no saved baseline for the next cycle.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Comment thread VoiceInk/MediaController.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="VoiceInk/MediaController.swift">

<violation number="1" location="VoiceInk/MediaController.swift:102">
P1: Overwriting an unresolved `.mute` restore with `.volume` loses the fact that the device still needs an unmute, which can leave system audio muted after recording stops.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Comment thread VoiceInk/MediaController.swift
@dhryniewicz
dhryniewicz force-pushed the feature/audio-ducking branch from e3afee3 to c9630a6 Compare May 14, 2026 08:30
dhryniewicz and others added 5 commits May 28, 2026 13:11
Replace the binary mute toggle with a configurable ducking percentage
(0-100%, default 100% to preserve existing behavior). At 100% the system
audio is fully muted via kAudioDevicePropertyMute as before; below 100%
the current output volume is reduced via kAudioDevicePropertyVolumeScalar
and restored when recording ends.

Closes Beingpax#683

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a pending unmute task was cancelled by a new recording, the previous
mode (e.g. .volume(savedVolume)) lingered. If the new cycle then took a
no-op path (ducking percent 0, or applyVolumeDucking failure), the stop
would incorrectly restore an old saved volume from the previous
recording. Reset the mode up front so only a successful path sets it.

Identified by cubic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix cleared activeDuckingMode at the start of muteSystemAudio,
but if the prior cycle's unmute task had been cancelled mid-delay the
audio was still in a ducked/muted state — and clearing the mode threw
away the only handle on the saved volume. A no-op new cycle (0% or
failure) would then leave audio stuck.

Restore synchronously instead: when starting a new cycle, drive the
prior cycle's restore to completion immediately (unmute or set volume
back to savedVolume), then clear the mode. This fixes both the original
stale-mode case and the new stuck-audio regression, and as a side benefit
the new cycle's applyVolumeDucking now reads the user's true volume
rather than a still-ducked value.

Identified by cubic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If setSystemMuted/setSystemVolume returned false from the synchronous
restore path, the code still cleared activeDuckingMode and didMuteAudio.
That left audio in a modified state (still ducked or still muted) with
no record of how to undo it: a subsequent stop would skip the restore,
and the next ducking cycle would anchor its baseline on the already-
ducked volume, eventually drifting the user's true volume to zero.

Bail early on failure so the prior cycle's mode survives. Then teach
applyVolumeDucking to reuse savedVolume from a still-active prior mode
as its baseline rather than reading the current (still-ducked) volume.

Identified by cubic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If applyPendingRestoreImmediately failed to undo a prior .mute (or
.volume), the new cycle could pick the other type and overwrite the
mode, losing the prior restore information. Concretely: an unresolved
.mute overwritten with .volume(savedVolume) would unmute-skip on stop
and leave audio silent; the symmetric .volume → .mute path would unmute
on stop but trap the device at the ducked level instead of restoring
the user's saved volume.

Bail out of applyFullMute when an unresolved .volume is active, and out
of applyVolumeDucking when an unresolved .mute is active. The recording
proceeds without applying ducking for that cycle; the prior mode
survives and unmuteSystemAudio retries the original restore on stop.

Identified by cubic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant