Skip to content

feat(mic)!: standalone SystemMic interface + shipped Resident::M5Mic driver - #24

Merged
genmon merged 2 commits into
mainfrom
feat/system-mic-standalone
Jul 29, 2026
Merged

genmon merged 2 commits into
mainfrom
feat/system-mic-standalone

Conversation

@genmon

@genmon genmon commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Replaces #23 (auto-closed by GitHub when its stacked base branch was deleted on #21's merge — same branch and commits, now against main). #21 has merged, so the diff here is exactly the SystemMic v2 change.

SystemMic is no longer a Driver

The mic used nothing from Driver — no Lua module, no events, no update() — and the one coupling it had (the lifecycle set calling void begin()) is exactly what blocked convergence: MicBus needs begin() to report failure, and C++ won't let a subclass change the return type. Three HRD backends with radically different internals (sync I2S, M5Unified's async queue, multi-mic array + AFE stage) had independently converged on the same three methods; that is now the interface:

class SystemMic {
  virtual bool begin() = 0;      // acquire hardware + start capture; false = failed
  virtual void end() {}          // stop + release (shared-codec co-user)
  virtual int  read(int16_t* buf, int maxSamples, int timeoutMs) = 0;
  virtual uint32_t sampleRate() const = 0;
  virtual int frameSamples() const { return 512; }
};

HawthornMicrophone is this, verbatim, plus the two getters — downstream it gets deleted, not adapted. The five-rule contract (capture runs begin()→end(); never hand hardware the caller's buffer; timeoutMs == 0 = don't block; short reads legal; single caller) lives in the header and api.md.

The pump owns capture: startMicStream() begins the mic and now returns bool — false when there is no systemMic or begin() fails, a state that previously silently "streamed" nothing. stopMicStream() ends it. Capture hardware is held only while streaming (what shared mic/speaker codecs need); cost is ~2 frames of priming latency at stream start, imperceptible behind a 500 ms push-to-talk hold.

Resident::M5Mic

src/ResidentM5Mic.h — opt-in (not included by Resident.h), header-only, compiles to nothing without M5Unified, which stays the consumer's dependency. Wiring a mic on any M5 board is now:

#include <ResidentM5Mic.h>
Resident::M5Mic mic;
cfg.systemMic = &mic;

It is hawthorn-firmware #117's on-device-validated M5Microphone ported whole — three-buffer rotation deriving completion from record()'s two-request lag, mic task pinned core 1 / priority 18, audit() health counters — with two deliberate changes:

  • timeoutMs == 0 is a non-blocking poll per the contract. HRD's advance() treats <= 0 as block indefinitely — the inversion flagged on #117 — which would hang the sandbox loop under the pump. An empty non-blocking poll is routine and does not count as an Audit::timeouts fault.
  • Underrun detection is queue-occupancy-based (isRecording() == 0 outside priming = mic task parked and discarded its DMA chunk), replacing the wait-duration heuristic (waited < 2 ms), which misfires under a non-blocking caller where zero wait is the healthy case. Works for both blocking (MicBus) and non-blocking (pump) callers.

The m5stick-voice example deletes its 120-line M5MicDriver.h; task priorities, memcpy and rotation logic now live once, in the shipped driver.

Verification

  • 91/91 native unit cases (3 new pump-ownership tests: begin-once-per-stream / fail-fast / end-on-stop; the lifecycle regression test now also pins "mic NOT begun at setup"), cppcheck clean, all example envs build including both m5stick-voice boards compiling ResidentM5Mic.h.
  • Local ESP-IDF v5.5.3 build of espidf-basic (header-only addition; component CMake lists only ResidentSandbox.cpp).
  • The pre-port rotation driver was validated on-device today (M5StickS3 → live worker, clean transcript). On-device re-test of this PR pending — same board, exercising the new begin/end-per-hold path.

Downstream (hawthorn-firmware)

After #117 merges and this releases as v0.7.0: bump vendor/resident; delete HawthornMicrophone.hpp + lib/M5Microphone; rename the base class in MicBus/Microphone/MicArray + add sampleRate() one-liners; boards adopt Resident::M5Mic (its Audit surface is unchanged, so existing m5mic.audit() dumps keep working). MicBus needs no behavioural change — begin()->bool, end(), read(...,1000) all already conform.

🤖 Generated with Claude Code

genmon and others added 2 commits July 29, 2026 15:26
…sident::M5Mic

SystemMic drops its Driver inheritance. The mic used nothing from Driver — no
Lua surface, no events, no per-loop update — and the one coupling it had, the
lifecycle set calling void begin(), is exactly what blocked convergence with
hawthorn-firmware's HawthornMicrophone, whose consumers need begin() to report
failure. Three backends with radically different internals (synchronous I2S,
M5Unified's asynchronous request queue, a multi-mic array with a processing
stage) had already converged on the same three methods; that is now the
interface: bool begin(), void end() (default no-op, for shared-codec boards),
and read(buf, maxSamples, timeoutMs). sampleRate() stays pure virtual;
frameSamples() defaults to 512, the pump's cap. HawthornMicrophone can be
deleted downstream.

Capture is now owned by the pump: startMicStream() begins the mic and returns
bool (false when there is no systemMic or begin() fails — previously that
state silently "streamed" nothing), stopMicStream() ends it. Capture hardware
is held only while streaming, which is what shared mic/speaker codecs need,
and the sandbox no longer carries the mic in its extension lifecycle.

Resident::M5Mic (src/ResidentM5Mic.h) is a shipped, opt-in driver for
M5Unified boards — one include and one object where the example previously
carried sixty lines of rotation logic for non-experts to hold wrong. It is
the Hawthorn production M5Microphone ported whole: three-buffer rotation
deriving completion from record()'s two-request lag, mic task pinned to
core 1 at priority 18, audit() health counters. Two deliberate changes from
that port: timeoutMs == 0 is a non-blocking poll per the SystemMic contract
(was: block indefinitely), and underrun detection is queue-occupancy-based
rather than wait-duration-based, so it works under both blocking and
non-blocking callers. The header is not included by Resident.h and compiles
to nothing without M5Unified, which stays the consumer's dependency.

The m5stick-voice example deletes M5MicDriver.h and wires Resident::M5Mic in
two lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@genmon
genmon merged commit d906d0d into main Jul 29, 2026
4 checks passed
@genmon
genmon deleted the feat/system-mic-standalone branch July 29, 2026 14:48
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.

1 participant