Skip to content

fix(mic): derive slot completion from record(); state the SystemMic contract - #21

Merged
genmon merged 1 commit into
mainfrom
fix/system-mic-contract-and-reference-driver
Jul 29, 2026
Merged

genmon merged 1 commit into
mainfrom
fix/system-mic-contract-and-reference-driver

Conversation

@genmon

@genmon genmon commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Supersedes #20 — same defect, but the completion mechanism #20 used turns out to be the one inanimate-tech/hawthorn-firmware#117 rejected after on-device investigation. Also lifts the general rule out of the example and onto the interface, which is what stops the next driver author rediscovering it.

The defect

M5.Mic.record() is asynchronous: it queues a destination buffer into M5Unified's request queue and returns while the mic task fills it in the background. M5MicDriver::read() returned maxSamples the instant it returned, so every read handed back a buffer that had not been written yet — and because the destination was the caller's buffer, the mic task went on writing memory the caller already considered its own.

Why not poll isRecording()

Verified against M5Unified master, src/utility/Mic_Class.{hpp,cpp}:

// Mic_Class.hpp:117
size_t isRecording(void) const { return _is_recording ? ((bool)_rec_info[0].length) + ((bool)_rec_info[1].length) : 0; }

_is_recording is set at Mic_Class.cpp:596, inside the mic task, after it dequeues. So between record() queueing and the task waking, this reads 0 with the fill still pending — a wait on it falls straight through onto an unfilled buffer. Priming it with a delay(1) is a bet on the task winning the CPU within one tick, not a guarantee. isRecording() answers queue occupancy (its own docs: 2 = no room in the queue), and that is all it is used for here.

What it does instead

_rec_raw() at Mic_Class.cpp:777 blocks until its slot frees:

while (_rec_info[_rec_flip].length) { xSemaphoreTake(_task_semaphore, 1); }

The queue holds exactly two requests, so record() returning means the request queued two calls earlier has completed — a guarantee, with nothing to poll. read() rides it with a three-buffer rotation: queue the buffer just finished serving, then serve the buffer whose completion that queueing waited on.

Queueing before serving keeps two requests outstanding at all times, which is load-bearing. When its queue runs empty the mic task parks, and on parking (Mic_Class.cpp:587-590) does src_idx = ~0u; src_len = 0 — discarding the partially consumed DMA chunk. No error, no counter, just a splice. The old one-buffer-per-read shape ran the queue dry on every single read, so the example was demonstrating exactly the audio defect #117 exists to eliminate.

The mic task is also pinned to core 1 at priority 18. This example records while streaming over TLS, and #117 measured a DMA-ring overrun under that load at M5Unified's default priority 2.

The interface half

The trap is not M5-specific, so the rule that would have caught it now lives on the interface rather than in one example. timeoutMs == 0 was previously undefined, and three independent implementations had read it three incompatible ways:

Site timeoutMs == 0 meant
Sandbox::updateMicStream() — read(_micBuf, want, 0) every loop() "non-blocking, give me what's ready"
M5MicDriver before this PR ignored the parameter; blocked ~32 ms per call
hawthorn-firmware M5Microphone::advance() — if (timeoutMs > 0 && ...) block indefinitely

ResidentSystemMic.h and docs/api.md now state four rules, each matching a defect seen in a real driver: capture belongs in begin() not read(); never hand capture hardware the caller's buffer; timeoutMs == 0 means do not block; short reads (0 included) are legal.

No runtime behaviour changes — the pump already passed 0. src/ is documentation-only; the contract was chosen to match what the runtime already does, so this is a zero-risk change to the library itself.

Tests

Three pump contract tests in test/unit/test/test_mic_stream/: reads with timeoutMs == 0, forwards short reads verbatim rather than padding to maxSamples, and emits nothing when a read returns 0.

./tools/run-tests.py all — cppcheck clean, 88/88 unit cases, all example envs build including both m5stick-voice boards (m5stick, m5sticks3).

Not hardware-validated by me; #117 carries the on-device measurements for the same rotation.

Note for #117

M5Microphone::advance() treats timeoutMs <= 0 as "wait as long as the mic task runs". Under the contract above that inverts the intended meaning, so if M5Microphone is ever adapted onto Resident::SystemMic, the pump's 0 would block the sandbox loop indefinitely. Worth reconciling when the submodule pointer moves.

🤖 Generated with Claude Code

… SystemMic contract

M5.Mic.record() is asynchronous: it queues a destination buffer into
M5Unified's request queue and returns while the mic task fills it in the
background. M5MicDriver returned maxSamples the instant it returned, so every
read handed back a buffer the task had not written yet — and since the
destination was the *caller's* buffer, the mic task went on writing memory the
caller already considered its own.

Rewritten as a three-buffer rotation that derives completion from record()
itself. M5Unified's queue holds exactly two requests and record() blocks until
its slot is free, so record() returning means the request queued two calls
earlier has completed: a guarantee, with nothing to poll. isRecording() cannot
serve here — it is gated on a flag the mic task sets itself, so a freshly
queued request reads back as "nothing pending" and a wait on it falls straight
through onto an unfilled buffer. It answers queue occupancy, and is used only
for that.

Queueing before serving also keeps two requests outstanding at all times. That
is load-bearing: when its queue runs empty the mic task parks, and on parking
discards the partially consumed DMA chunk it was holding — no error, no
counter, just a splice in the audio. The old one-buffer-per-read shape ran the
queue dry on every single read. The mic task is also pinned to core 1 at
priority 18, fixing a separate DMA-ring overrun under TLS streaming load at
M5Unified's default priority 2.

The underlying trap is not M5-specific, so the contract that would have caught
it now lives on the interface. ResidentSystemMic.h and api.md spell out four
rules, each matching a defect seen in a real driver: capture belongs in
begin() rather than read(); never hand capture hardware the caller's buffer;
timeoutMs == 0 means do not block (the pump calls read(buf, frameSamples(), 0)
from loop(), so blocking stalls the whole sandbox); and short reads, 0
included, are legal and routine.

timeoutMs == 0 was previously undefined, and independent implementations had
read it as "don't block", "block indefinitely" and "ignore the parameter".
No runtime behaviour changes — the pump already passed 0 — but that is now the
documented and tested contract rather than an accident.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@genmon
genmon force-pushed the fix/system-mic-contract-and-reference-driver branch from 73b2ee1 to baa2929 Compare July 29, 2026 14:28
@genmon
genmon merged commit 2eaebbe into main Jul 29, 2026
4 checks passed
@genmon
genmon deleted the fix/system-mic-contract-and-reference-driver branch July 29, 2026 14:40
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