Skip to content

fix(sound): play cues in full stereo from the first sample - #147

Merged
Orinks merged 1 commit into
Orinks:devfrom
Nick6489:fix/stereo-cue-mono-playback
Sep 2, 2026
Merged

fix(sound): play cues in full stereo from the first sample#147
Orinks merged 1 commit into
Orinks:devfrom
Nick6489:fix/stereo-cue-mono-playback

Conversation

@Nick6489

@Nick6489 Nick6489 commented Sep 2, 2026

Copy link
Copy Markdown

Problem

Stereo sound cues played in mono, most audibly the connect sound.

Every cue was played through a rodio Sink. Sink wraps its playback queue in a
channel converter the instant it is built, while that queue is still empty, and an
empty rodio queue reports one channel (its Empty placeholder source). The
converter 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 default
connect_success.ogg: 512 leading output frames with L == R exactly, versus 0
for the bare decoder.

Fix

Decode each cue fully into a SamplesBuffer and hand it straight to
OutputStreamHandle::play_raw instead of going through a Sink.

  • A SamplesBuffer carries its real channel count from the first sample, so
    nothing is folded. The reproduction now shows 0 mono frames and
    stereo-difference energy identical to decoding the file directly.
  • A non-48 kHz output device now resamples the whole buffer in one correctly
    configured pass, instead of the old path's per-packet re-bootstrapping.
  • Sound lifetime for the exit wait is tracked with an AtomicUsize that rodio's
    Done decrements when a cue ends, replacing the Vec<Sink> + sink.empty()
    poll. Volume is applied by scaling samples during decode.

No public API change: play_sound_file, wait_for_playback, SoundPlayer, and
EXIT_SOUND_TIMEOUT keep their signatures.

Tests

decode_to_buffer is split out as a device-free helper so the pipeline is testable
without an audio device:

  • a_stereo_sound_is_buffered_without_being_folded_to_mono -- the regression
    guard: the decoded buffer still reports 2 channels and keeps distinct L/R
    samples.
  • volume_scales_the_buffered_samples
  • a_zero_length_sound_is_not_played

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings,
and cargo test --workspace all pass.

🤖 Generated with Claude Code

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
Orinks merged commit 4f15605 into Orinks:dev Sep 2, 2026
6 checks passed
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
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