fix(sound): play cues in full stereo from the first sample - #147
Merged
Conversation
Every cue was played through a rodio `Sink`. `Sink` wraps its playback queue in a channel converter the moment it is built, while the queue is still empty, and an empty queue reports one channel. The first ~11 ms of whatever played next was folded to mono and slightly time-stretched before the converter re-read the real channel count. The default cues are short and their stereo image sits in the opening transient, so the connect and disconnect sounds came out sounding mono. Decode each cue fully into a `SamplesBuffer` and hand it straight to `play_raw` instead. A `SamplesBuffer` carries its real channel count from the first sample, so nothing is folded, and a non-48 kHz device now resamples the whole buffer in one correctly configured pass. The exit wait now tracks an `AtomicUsize` that rodio's `Done` decrements when a cue ends, replacing the `Vec<Sink>` poll. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARKC56K8mFfAPPc98JPfCP
Orinks
added a commit
that referenced
this pull request
Sep 2, 2026
feat(sound): loop a "waiting to connect" cue during SSH agent approval Resolved against #147: the looping path now decodes through decode_to_buffer instead of its own copy of the same code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012D47k3ztaJPbaxwNMRYe1C
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Stereo sound cues played in mono, most audibly the connect sound.
Every cue was played through a rodio
Sink.Sinkwraps its playback queue in achannel converter the instant it is built, while that queue is still empty, and an
empty rodio queue reports one channel (its
Emptyplaceholder source). Theconverter is therefore locked to mono to stereo for its first batch: the opening
512 samples (256 frames, ~11 ms) of whatever plays next get read as mono, with
left and right interleaved into one stream and each sample duplicated to both
speakers, which also time-stretches that slice. Only after that batch does the
converter re-read the source and discover it is actually stereo.
The default cues are short and their stereo image sits in the opening transient, so
connect/disconnect came out sounding mono while longer, noisier cues masked it.
Reproduced by driving the real
Sink-> queue -> mixer wiring with the defaultconnect_success.ogg: 512 leading output frames with L == R exactly, versus 0for the bare decoder.
Fix
Decode each cue fully into a
SamplesBufferand hand it straight toOutputStreamHandle::play_rawinstead of going through aSink.SamplesBuffercarries its real channel count from the first sample, sonothing is folded. The reproduction now shows 0 mono frames and
stereo-difference energy identical to decoding the file directly.
configured pass, instead of the old path's per-packet re-bootstrapping.
AtomicUsizethat rodio'sDonedecrements when a cue ends, replacing theVec<Sink>+sink.empty()poll. Volume is applied by scaling samples during decode.
No public API change:
play_sound_file,wait_for_playback,SoundPlayer, andEXIT_SOUND_TIMEOUTkeep their signatures.Tests
decode_to_bufferis split out as a device-free helper so the pipeline is testablewithout an audio device:
a_stereo_sound_is_buffered_without_being_folded_to_mono-- the regressionguard: the decoded buffer still reports 2 channels and keeps distinct L/R
samples.
volume_scales_the_buffered_samplesa_zero_length_sound_is_not_playedcargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,and
cargo test --workspaceall pass.🤖 Generated with Claude Code