Skip to content

WAVStream: opt-in warm output so back-to-back clips don't click (ES8311) - #301

Open
bitcoin3us wants to merge 5 commits into
MicroPythonOS:mainfrom
bitcoin3us:fix/wav-warm-output-click-pr
Open

bitcoin3us wants to merge 5 commits into
MicroPythonOS:mainfrom
bitcoin3us:fix/wav-warm-output-click-pr

Conversation

@bitcoin3us

Copy link
Copy Markdown
Contributor

Problem

On ES8311 boards (Waveshare ESP32-S3-Touch-LCD-3.5) every WAV clip started with an audible click, which makes a soundboard-style app unusable. It is not the files (they start at zero) and it is not fixable by reordering the codec unmute around each play (tried: unmute after I2S init plus a silence prime; still clicked). The cause is structural: WAVStream re-created the MCLK PWM and machine.I2S for every clip and in its finally block deinit'd both and re-muted the DAC, so every press produced clock and mute transitions that the codec turns into a click.

Fix

A warm output, opt-in per board via AudioManager.Output(warm_ms=N):

  • After a clip ends, the MCLK/I2S keep running and the codec stays unmuted for N ms. The next clip with the same rate, format and pins reuses them: no clock restart, no re-unmute, nothing to click.
  • A one-shot machine.Timer(-1) releases the output after N ms idle (on_close, then deinit); the timer is cancelled while a clip plays and re-armed when it finishes. WAVStream.release_warm() releases it explicitly (an app can call it when leaving), and the I2S recorder releases it before taking the peripheral.
  • For warm outputs the codec is unmuted only after the fresh clocks have been primed with silence, so the one remaining transition (first clip after idle) happens over silence.
  • warm_ms=0, the default, keeps the existing behaviour byte-for-byte, including on_open running before machine.I2S() binds the pins, which boards that mux pins between SD and I2S in those hooks (SQUiXL) rely on. Only boards that opt in see any change.
  • Enabled at 30 s on the Waveshare ESP32-S3-Touch-LCD-3.5 speaker output.

Verification

  • On the 3.5" board, by ear: the click on every press is gone; after the first clip, presses are silent. (A precursor of this change without the opt-in and idle release has been running frozen in that board's firmware for a week without issues.)
  • tests/test_stream_wav.py (30 tests, 7 new: release order, no-op release, release despite on_close failure, idle timer skipping while a clip plays and releasing when idle, one-shot timer period, warm_ms default) and tests/test_audiomanager.py (32 tests, 2 new) pass on the desktop build.

Notes

🤖 Generated with Claude Code

With thanks to the scientists and engineers who did the hard, unglamorous work that got us here.

bitcoin3us and others added 2 commits September 16, 2026 14:06
On ES8311 boards every clip started with an audible click. Not the
files, and not fixable by reordering the codec unmute around each play:
WAVStream re-created the MCLK PWM and machine.I2S for every clip and
deinit'd both plus re-muted the DAC in its finally block, so every
press had clock and mute transitions the codec turns into a click.

Add a warm output, opt-in per board via AudioManager.Output(warm_ms=N):
after a clip ends the MCLK/I2S keep running and the codec stays unmuted
for N ms, and the next clip with the same rate/format/pins reuses them
with no restart and no re-unmute. A one-shot machine.Timer(-1) releases
the output (on_close, then deinit) after N ms idle; WAVStream.
release_warm() releases it explicitly, and the I2S recorder releases it
before taking the peripheral. For warm outputs the codec is unmuted only
after the fresh clocks have been primed with silence.

warm_ms=0 (the default) keeps the previous behaviour exactly, including
on_open running before machine.I2S() binds the pins, which boards that
mux pins between SD and I2S in those hooks (SQUiXL) rely on. Enabled at
30 s on the Waveshare ESP32-S3-Touch-LCD-3.5 speaker; confirmed by ear
on that board that presses after the first are silent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
machine.Timer(-1) is not available on the ESP32 port (ValueError), so
the idle release never fired there and a warm output stayed warm until
released explicitly. Try the virtual timer first and fall back to
hardware timer 3 (the LVGL task handler owns 0), and recreate the timer
object on every arm: a deinit'd ESP32 hardware timer is not reliably
re-armed by init(), which broke the cancel/re-arm cycle between
back-to-back clips.

Verified on the Waveshare ESP32-S3-Touch-LCD-3.5: second clip reuses
the warm clocks, the timer re-arms, the output is released 29.3 s after
the last clip, and the next clip re-warms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bitcoin3us

Copy link
Copy Markdown
Contributor Author

CI note: the one red job is the macOS unit-test runner failing tests/test_graphical_simulate_long_press.py::test_short_click_does_not_fire_long_pressed (a timing-sensitive input test; the same test failed on main in run 35064389249). Both audio test modules touched by this PR pass on all three runners, and the PR changes nothing in input or LVGL timing. I can't re-run the job from a fork; a maintainer re-run should go green.

