Skip to content

fix(audio): play transcription cues with app-local gain - #867

Open
postoso wants to merge 1 commit into
altic-dev:mainfrom
postoso:fix/522-app-local-cue-gain
Open

fix(audio): play transcription cues with app-local gain#867
postoso wants to merge 1 commit into
altic-dev:mainfrom
postoso:fix/522-app-local-cue-gain

Conversation

@postoso

@postoso postoso commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Description

With Independent Volume enabled, TranscriptionSoundPlayer read the Mac output volume, wrote it to the selected cue level, played the cue at full player gain, and restored the output on a timer. Writing the output volume is globally audible, and that is what #522 reports: with YouTube playing in a tab, the reporter hears a few milliseconds of raised output before it settles.

The overlap behaviour made it worse. Both cues share one savedSystemVolume slot, so a cue that starts while another is still playing reads the output while it is pinned at the first cue's level and saves that as though it were the user's setting. Depending on which cue finishes first, the output is either stranded at a cue level permanently or snapped back to the pre-cue level while an earlier cue is still audible.

This stops writing the output volume at all. When the output scalar is readable and above the mute threshold, the player volume becomes min(1, desired / output), so the audible result is min(output, desired) and the current output stays the ceiling. At mute or near zero the cue is suppressed rather than raising anything. If the scalar cannot be read the cue falls back to the plain selected level, which is also what ordinary mode does.

savedSystemVolume, the delayed restoration, and setSystemVolume are removed rather than patched, so no code path is left that can write the output volume. Cached players and the overlap behaviour of the audio itself are unchanged.

The tradeoff, and I think it is the right one: the cue can no longer be louder than the current output. If you keep system audio low and relied on an independently louder cue, the cue is now quieter. I would rather state that limitation than deliver it by moving everyone's volume.

One thing I deliberately left out. The Independent Volume setting copy still describes the old behaviour, and its footnote ("Temporarily changes system volume during playback") is now false. I have that correction ready but kept it out so this PR stays on one surface. Happy to fold it in here if you would rather have it in one piece.

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Closes #522. This is the app local approach I sketched in that thread.

Testing

  • Tested on Apple Silicon Mac
  • Tested on macOS version: 15.7.7
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran tests locally: xcodebuild test -project Fluid.xcodeproj -scheme Fluid -destination 'platform=macOS,arch=arm64'
  • Tested on Intel Mac
  • Ran formatter locally: swiftformat --config .swiftformat Sources

I ran the linter in the form CI runs it, swiftlint lint --strict --config .swiftlint.yml over the whole repository rather than just Sources. Clean, 0 violations across 154 files.

Eight new tests cover the gain policy as a pure function: ordinary mode, compensation, the 1.0 cap, the exact mute threshold and just above it, an unreadable scalar, out of range and NaN inputs, order independence across two overlapping cues, and a grid assertion that the audible level equals min(output, desired) and never exceeds the pre cue output. All eight pass.

Worth being precise about what those tests do not cover: they exercise the resolver, not production playback. They would not catch a save and restore path being reintroduced around player.play() later. What rules that out today is that setSystemVolume no longer exists to call.

One thing to flag about the local run. The full xcodebuild test exits 65 on my machine, and it does the same on main without this change. Neither run has a failing assertion. The cause is a double free during test host teardown, pointer being freed was not allocated, which crashes and restarts the runner. I ran the same command against main to check: 27 crash and restart cycles in both, the same tests in the same order, the same address every time. So it is pre existing and unrelated, but you will see that exit code if you run the suite locally. It shows up in DictationE2ETests.testAutomaticDictionarySuggestion* and across HotkeyShortcutTests, on macOS 15.7.7 with Xcode 26.3. Happy to open a separate issue for it if it is not already tracked.

Screenshots / Video

  • No UI/visual changes; screenshots/video are not applicable.

Notes

The capability that goes away is worth naming: previously, when system output was below the selected cue level, the app raised the output so the cue always hit that level. It cannot do that now without affecting other audio, which was the bug. At output at or above the selected level the effective cue loudness is unchanged.

getSystemVolume returned 1.0 on failure, which made a read error indistinguishable from full volume. It now returns Float? as currentOutputVolume, which is what lets the unreadable case fall back deliberately rather than by accident.

Independent Volume wrote the Mac output volume before each cue and
restored it on a timer afterwards. That is audible in every other
application, which is the spike reported in altic-dev#522, and overlapping cues
could restore the wrong level or strand the output at a cue level.

Compensate the player toward the selected level instead. When the output
scalar is readable and above the mute threshold the player volume becomes
min(1, desired / output), so the audible result is min(output, desired)
and the current output stays the ceiling. Cues are suppressed at mute or
near-zero output rather than raising anything, and an unreadable scalar
falls back to the ordinary selected level.

The saved-volume state, the delayed restoration, and setSystemVolume are
removed outright, so no code path can write the output volume. Cached
players and overlap behaviour are unchanged.
@greptile-apps

greptile-apps Bot commented Aug 16, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces temporary system-output-volume writes with app-local transcription-cue gain, preventing cues from changing other applications’ audio.

  • Adds a pure gain policy that compensates against readable output volume, suppresses cues near mute, and falls back to the selected level when output volume is unavailable.
  • Removes shared saved-volume state, delayed restoration, and the system-volume setter.
  • Adds policy tests covering ordinary and independent modes, clamping, mute handling, overlap order, and audible-level limits.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete changed-code defect identified.

The new policy keeps system output read-only, applies bounded gain to each cached player on the existing serial playback queue, and covers the intended mute, fallback, overlap, and clamping behavior with focused tests.

Reviews (1): Last reviewed commit: "fix(audio): play cues with app-local gai..." | Re-trigger Greptile

@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: 40dd7c5e56

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +49 to +51
// The cap is what keeps the audible result at or below the current
// output: `output * min(1, desired / output)` is `min(output, desired)`.
return .play(playerVolume: min(1, desired / output))

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 Convert device volume through its dB curve

On output devices whose CoreAudio scalar-to-decibel mapping is nonlinear, dividing desired by the hardware volume scalar does not produce the gain needed to reach the desired audible level: the hardware scalar and AVAudioPlayer.volume are different gain domains. For example, an output scalar of 0.8 and desired scalar of 0.4 need not correspond to a player gain of 0.5, so Independent Volume can play substantially above or below the selected level. Convert the device scalars to decibels using its CoreAudio conversion property, then derive the player gain from the dB difference.

Useful? React with 👍 / 👎.

Comment on lines +16 to +18
/// Independent Volume compensates the player toward the selected level instead
/// of moving the Mac output volume, so the current output stays the ceiling and
/// other audio is never touched.

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 Update the Independent Volume setting copy

When Independent Volume is enabled and system output is below the selected cue level, this new ceiling deliberately makes the cue quieter, but SettingsView.swift:375-377 still promises that the sound stays constant regardless of system volume and warns that playback temporarily changes system volume. Users therefore see a description that is now false in both directions; update that copy alongside this behavioral change.

Useful? React with 👍 / 👎.

@postoso

postoso commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

CI note: the Build FluidVoice failure is DirectAudioReliabilityTests.testReadinessGateRearmingCancelsExistingWaiter (264 executed, 1 failure). This PR does not touch the readiness gate or waiter machinery - the diff is confined to TranscriptionSoundPlayer.swift and its tests, and the full set including the 8 focused sound-player tests passes locally. Looks like an unrelated timing flake; happy to rebase or push a retrigger if you'd like a clean run.

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.

Independent volume selection causes temporary system volume spike

1 participant