StopWatch: recover ES8311 capture after power-on reset - #348
Merged
Conversation
Some codecs accept part of their setup only while the I2S bus clock is running (ES8311), but the enable callback runs before the capture task starts the clock. Add an optional post-start callback that begin() invokes in the caller's context once the capture task has enabled the I2S channel, waiting up to one second for the clock. A missing clock or a false return from the callback tears the port back down and fails begin(). Publishing the codec state safely also requires the lifecycle to be serialized: end() now takes _begin_lock (internal callers use the new _end_locked()), and the already-running early return in begin() moved under the lock, so a concurrent end() can no longer tear the port down while begin() is still bringing it up, or right after its early check. The capture task publishes the clock state (_i2s_active) only when the I2S start actually succeeds, and a failed enable callback or setup now runs the disable callback and driver uninstall before begin() reports failure, instead of leaving a half-built port behind.
…#347) After a power-on reset the ES8311 runs an internal sequence once the CSM is enabled and the bus clock arrives. A SYSTEM(0x0D) analog power-up write issued before the I2S clock starts is absorbed without effect even though the register reads back the written value, so a capture path initialized by the enable callback (which runs before the clock starts) records only zeros, forever. Measured on StopWatch hardware with both arduino-esp32 2.x and 3.x: - a 0x0D write arms the capture path only when issued while the bus clock is running (earliest observed working point ~25 ms after the clock start); pre-clock writes never arm it - once armed, real samples appear when the analog warm-up finishes, roughly a second after the codec powered up - a fixed physical time that neither blocking nor extra writes shorten - the arming is needed once per codec power-on reset; warm re-inits (including Mic end/begin cycles) keep it - cutting the codec power rail at speaker disable forces the next capture through another reset and warm-up, and a quickly cycled rail produces marginal resets whose recovery misfires Register the new Mic post-start callback for the StopWatch: it writes 0x0D=0x01 at roughly +30/+60/+90 ms after the clock start and returns, so the first Mic.begin() after a codec reset blocks for about 100 ms; later ones are unaffected. The armed state is latched only when the latest (most conservative) write was acknowledged. The analog warm-up then completes in the background: begin() success means the port runs and the codec is configured, and for about the first second after a codec reset captured samples can still be all zero - documented as part of the begin() contract. A reset performed outside this library (rail cycled by the application, brown-out) is detected through register 0x17, which keeps the value a previous capture setup wrote only while no reset has occurred. The speaker disable path keeps the audio power rail on and powers down only the DAC over I2C; the deep I2C power-down still happens on mic disable. This changes the idle power draw after Speaker.end() and is deliberate - see the comments for the measured failure modes behind it. in_i2c_bulk_write() now reports whether every write was acknowledged, and the StopWatch mic enable callback fails the enable when its setup bulk did not fully land. ChainCaptain shares the same callback structure and codec and likely needs the same treatment; it is left unchanged here because it could not be verified on hardware.
… path The rollback for a failed enable callback installed nothing itself: _setup_i2s cleans up its own failures, and on the legacy driver an unconditional i2s_driver_uninstall(port) could take down a driver some other code installed on the same port.
This was referenced Sep 1, 2026
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.
Fixes #347 (StopWatch: microphone returns all-zero samples once
M5.Speaker.begin()has run).Root cause (measured on StopWatch hardware, arduino-esp32 2.x and 3.x)
The ES8311 arms its capture path only through a
SYSTEM(0x0D)write performed while the I2S bus clock is running. A write issued before the clock starts is absorbed silently: it is acknowledged and even reads back, but has no effect. The microphone enable callback runs before the capture task starts the I2S clock, so on the StopWatch the capture path was never armed and every recording came back as zeros.Two more measured properties shape the fix:
Mic.end()/begin()).Speaker.end()path cut the codec power rail (M5IOE1_G3), so every speaker→mic transition forced a fresh reset — this is why the issue reproduces exactly afterM5.Speaker.begin()has run. A quickly cycled rail also produces marginal resets that make the recovery unreliable.Fix
Mic_Class: post-start callback + lifecycle serialization —begin()can now invoke a (private,M5Unified-only) callback once the capture task has enabled the I2S channel, in the caller's context. A missing clock or a failed callback tears the port down and failsbegin(). While touching this path,begin()/end()are serialized under_begin_lock(a concurrentend()could previously interleave with abegin()in progress), and a failed enable callback or I2S setup now rolls back via the disable callback and driver uninstall.0x0D=0x01at roughly +30/+60/+90 ms after the clock start (earliest working point measured ~+25 ms) and latches the armed state, so only the firstMic.begin()after a codec reset blocks, for about 100 ms; later ones are unaffected. A reset performed outside the library (rail cycled by the application, brown-out) is detected through register0x17and re-arms.Speaker.end()now keeps the rail on and powers the DAC down over I2C, so alternating speaker/mic use no longer resets the codec each time.Behavior notes
begin()documents it instead of hiding it behind a long block. An application that records immediately after the very firstbegin()will see one quiet capture; everything afterwards is normal.Speaker.end()no longer cuts the codec power rail on the StopWatch (DAC is powered down over I2C instead). This slightly changes idle power draw afterSpeaker.end()in exchange for reliable and fast speaker/mic transitions; please review this trade-off from the product side.Verification
On a StopWatch unit, with both
espressif32 6.12.0(arduino-esp32 2.x, as in the report) and pioarduino (arduino-esp32 3.x), across 10+ cold boots each:Mic.begin()→ record: real samples (previously all-zero)Speaker.begin()→ tone →Speaker.end()→ mic: real samples0x17witness and re-armedM5.Speaker/M5.Micbehavior on other boards is unchanged (the callback is registered only for the StopWatch); compile-checked for ESP32/ESP32-S3/ESP32-C6 on both core generations, and the stockBasic/Microphoneexample was verified on the StopWatch displayA machine-readable readiness query (e.g.
Mic.isReady()for the warm-up window) could be a follow-up if desired.