Skip to content

StopWatch: recover ES8311 capture after power-on reset - #348

Merged
lovyan03 merged 3 commits into
m5stack:developfrom
ainyan03:stopwatch_es8311_capture
Sep 1, 2026
Merged

StopWatch: recover ES8311 capture after power-on reset#348
lovyan03 merged 3 commits into
m5stack:developfrom
ainyan03:stopwatch_es8311_capture

Conversation

@ainyan03

@ainyan03 ainyan03 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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:

  • The arming is needed once per codec power-on reset and survives I2C power-down/up cycles (Mic.end()/begin()).
  • The previous 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 after M5.Speaker.begin() has run. A quickly cycled rail also produces marginal resets that make the recovery unreliable.

Fix

  1. Mic_Class: post-start callback + lifecycle serializationbegin() 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 fails begin(). While touching this path, begin()/end() are serialized under _begin_lock (a concurrent end() could previously interleave with a begin() in progress), and a failed enable callback or I2S setup now rolls back via the disable callback and driver uninstall.
  2. StopWatch: ES8311 capture recovery — the post-start callback writes 0x0D=0x01 at roughly +30/+60/+90 ms after the clock start (earliest working point measured ~+25 ms) and latches the armed state, so only the first Mic.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 register 0x17 and 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

  • After the arming, the ES8311's analog stage still warms up on its own for roughly one second after the codec powered up; during that window captured samples can be all zero. This is chip physics — neither blocking nor extra writes shorten it — so begin() documents it instead of hiding it behind a long block. An application that records immediately after the very first begin() 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 after Speaker.end() in exchange for reliable and fast speaker/mic transitions; please review this trade-off from the product side.
  • ChainCaptain uses the same callback structure and the same codec and most likely needs the same treatment, but I could not verify it on hardware, so it is left unchanged here.

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:

  • cold boot → Mic.begin() → record: real samples (previously all-zero)
  • the reported path Speaker.begin() → tone → Speaker.end() → mic: real samples
  • repeated speaker↔mic alternation: stable
  • manual codec power-rail cycling: detected via the 0x17 witness and re-armed
  • M5.Speaker/M5.Mic behavior 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 stock Basic/Microphone example was verified on the StopWatch display

A machine-readable readiness query (e.g. Mic.isReady() for the warm-up window) could be a follow-up if desired.

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.
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