Skip to content

fix(voice): stop the recorder writing over audio still in the resampler - #7079

Open
longcw wants to merge 2 commits into
mainfrom
longc/recorder-input-settled
Open

fix(voice): stop the recorder writing over audio still in the resampler#7079
longcw wants to merge 2 commits into
mainfrom
longc/recorder-input-settled

Conversation

@longcw

@longcw longcw commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem: The recorder writes each block up to _input_settled, which is the arrival time of the frame. A resampler emits only whole output frames, so the tail of the audio is still inside it, and the writer puts silence in that place. take() discards those samples later, because their place is behind the cursor.

Fix: _Track reports placed_through, the point the open run reaches, or None when the resampler holds nothing back. The encode thread keeps each write behind that point.

Context for reviewing and coding agents

How to see it

Three cases in tests/test_recorder_io.py are new and fail on main. test_the_writer_waits_for_samples_the_resampler_still_holds shows the loss directly: it takes a block up to the arrival time and asserts no zero samples inside it.

Blast radius

placed_through has one consumer, the cursor loop in _encode_thread. The end_run() added there is the third call in the file and sits beside the one from #7047 — that one ends a run at playback end, this one ends it when a source stops delivering mid-run. Nothing outside recorder_io.py reads either.

Why the shortfall is computed in _Track and not in the resampler

_Track already holds _run_start, _t0, _sample_rate and _run_samples, so the point the run reaches is derivable from state it keeps. No resampler API change is needed, and a track without a resampler returns None and never holds the writer.

What this does not change

A 48 kHz input has no resampler and takes the None path throughout. The two channels still share one cursor, so this only moves where that cursor may stop, never how the channels align.

The writer flushes each block up to _input_settled, which comes from frame
arrival, but the default 24kHz input passes through a resampler into a
48kHz recording and a resampler emits only whole output frames. The writer
puts silence where the last 24ms belongs, and take() then discards those
samples because their place is behind the cursor.

_Track now reports placed_through, and the encode thread keeps each write
behind it. The clamp stays no more than MAX_RESAMPLER_LAG behind so that a
stalled source cannot pin the cursor the two channels share.
@longcw
longcw requested a review from a team as a code owner September 1, 2026 07:57
devin-ai-integration[bot]

This comment was marked as resolved.

The clamp let the cursor move on once a source went quiet for longer than
MAX_RESAMPLER_LAG, which left the samples still inside its resampler behind
the cursor. end_run() flushed them at their own place, and take() then
discarded them.

The writer now ends the run of a source that stopped delivering, so its tail
is placed while the cursor can still reach it.
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