Repository navigation
fix(mic): derive slot completion from record(); state the SystemMic contract - #21
Merged
Merged
Conversation
… 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
force-pushed
the
fix/system-mic-contract-and-reference-driver
branch
from
July 29, 2026 14:28
73b2ee1 to
baa2929
Compare
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.
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()returnedmaxSamplesthe 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}:_is_recordingis set atMic_Class.cpp:596, inside the mic task, after it dequeues. So betweenrecord()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 adelay(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()atMic_Class.cpp:777blocks until its slot frees: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) doessrc_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 == 0was previously undefined, and three independent implementations had read it three incompatible ways:timeoutMs == 0meantSandbox::updateMicStream()—read(_micBuf, want, 0)everyloop()M5MicDriverbefore this PRM5Microphone::advance()—if (timeoutMs > 0 && ...)ResidentSystemMic.handdocs/api.mdnow state four rules, each matching a defect seen in a real driver: capture belongs inbegin()notread(); never hand capture hardware the caller's buffer;timeoutMs == 0means do not block; short reads (0included) 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 withtimeoutMs == 0, forwards short reads verbatim rather than padding tomaxSamples, and emits nothing when a read returns0../tools/run-tests.py all— cppcheck clean, 88/88 unit cases, all example envs build including bothm5stick-voiceboards (m5stick,m5sticks3).Not hardware-validated by me; #117 carries the on-device measurements for the same rotation.
Note for #117
M5Microphone::advance()treatstimeoutMs <= 0as "wait as long as the mic task runs". Under the contract above that inverts the intended meaning, so ifM5Microphoneis ever adapted ontoResident::SystemMic, the pump's0would block the sandbox loop indefinitely. Worth reconciling when the submodule pointer moves.🤖 Generated with Claude Code