Repository navigation
fix(bsp): audio tasks wait for a whole block instead of padding with silence (all 10 BSPs) - #845
Merged
Merged
Conversation
…lence The I2S feed task took whatever was queued up to one block each time it woke and zero-filled the rest. The block is sized for the default 48 kHz (sample_rate * 4 / 60 = 3200 bytes), which is 30 ms of audio at the rates emulators use (e.g. 26.6 kHz for a Genesis), and producers push one video frame of audio (~16.7 ms) at a time with frame-to-frame jitter -- so any moment the stream held less than a block turned into up to a block of injected silence, audible as stutter whenever the producer's cadence was uneven (esp-box-emu, Sonic 3 special stages: measured ~1000 padded blocks per session). Create the stream buffer with a trigger level of one block and receive with a bounded timeout, so the task waits for a whole block while audio is flowing; after a timeout (producer idle: menu, pause) write what was received, or a block of silence when there is nothing, which keeps the DMA fed. Partial writes are concatenated by the I2S driver without gaps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
|
✅Static analysis result - no issues found! ✅ |
Contributor
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
2 open findings
What changed in this PR
Adjust ESP-BOX audio consumption to reduce audible stutter by waiting for full audio blocks instead of padding underfilled blocks with silence, while still keeping I2S fed during idle periods.
Changes:
- Set the FreeRTOS stream buffer trigger level to one block so the consumer typically waits for a full block.
- Switch the audio task to bounded receive (40 ms) and write only what was received (or silence on timeout/no data).
- Enforce whole 16-bit stereo frame alignment when writing to I2S.
| File | Description |
|---|---|
| components/esp-box/src/audio.cpp | Updates stream buffer configuration and audio task receive/write logic to avoid padding-induced silence and stutter. |
🧠 Review effort: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…th silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
… of padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
… padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…ith silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…padding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…ng with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…th silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…adding with silence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…ence Same change as esp-box: the I2S feed task took whatever was queued up to one block and zero-filled the rest, injecting up to a block of silence whenever a producer's cadence left the stream momentarily below one block. Create the stream buffer with a trigger level of one block and receive with a bounded timeout; write what was received after a timeout, or silence when idle. Board-specific handling (sample alignment, write timeouts, the reconfig mutex, volume/mute scaling) is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…e receive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…eceive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…eout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…ceive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
… timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…eive timeout Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Review follow-up. The block (and so the stream's trigger level) is now trimmed to a whole number of frames at init, using the board's own channel/byte constants, so a receive can never split a frame. The callback no longer silently discards a trailing partial frame; it writes what it received and logs a rate-limited warning if the count is ever misaligned (which the aligned block makes impossible). The receive timeout is a named constant with its rationale instead of a bare 40. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
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
Every BSP with audio output drains its TX stream buffer the same way (the code was copied from esp-box): each time the audio task wakes it takes whatever is queued, up to one block, zero-fills the rest, and writes a full block to I2S. The block is sized from the default sample rate (
sample_rate × channels × 2 / 60), so at the rates emulators and games actually run at (26.6 kHz for a Genesis, for instance) one block is 30 ms of audio. Producers push one video frame of audio (~16.7 ms) at a time with frame-to-frame jitter, so any moment the stream momentarily holds less than one block becomes up to a block of injected silence — audible as stutter whenever the producer's cadence is uneven.Measured on an ESP32-S3-BOX-3 running esp-box-emu (Sonic 3's special stages, emulator at a locked 60 fps): ~1000 padded blocks per play session with the current code, zero with this change, and the audio is clean by ear.
The comments in several boards argue the opposite — that writing only the available bytes "interleaves whole frames of silence into bursty streams". That observation was correct for the other naive option (write
availableimmediately); this change keeps the DMA fed with whole blocks at a constant cadence while audio is flowing. The difference is when a partial block is allowed: only after a bounded wait, i.e. when the producer is idle or genuinely slower than real time, never merely because the producer's next frame hadn't arrived yet.Change (all ten BSPs: esp-box, esp32-p4-eth, esp32-p4-function-ev-board, esp32-p4-module-dev-kit, esp32-p4-nano, esp32-p4-wifi6-dev-kit, m5stack-cardputer, m5stack-tab5, smartpanlee-sc01-plus, t-deck)
& ~1) on the boards that use it and whole stereo frames (% 4) on the others; the 100 ms write timeouts and short-write logging on the P4 kits; on esp32-p4-module-dev-kit / esp32-p4-wifi6-dev-kit the wait happens beforeaudio_tx_mutex_is taken so a sample-rate change is never held off by it; cardputer / sc01-plus / t-deck apply their software volume and mute to the bytes actually received.Hardware-verified on esp-box (ESP32-S3-BOX-3). The other nine are the same mechanism applied to the same construct; please review them with that in mind — I could not run them.
Related
esp-cpp/esp-box-emu#129 (the consumer side was verified there with a local edit of the managed component; that PR also moves the application's audio task above its emulator tasks so it is never starved — the second half of the fix, which is an application-side
Task::BaseConfigdecision).Review follow-up
AUDIO_TX_RECEIVE_TIMEOUT_MS) with its rationale.🤖 Generated with Claude Code
https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1