feat: microphone domain — list/inspect/gain/array/param/stream/record (0.9.0) - #6
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Add EXIT_BUSY_ERROR=3 to cli/_errors.py and a new microphone_cli/access.py
(cited from webcam-cli webcam_cli/access.py) with AccessState
{ok,absent,forbidden,busy}, check_access/require_access, a best-effort
/proc/*/fd holder lookup, and kind-specific remediations: 'audio' group
membership for /dev/snd nodes, a udev SUBSYSTEM=="usb" rule for
/dev/bus/usb nodes. tests/test_access.py covers all four states against a
fake /proc tree, hardware-free (os.open is monkeypatched throughout; no
real /dev or /proc access).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Cites webcam-cli/webcam_cli/activation.py:26-129, renamed to MICROPHONE_ACTIVATION_LOG and adapted to microphone-cli's field set (verb, device, params, started_at, ended_at). Appends one JSON line per --apply action to XDG_STATE_HOME/microphone-cli/activation.jsonl (or the env override), never overwriting prior lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Ports the audio subset of webcam-cli's engine.py (Capability/AudioFormat, detect(), require_engine()) for microphone-cli's own element set — alsasrc/audioconvert/audioresample/queue core, opusenc/matroskamux/wavenc/ rtpopuspay/rtpL16pay/udpsink optional — plus require_elements() and two pure argv builders: build_audio_stream_argv() (RTP/UDP passthrough or opus) and build_audio_record_argv() (Matroska/Opus or WAV, with an alsasrc num-buffers+latency-time bound for duration_s). No PyGObject/gi import; detect() is the only function that shells out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Add microphone_cli/devices.py: enumerate USB audio capture cards from /proc/asound/cards plus the sysfs USB parent walk, build stable ids from the USB descriptors (udev's escaping rules; sysfs-path fallback when the firmware ships no serial), flag XVF3800 arrays by idVendor:idProduct (38fb:1001, 2886:001a), report the capture channel count from /proc/asound/cardN/stream0, and resolve() a selector to exactly one device — refusing raw hw:N / plughw:N / bare-integer card numbers and refusing ambiguity, both as CliError(EXIT_USER_ERROR) naming the stable ids to use instead. Cited from webcam_cli/devices.py with the video half dropped; provenance is in the module docstring. Four synthetic fixture trees under tests/fixtures (text files and relative symlinks only, no real captures): host-baseline, host-renumbered, two-arrays, respeaker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the hardware-free protocol core for microphone-cli:
* microphone_cli/usbctl.py — ctypes mirror of struct usbdevfs_ctrltransfer,
USBDEVFS_CONTROL derived as _IOWR('U', 0, sizeof(struct)) (0xC0185500 on
64-bit), control_transfer() over fcntl.ioctl behind a module-level _ioctl
seam, sysfs device enumeration to /dev/bus/usb/BBB/DDD node paths, and
open_device() mapping EACCES/ENOENT onto CliError with a udev remediation.
* microphone_cli/xvf3800.py — the vendor protocol ported from reachy_mini's
media/audio_control_utils.py (Apache-2.0, cited in the module docstring),
with the PARAMETERS table vendored verbatim, typed pack/unpack, the
status-64 retry loop, PERSISTENT/KNOWN_IDS, and param_info().
Tests inject a fake ioctl; no test opens /dev or touches real hardware.
Runtime dependencies stay empty — stdlib only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- list_devices.py: enumerate_devices() + per-device audio_access probe
via access.check_access on the ALSA capture PCM node
- inspect.py: parses <root>/proc/asound/cardN/stream0 for formats/rates/
channels; on an XVF3800 array reads firmware.{version,build,host,
repo_hash} via usbctl.find_devices + Xvf3800.firmware_info(), reporting
{"error": ...} rather than hard-failing on a lookup or permission problem
- Cites ../webcam-cli/webcam_cli/cli/_commands/list_devices.py for the
"compose, never hard-fail" shape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
`array` noun group: bare `array` and `array overview` describe the group; `array doa <device>` reads DOA_VALUE_RADIANS as raw firmware radians (no coordinate transform), single-shot or `--watch` JSON Lines with `--interval`/`--count`, Ctrl-C exits 0 and a lost device exits 2; `array aec get|set` reports converged/bypass/hpf/echo/num_mics/geometry and flips --echo/--bypass/--hpf, dry-run unless --apply (which writes inside an activation_scope). Every nested add_subparsers passes parser_class=type(p) so parse errors keep the structured error contract. Hardware is reached only through the module-level _open_array / _sleep seams; tests serve every transfer from an in-memory fake and never open a device node. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds microphone_cli/cli/_commands/param.py: `param overview`, `param list`,
`param get <device> <NAME>`, `param set <device> <NAME> <values...>
[--apply] [--allow-persistent]`. Name/access/count validation (via
xvf3800.param_info and a local per-type value parser) happens before any
device is opened, so an unknown name, a ro name, or a wrong value count
never issues a transfer, whether or not --apply is passed. The persistent
tier (xvf3800.PERSISTENT, already added by t3) is checked before opening the
device or writing when --apply is set: a persistent/destructive name
without --allow-persistent is refused with a hint naming the flag and
explaining the volatile-vs-persistent distinction. Applied writes run
inside activation_scope("param set", ...) and include a readback when the
parameter is also readable (rw).
Adds tests/test_param.py, which builds a standalone parser via
param.register() (the noun isn't wired into cli/__init__.py yet — that's a
separate task) and monkeypatches param.resolve / param._open_array so no
test opens /dev.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Adds microphone_cli/mixer.py (amixer -c <card> contents/cset wrapper,
hardware-free via a run= seam) and microphone_cli/cli/_commands/gain.py
exposing `gain overview|get|set`. `gain get` reports the ALSA
capture-volume control and, on array devices, AUDIO_MGR_MIC_GAIN read
over the XVF3800 vendor protocol (firmware read failures degrade to
firmware: {error} rather than failing the whole command). `gain set`
is a dry run by default (plans the amixer argv/firmware write without
issuing them) and only applies with --apply, wrapped in exactly one
activation_scope("gain set", ...) log line.
Not yet wired into cli/__init__.py (a separate task owns that file);
tests/test_gain.py drives gain.register() through a standalone
_CliArgumentParser instead of microphone_cli.cli.main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Two capture verbs, both cited (audio paths only) from webcam-cli's stream.py/record.py and both keeping the three-level hardware split: * `stream audio <device>` — RTP/UDP attachment point via udpsink, with --port/--host/--rate/--channels/--format/--encode. Default is a dry run that prints the exact gst-launch-1.0 argv and touches nothing; --probe detects the engine and checks the capture node without spawning; --apply requires the engine and the encode's elements, enforces access (busy is exit 3), spawns through the module-level `_spawn` seam inside an activation_scope, and returns the pid plus per-codec receive pipelines. * `record <device> <output>` — bounded capture to .mka/.wav (container from the extension), with --duration (default 30, 0 < d <= 3600) and --max-bytes (default 256 MiB, <= 4 GiB) enforced both by the argv's own num-buffers bound and by a poll loop over the growing artifact through the `_sleep`/`_monotonic` seams; stopped_reason reports which bound won. Refuses a missing parent dir, an unknown extension, and an existing file without --overwrite. Payload key names follow the cited modules, minus the video-only ones. Tests are hardware-free by construction: subprocess.Popen/run are booby-trapped autouse, devices resolve under tests/fixtures/host-baseline, the capture node is root-joined so it is absent under a fixture root, and the activation log is redirected into tmp_path. Not wired into cli/__init__.py, learn/overview/catalog — those files belong to the wiring task; the tests build the same parser shape by hand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Register every wave-0/1 command module in `_build_parser` (list, inspect,
gain, array, param, stream, record) alongside the introspection verbs, and
set `prog="microphone"` to match `[project.scripts]` — the binary is
`microphone`, so nothing the CLI prints should tell an agent to type
`microphone-cli`.
Rewrite the three hand-maintained duplicates of the surface for the
microphone domain:
* `learn.py` — purpose, the three-level hardware-touch split first
(dry-run / --probe / --apply) plus the read-only vendor control transfers
that still open the USB node, the full command map, the stable-id selector
rule, exit codes 0/1/2/3, the persistent tier, raw firmware radians,
consent and the activation-log pointer, and an audience section naming
media-cli as the composing consumer. `_as_json_payload` gains `command`,
`import_package`, `audience`, `hardware_activation`, `device_selector`,
`bounds`, `persistent_tier`, `raw_firmware_values`, `consent`.
* `overview.py` — `_VERBS` covering all 29 registered paths, plus
`_HARDWARE`, `_CONTRACTS` and a consent section; `cli_sections` now reuses
`_VERBS` verbatim instead of re-declaring it.
* `explain/catalog.py` — an entry per registered path, with
`("microphone-cli",)` kept as a resolvable legacy alias.
Ported from webcam-cli's tests/test_cli.py:190-327: `_registered_paths()`
walks the live argparse tree, and the parity tests require the catalog,
`overview._VERBS` and learn's command map to agree with it. Two prose gates
join them — no user-facing string may present `microphone-cli` as a typable
command, and none may carry the scaffold's clonable/template wording.
`doctor`'s checks and JSON shape are untouched; only its text header and
docstring changed. Three wave-1 modules needed their overview *subject*
strings retitled (`microphone-cli array` -> `microphone array`, same for
`param` and `stream`) so the dead-command gate can pass; their flags and
payload keys are unchanged, and the affected assertions in
tests/test_array.py, test_param.py and test_stream.py moved with them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
README.md and CLAUDE.md replace the scaffold-state narrative from 5f9b1bd with the domain state: 13 top-level verbs (list, inspect, gain get/set, array doa, array aec get/set, param list/get/set, stream audio, record, plus the six agent-first verbs), 276 tests, 92% coverage. README follows webcam-cli's heading shape (Status, Scope, Quickstart, CLI table, What comes out, What touches the hardware, Why device identity is the hard part, What this repo carries, License). CLAUDE.md gets a module map, the three-level hardware split, the testing seams (root=, _open_array, _ioctl, _spawn, _sleep, run=), the fixture trees, the parity tests, and the issue #3 hardware-acceptance status; the console-script note is marked RESOLVED now that prog and the script both read `microphone`. Adds docs/xvf3800-parameters.md: attribution for the vendored XVF3800 parameter table (Pollen Robotics' reachy_mini, Apache-2.0) and a guide to its resid groups and persistent tier. docs/skill-sources.md gains rows for the recall/remember skills (first-party to eidetic-cli, not guildmaster), which were vendored but never entered into the provenance ledger. Version bumped 0.8.2 -> 0.9.0 (MINOR) via the version-bump skill. No Python source, tests, or .github/workflows touched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
…ot-joined capture node Both found during on-device acceptance against a ReSpeaker XVF3800 (2886:001a): the permission hint told the user to add a rule for 38fb:1001, and 'list --root <fixture>' probed the host's real /dev/snd/pcmC1D0c because the fixture's card 1 collided with the live card 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
…rmware Found on hardware (ReSpeaker XVF3800, Seeed USB firmware v2.1.0): the vendored reachy_mini table is the 38fb:1001 map. Seeed's 2886 firmware has no DOA_VALUE_RADIANS, its DOA_VALUE is two uint16 (degrees, speech flag), and it adds LED_RING_COLOR and the AIC3104 output levels. Adds the uint16 codec, FIRMWARE_OVERLAYS keyed by USB vendor id, Xvf3800(vendor=...), param resolution after device resolution, 'param list --vendor', and azimuth_deg alongside azimuth_rad in 'array doa' (unit conversion only). Plan deviation d2. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
- mixer: accept ',index=N' on the amixer numid line so a second same-named control no longer overwrites the first one's values (gain readback was stale) - engine: passthrough stream converts to S16BE before rtpL16pay (the payloader refused S16LE: 'could not link queue0 to rtpl16pay0') - stream/record: --rate/--channels/--format default to what the device advertises in /proc/asound stream0 instead of a fixed 48 kHz mono, which the exact caps filter could never negotiate on a 16 kHz stereo array; the payload records each field's source (explicit/advertised/default) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
PR Summary by QodoAdd complete microphone control, streaming, and recording domain
AI Description
Diagram
High-Level Assessment
Files changed (115)
|
Code Review by Qodo
1.
|
…e ranges, unvalidated gain - cli/__init__.py: wrap parser construction/parse_args in main() so any non-SystemExit, non-KeyboardInterrupt exception raised before _dispatch's boundary (e.g. an import or registration failure) becomes a structured CliError instead of a raw traceback. - xvf3800.py _decode: the uint8 branch now checks the reply length against info.count and raises the same short-reply CliError the wide/half types raise. - xvf3800.py _encode: uint8 and uint16 values are range-checked (0..255 / 0..65535) before packing, so an out-of-range value raises CliError(1) instead of being silently masked/truncated (-1 -> 255, 256 -> 0) and sent to firmware. - gain.py cmd_gain_set: validates the gain value is finite and in 0.0..1.0 before any planning or writing, for every --target, instead of relying on _map_to_alsa's clamp (which never reached the firmware write). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
…in validation (PR #6 threads 1, 6, 10, 11)
- record_activation() looped os.write() once and ignored its return value, so a short write could truncate an activation line. Loop until the full payload is written (or raise OSError on a 0-byte/failed write), keeping the single-open O_APPEND semantics that make lines under PIPE_BUF atomic. - activation_scope() only opened the log after running the protected action, so a permission/ENOSPC problem surfaced as a generic failure after hardware had already changed, inviting a retry of a non-idempotent action. It now resolves the path, creates parent directories, and opens the log for append (then closes it) before yielding, raising CliError(code=2) up front on failure. If the final write still fails after the body ran, it now raises CliError(code=2) stating the action was applied and only the audit write failed, instead of a bare OSError. Tests: test_record_activation_survives_short_writes, test_record_activation_raises_on_zero_byte_write, test_activation_scope_raises_before_body_when_log_dir_unwritable, test_activation_scope_reports_applied_but_not_logged_after_body_ran, test_activation_scope_reports_applied_but_not_logged_on_late_write_failure_after_raise. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
…y (PR #6 threads 7, 8)
Four review findings on the --apply paths of `record` and `stream audio`, all fixed test-first. 1. Recordings can outlive their bounds (record.py `_stop`/`_run_bounded`). A child that ignored SIGTERM was only warned about, and the bound was reported as if it had stopped the recording. `_stop` now escalates to SIGKILL, waits for it, and raises CliError(code=2) if the process survives even that — it never returns while the child is alive. 2. Recordings can exceed their size cap (record.py `_run_bounded`/`_apply`). The process-exit check returned "eos" before the artifact was compared with --max-bytes, so a pipeline that blew the cap and exited inside one poll interval looked like a clean bounded recording. The finished artifact is now stat'ed whatever the stopped_reason, and an oversized file is a typed exit-2 error naming the bytes written and the cap. The file is deliberately kept at its path — deleting it is the caller's call — and the activation record still carries the outcome. 3. Broken streams are reported as live (stream.py `cmd_stream_audio`). A successful Popen was treated as a running stream and the child's stderr was discarded. The child's stderr now goes to a temp file, the command waits STARTUP_SETTLE_S (0.5s, via the new `_sleep` seam) and polls; a child that has already exited is CliError(code=2, "stream pipeline exited <rc> during startup") whose remediation quotes the pipeline's own first ERROR/WARNING/ not-negotiated lines — never a traceback. 4. Live streams look finished in the audit (stream.py + activation). An unbounded stream got ended_at at launch. It is now logged honestly with ended_at=None and params carrying pid and lifetime="unbounded" (via record_activation with an explicit Activation, not activation_scope); a startup failure still logs a closed line with params.error. The payload's lifetime/consent text says the log line is open-ended. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
…ection, open-ended stream audit (PR #6 threads 3, 4, 5, 9)
…n unstopped over-cap artifact is an error Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Hoist setup calls out of pytest.raises blocks so each contains exactly one call that can raise (S5778), split composite `and` assertions into separate asserts (S9073), and drop empty parentheses from bare @pytest.fixture decorators (S9083). Behaviour and assertions are unchanged; test count stays at 304 passed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Behaviour-preserving refactor of the four command modules Sonar flagged: - record._payload took 14 parameters (S107): the container, format, bounds and output path now travel together in a frozen `_Plan` dataclass, built once in cmd_record and also passed to _apply. Payload keys and values are unchanged. - record's `mode` selection was a nested conditional expression (S3358); it is now a plain if/elif/else. - cmd_record, cmd_stream_audio and cmd_param_list always returned 0 (S3516). Per the dispatch contract (_dispatch maps None to exit 0) these handlers — and their unconditionally-zero neighbours cmd_param_get, cmd_param_set, cmd_param_overview, cmd_gain_get, cmd_gain_set, cmd_gain_overview and both _no_verb wrappers — now return None. - stream.advertised_format had cognitive complexity 16 (S3776); the per-field fallback logic moved into _resolved_rate / _resolved_channels / _resolved_sample_format, each returning (value, source). - "Emit structured JSON." was duplicated in param.py (5x) and gain.py (4x) (S1192). It is now JSON_FLAG_HELP in microphone_cli/cli/_commands/__init__, used by param, gain, stream and record. 304 tests pass unchanged; teken cli doctor --strict stays 26/26. Every --json payload was diffed against the pre-refactor build and is identical apart from its own resolved_at timestamp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nt continue, type mismatch - mixer.py: _NUMID_RE's lazy `.*?` name group (ambiguous with the closing quote char) replaced with `[^']*` (S8786); list_controls' line-kind branching split into _header_fields/_apply_attr_line/_apply_value_line/ _control_from_fields helpers, dropping cognitive complexity from 19 and removing the now-unreachable trailing `continue` (S3776, S3626). - inspect.py: _RATES_RE's `.+` (ambiguous with the preceding `\s*`, both matching whitespace) replaced with `\S.*` (S8786); _parse_capture_block split into _section_header/_append_format/_append_channels/_append_rates/ _parse_capture_line helpers, dropping cognitive complexity from 27 (S3776). - usbctl.py: find_devices' per-device attribute read and the vendor/ product/serial filter extracted into _read_device_attrs/_matches_filters, dropping cognitive complexity from 17 (S3776). - activation.py: explicitly annotate activation_scope's `target` local as Path so its type is unambiguous at both _record_or_report_applied call sites (S5655). All regex rewrites verified against the existing amixer/stream0 fixtures, including the ",index=1" duplicate-control case. 304 tests pass unchanged; black/isort/flake8/bandit clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
… Activation explicitly Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
…g regex Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za
|



What
The microphone domain, built from a converged devague spec and plan and accepted on real hardware.
list,inspect,gain get|set,array doa(single-shot or--watchJSON Lines) andarray aec get|set,param list|get|setover the full XVF3800 table with a persistent tier behind--allow-persistent,stream audio(RTP/UDP passthrough or Opus),record(bounded WAV/Matroska), plus the agent-first baseline.devices(stable ids from USB serial),access(ok/absent/forbidden/busy → exit 0/1/2/3),usbctl(stdlibusbdevfscontrol transfers, no pyusb),xvf3800(vendored parameter table with per-firmware overlays, typed codec, status-64 retry),mixer(amixer),engine(GStreamer argv),activation(append-only log of every--apply).--probechecks without opening;--applytouches hardware and is logged.progis nowmicrophone, matching the console script; template prose is gone and a parity test keeps catalog/learn/overview in sync with the parser.teken cli doctor --strict26/26, workflows untouched,dependencies = [].How it was built
/scope→/think→/challenge→/spec-to-plan→/assign-to-workforce: 13 tasks in 5 waves, one agent per task in isolated worktrees, TDD-gated merges. Artifacts:docs/specs/2026-09-06-microphone-domain.md,docs/plans/2026-09-06-microphone-domain.md(+ split),.devague/.Two approved plan deviations: d1 acceptance ran on a Seeed ReSpeaker XVF3800 (
2886:001a) instead of the Reachy Mini Lite the plan named (the robot on hand was a Reachy Mini whose array lives on its own Pi); d2 the parameter map turned out to be firmware-specific, soxvf3800.FIRMWARE_OVERLAYSwas added.On-device acceptance
scripts/acceptance/run.sh --writes --mediapassed 10/10 on the array (Seeed USB firmware 2.1.0): DoA matched Seeed's own reference reader exactly, all volatile writes round-tripped and were restored, record produced a real 16 kHz stereo WAV, and a blind consumer attached to the stream from the JSON payload alone. Evidence and the seven hardware-only defects it surfaced (all fixed here) are indocs/acceptance-microphone-domain.md.Follow-ups
On-device acceptance: XVF3800 DoA/AEC/gain against a Reachy Mini Lite (USB) #3 stays open for the Reachy Mini Lite (
38fb:1001) path.Support XVF3800 firmware variants and DFU reflash (XIAO board ships I2S firmware, silent on USB) #4 CLI support for the I2S→USB firmware bring-up (safe mode, dfu-util, udev).
Expose firmware voice activity detection: array vad get/watch/set #5 voice-activity exposure (
array vad).microphone-cli (Claude)
🤖 Generated with Claude Code
https://claude.ai/code/session_01Jei6EarsNVUDHMNxb1U1Za