@ThomasFarstrike

Copy link
Copy Markdown
Contributor

CI note: the one red job is the macOS unit-test runner failing tests/test_graphical_simulate_long_press.py::test_short_click_does_not_fire_long_pressed

Indeed, that test is flaky on the (slow?) CI macOS machine(s). I've committed a fix attempt.

@bitcoin3us

Copy link
Copy Markdown
Contributor Author

Thanks — brought the branch up to date with main (merge commit beb688f; only CHANGELOG.md needed resolving, both sides kept) so it now carries your simulate_long_press deflake commits and CI reruns against them.

@ThomasFarstrike

ThomasFarstrike commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Since it adds complexity, I'm a bit hesitant to merge as-is.

As I understand it, it doesn't really solve the problem, as there will still be an audible click at the start of the first clip, as well as when the user waits more than 30 seconds to play the next clip.

Many of these audio DAC have a section in the datasheet with something like "click-free start" or "glitch-free startup" which describes how to start playing audio without clicks. I haven't studied the ES8311 but perhaps that could offer an even better solution?

BTW, I also fixed another test failure so feel free to re-sync this branch :-)

@bitcoin3us

Copy link
Copy Markdown
Contributor Author

Fair pushback — you're right that it's a mitigation, not a cure: the first clip after boot and the first clip after the 30 s idle still get the transition. (Branch re-synced with main again; only CHANGELOG conflicted.)

On the datasheet route: I looked at what our driver actually does around a clip versus what the ES8311 documents as the pop-free sequence, and there's a real gap.

  • Today on_open/on_close only toggle the REG31 soft-mute (bits 6:5), and the DAC stays powered (REG12 = 0x00) the whole time — including while WAVStream stops and restarts MCLK/BCLK/LRCK for every clip. Stopping the clocks with the DAC path powered is exactly the case the datasheet warns produces pops; the soft-mute only ramps the digital signal, it does nothing about the analog stage seeing its clocks vanish and reappear.
  • The documented click-free sequence is: on stop → soft-mute → power the DAC/analog output down (REG12 DAC power-down, REG13/REG0D output + analog off) → then stop the clocks; on start → clocks first → power the DAC/analog up → un-mute with the DAC ramp (REG37 DAC_RAMPRATE bits[7:4], which we currently leave at 0 = no ramp; only EQ-bypass 0x08 is set). Espressif's esp_codec_dev es8311 driver also does nothing beyond the REG31 mute, so mirroring it wouldn't have found this.

So I think you're right that there's a better fix: implement the power-sequenced open/close in the ES8311 driver (dac_power(on) + ramp rate), have the board hooks use it, and keep WAVStream unchanged. If that removes the click on the bench, it's strictly simpler than the warm output (no timer, no I2S peripheral held, no first-clip residual); the cost is a few tens of ms of codec power-up + ramp latency at each clip start, which is fine for a soundboard.

Proposal: park this PR, I test the datasheet sequence on the 3.5 by ear, and report back with numbers/recording. If it works, I'll rework this into that (much smaller) change; if the click survives the proper sequence, that's evidence the transition is in the I2S peripheral start itself and the warm output is the pragmatic answer. Sound OK?

@bitcoin3us

Copy link
Copy Markdown
Contributor Author

Bench results (Waveshare 3.5 with its speaker, ES8311; loud test clip = 150 ms digital silence then a 2 s near-full-scale 660/1320 Hz tone so any click is the hardware transition, not the file; volume 100; codec registers sampled mid-tone to confirm DAC powered + un-muted on every play; judged by ear):

set what changes between clips result
A — today's driver, warm output off MCLK/I2S stopped and restarted per clip, DAC stays powered, REG31 soft-mute only click on every tone
B — datasheet sequence, warm output off mute → DAC + HP driver powered down (REG12/REG13) before the clocks stop; clocks → power-up → ramped un-mute (REG37 ramp rate 4) click on every tone; the one after a 6 s idle was the mildest
C — this PR's warm output (30 s) clocks and codec kept running between clips; un-mute after silence-primed clocks all four clean, including the first

So the datasheet's click-free start doesn't help here: the pop isn't in the codec's mute/power path, it's in the MCLK/I2S start itself (or the amp reacting to it), and only clock continuity removes it. Set C also answers the residual-click worry: with the silence-primed un-mute the first clip after a release was clean too, so the remaining cost of the warm approach is the clocks running for the idle window, not an audible artefact.

Caveats: one board, one listener, one clip. If you'd still rather avoid the complexity, the one cheaper idea the data leaves open is applying the same silence-primed un-mute to the non-warm path (start clocks → prime with silence → un-mute) and testing whether that alone is enough; I'm happy to bench that as a follow-up, but on this evidence I'd merge the warm output as-is.

This branch has not been deployed

No deployments
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