diff --git a/.devague/current b/.devague/current new file mode 100644 index 0000000..a1ab941 --- /dev/null +++ b/.devague/current @@ -0,0 +1 @@ +microphone-domain diff --git a/.devague/current_plan b/.devague/current_plan new file mode 100644 index 0000000..a1ab941 --- /dev/null +++ b/.devague/current_plan @@ -0,0 +1 @@ +microphone-domain diff --git a/.devague/deliveries/microphone-domain.json b/.devague/deliveries/microphone-domain.json new file mode 100644 index 0000000..605b3ad --- /dev/null +++ b/.devague/deliveries/microphone-domain.json @@ -0,0 +1,39 @@ +{ + "plan_slug": "microphone-domain", + "schema_version": 2, + "created": "2026-09-06T19:58:21Z", + "updated": "2026-09-06T20:07:24Z", + "deviations": [ + { + "id": "d1", + "what": "Wave-4 acceptance target is a Seeed ReSpeaker XVF3800 (XIAO variant) on the dev host, id 2886:001a serial 114993702263100642, reflashed from I2S to USB firmware v2.1.0 via dfu-util, instead of the Reachy Mini Lite (38fb:1001) the plan named; the Reachy Mini's array is on its own Pi at 192.168.1.162 and was never reachable from this host", + "task_ref": "t13", + "reason": "the connected robot's USB-C is a host port (Reachy Mini, not Lite); the user chose to bring up a separate ReSpeaker board and asked for the firmware update; issue #4 tracks CLI support for the DFU path", + "affects": [ + "t13" + ], + "origin": "llm", + "status": "approved", + "classification": "acceptable", + "seq": 1 + }, + { + "id": "d2", + "what": "Seeed's USB firmware v2.1.0 on the 2886:001a board does not implement DOA_VALUE_RADIANS (resid 20 cmd 19) and its DOA_VALUE (20/18) is two uint16 values (degrees 0-359, speech flag), not two uint32; 'array doa' fails with firmware status 66 while every other probed parameter (VERSION, BLD_*, AEC_*, AUDIO_MGR_MIC_GAIN, LED/GPO) reads correctly through the stdlib usbdevfs path", + "task_ref": "t13", + "reason": "the vendored PARAMETERS table is reachy_mini's 38fb:1001 map; the spec parked this exact unknown (v4) and hardware now answers it: the map is firmware-specific. Needs a uint16 type and a per-firmware DoA source before array doa can pass acceptance on this board", + "affects": [ + "t13", + "t7", + "t3" + ], + "origin": "llm", + "status": "approved", + "classification": "needs-follow-up", + "seq": 2 + } + ], + "evidence": [], + "deltas": [], + "supersessions": [] +} diff --git a/.devague/frames/microphone-domain.json b/.devague/frames/microphone-domain.json new file mode 100644 index 0000000..c951dd4 --- /dev/null +++ b/.devague/frames/microphone-domain.json @@ -0,0 +1,1027 @@ +{ + "slug": "microphone-domain", + "title": "microphone domain", + "schema_version": 6, + "status": "exported", + "created": "2026-09-06T18:08:53Z", + "updated": "2026-09-06T18:49:41Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "microphone-cli enumerates USB microphones and arrays by stable id, inspects channels and formats, gets and sets gain, and reads direction-of-arrival and AEC state from XVF3800-class array firmware \u2014 zero runtime deps, --json everywhere, and a contract media-cli can compose", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "each verb named in the announcement exists in the live argparse tree and has a catalog entry", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "verified when every announced verb runs with --json in a clean venv (uv run microphone `` --json) and issue #3's on-device checklist is complete", + "revisions": [] + }, + { + "id": "c2", + "kind": "requirement", + "text": "Domain modules land under microphone_cli/ as frozen dataclasses with as_dict(), mirroring webcam_cli/devices.py:105-159 (VideoNode/AudioCard/LogicalDevice) \u2014 AudioCard.alsa_address 'hw:CARD=...' is the stable handle, card index is ephemeral", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "list output on the host-baseline fixture tree is byte-identical across a renumber fixture (card index changes, stable_id does not)", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c3", + "kind": "requirement", + "text": "Access model reuses webcam_cli/access.py's AccessState {ok,absent,forbidden,busy} \u2192 exit 1/2/3, with audio-group remediation for FORBIDDEN and /proc/*/fd holder lookup for BUSY; this repo's _errors.py (EXIT_SUCCESS/USER/ENV only) gains EXIT_BUSY_ERROR=3", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "EXIT_BUSY_ERROR=3 is raised when another process holds the PCM open, with the holder pid/command in the message when /proc is readable", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c4", + "kind": "requirement", + "text": "New verbs: 'list' (--root PATH, --json \u2192 {devices,count}), 'inspect ``' (channels, formats, rates from /proc/asound/cardN/stream0 and pcm*c), 'gain get|set `` [--apply]', 'array doa ``', 'array aec get|set'; each registered in _build_parser, given a catalog.py entry, a learn.py command-map entry, an overview._VERBS line, and every noun group exposes '`` overview' with parser_class=type(p)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "tests/test_cli.py asserts registered_paths == known_paths and every path appears in overview._VERBS and learn commands", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [ + { + "text": "New verbs: 'list' (--root PATH, --json \u2192 {devices,count}), 'inspect ' (channels, formats, rates from /proc/asound/cardN/stream0 and pcm*c), 'gain get|set [--apply]', 'array doa ', 'array aec get|set'; each registered in _build_parser, given a catalog.py entry, a learn.py command-map entry, an overview._VERBS line, and every noun group exposes ' overview' with parser_class=type(p)", + "kind": "requirement", + "reason": "formatting only: backtick angle-bracket tokens for markdownlint MD033" + } + ] + }, + { + "id": "c5", + "kind": "requirement", + "text": "Gain get/set is new ground with no webcam-cli precedent (webcam_cli has no control API; only warm-up frame discard, engine.py:110-148). Implement via ALSA mixer: 'amixer -c `` cget/cset' subprocess (present, alsa-utils 1.2.9) for generic USB mics, and AUDIO_MGR_MIC_GAIN (resid 35, cmd 0, float) via the XVF3800 vendor control path for arrays", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "gain get/set on a non-array USB mic works through amixer alone; on an XVF3800 it also reports AUDIO_MGR_MIC_GAIN", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [ + { + "text": "Gain get/set is new ground with no webcam-cli precedent (webcam_cli has no control API; only warm-up frame discard, engine.py:110-148). Implement via ALSA mixer: 'amixer -c cget/cset' subprocess (present, alsa-utils 1.2.9) for generic USB mics, and AUDIO_MGR_MIC_GAIN (resid 35, cmd 0, float) via the XVF3800 vendor control path for arrays", + "kind": "requirement", + "reason": "formatting only: backtick angle-bracket tokens for markdownlint MD033" + } + ] + }, + { + "id": "c6", + "kind": "requirement", + "text": "DoA and AEC are read/written through USB vendor control transfers exactly as reachy_mini/media/audio_control_utils.py does for the XVF3800: bRequest=0, wValue=cmdid (|0x80 for read), wIndex=resid, CTRL_TYPE_VENDOR|RECIPIENT_DEVICE; DOA_VALUE_RADIANS=(resid 20, cmd 19, 2 floats: azimuth radians + speech flag); AEC params on resid 33 (AEC_AECCONVERGED cmd 3 ro, AEC_HPFONOFF cmd 1 rw, SHF_BYPASS cmd 70 rw, AEC_NUM_MICS cmd 71, AEC_MIC_ARRAY_GEO cmd 74) and PP_ECHOONOFF (resid 17, cmd 23); read status byte 0=ok, 64=retry; the XMOS control-command appendix (XM-014888-PC) is the upstream reference", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "a fake ioctl layer replays the SDK's documented request bytes (bRequest 0, wValue cmd|0x80, wIndex resid) and the parser decodes status byte 0/64 and little-endian floats exactly as audio_control_utils.py does", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [ + { + "text": "DoA and AEC are read/written through USB vendor control transfers exactly as reachy_mini/media/audio_control_utils.py does: bRequest=0, wValue=cmdid (|0x80 for read), wIndex=resid, CTRL_TYPE_VENDOR|RECIPIENT_DEVICE; DOA_VALUE_RADIANS=(resid 20, cmd 19, 2 floats: azimuth radians + speech flag); AEC params on resid 33 (AEC_AECCONVERGED cmd 3 ro, AEC_HPFONOFF cmd 1 rw, SHF_BYPASS cmd 70 rw, AEC_NUM_MICS cmd 71, AEC_MIC_ARRAY_GEO cmd 74) and PP_ECHOONOFF (resid 17, cmd 23); read status byte 0=ok, 64=retry", + "kind": "requirement", + "reason": "user: target is the XVF3800 class generically" + } + ] + }, + { + "id": "c7", + "kind": "decision", + "text": "Zero runtime deps is kept: the vendor control transfer is issued with stdlib only \u2014 ctypes/fcntl USBDEVFS_CONTROL ioctl on /dev/bus/usb/BBB/DDD (device located by matching idVendor/idProduct in /sys/bus/usb/devices/*/) \u2014 instead of pyusb + libusb_package that reachy_mini imports", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "pyproject dependencies stays [] and 'import usb' appears nowhere in microphone_cli/", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c8", + "kind": "assumption", + "text": "Array firmware identification targets the XVF3800 class: match USB ids 38fb:1001 (Reachy Mini Audio firmware) and 2886:001a (Seeed ReSpeaker XVF3800 firmware, warn that reachy_mini considers it old), per reachy_mini init_respeaker_usb(); other ReSpeaker products (e.g. 2886:0018, a different XMOS part) stay out until their control map is verified. On-device acceptance runs against a Reachy Mini Lite over USB when it is connected", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "38fb:1001 and 2886:001a are matched from /sys/bus/usb/devices/*/idVendor+idProduct; any other id is reported as 'not an XVF3800 array' rather than probed", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [ + { + "text": "Array firmware identification: match USB ids 38fb:1001 (Reachy Mini Audio firmware) and 2886:001a (older ReSpeaker XVF3800 firmware, warn to update) per reachy_mini init_respeaker_usb(); 2886:0018 (ReSpeaker 4-mic v2) is out of the first cut unless its control map is verified", + "kind": "assumption", + "reason": "user: XVF3800 is the device class, Reachy Mini is one instance; a Reachy Mini Lite (USB) is available to test later" + } + ] + }, + { + "id": "c9", + "kind": "requirement", + "text": "Video is out of scope. Audio capture verbs ARE in scope: 'stream audio ``' and 'record `` ``' over GStreamer alsasrc (subprocess gst-launch-1.0, same dry-run/--probe/--apply split and typed engine-missing exit 2 as webcam_cli/engine.py), so a consumer can attach to or save the array's processed output without webcam-cli", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "stream audio never opens the device without --apply; dry-run prints the pipeline argv and exits 0 with hardware_touched=false", + "status": "confirmed", + "instruction": "" + }, + { + "id": "h23", + "text": "record without --apply writes no file; with --apply the output is bounded by --duration/--max-bytes", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [ + { + "text": "No capture/record/stream verb: reachy_mini and webcam-cli's 'stream audio' (GStreamer alsasrc) already own getting samples off the device; microphone-cli stops at enumerate/inspect/control/DoA/AEC", + "kind": "non_goal", + "reason": "user mid-turn: 'gstreamer stream for audio is fair'" + }, + { + "text": "No 'record' verb and no video: capture-to-file stays with webcam-cli/reachy_mini. A 'stream audio ' verb over GStreamer alsasrc (subprocess gst-launch-1.0, same dry-run/--probe/--apply split and typed engine-missing exit 2 as webcam_cli/engine.py) IS in scope, so a consumer can attach to the array's processed output without webcam-cli", + "kind": "requirement", + "reason": "user: record verb also, apply for recording" + }, + { + "text": "Video is out of scope. Audio capture verbs ARE in scope: 'stream audio ' and 'record ' over GStreamer alsasrc (subprocess gst-launch-1.0, same dry-run/--probe/--apply split and typed engine-missing exit 2 as webcam_cli/engine.py), so a consumer can attach to or save the array's processed output without webcam-cli", + "kind": "requirement", + "reason": "formatting only: backtick angle-bracket tokens for markdownlint MD033" + } + ] + }, + { + "id": "c10", + "kind": "boundary", + "text": "'doctor' stays an agent-identity check (prompt file + backend consistency + skills); hardware readiness surfaces through 'list' and the typed exit codes, per webcam_cli/cli/_commands/doctor.py:1-18", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "doctor --json shape is unchanged: {healthy, checks:[{id,passed,severity,message,remediation}]} with only identity checks", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c11", + "kind": "boundary", + "text": "Writes are dry-run by default and need --apply (gain set, aec set, any SAVE_CONFIGURATION), following webcam-cli's --probe/--apply three-level split and media-cli's dry-run-by-default convention; reads (list, inspect, doa, aec get, gain get) touch hardware only via read-only control transfers and never mutate firmware state", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "no write path (gain set, aec set, SAVE_CONFIGURATION) issues a control transfer or amixer cset unless --apply is present", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c12", + "kind": "boundary", + "text": "CI stays hardware-free: tests replay synthetic /proc/asound, /sys/bus/usb and /dev trees under tests/fixtures/ via the root= param and monkeypatch the ioctl/subprocess boundary; no apt-get or virtual sound card is added to tests.yml", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "the full suite passes with /dev/snd and /dev/bus/usb absent (fixture root only)", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c13", + "kind": "requirement", + "text": "Console-script/prog mismatch is fixed as part of this work: prog becomes 'microphone' to match [project.scripts] (webcam-cli did the same, __init__.py:9-16, guarded by test_no_user_facing_string_presents_webcam_cli_as_a_command); all template prose (learn.py:15-55, catalog.py:15,84, __init__.py:74, whoami.py:7, overview.py:4) is rewritten and a test_no_template_prose_survives test is ported", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "grep -r 'microphone-cli ' on --help, learn, overview, catalog finds no runnable-command usage; prog == 'microphone'", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c14", + "kind": "requirement", + "text": "media-cli contract: expose both an importable API (microphone_cli.devices.enumerate_devices/resolve, microphone_cli.array.read_doa) and the --json CLI, because media-cli CLAUDE.md:230-245 (Q1) has not decided between import and subprocess; device ids are stable (ALSA card id + USB serial), formats are reported not assumed, and ALSA-visible-but-PipeWire-invisible devices are flagged", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "from microphone_cli.devices import enumerate_devices works in a clean venv and returns the same dicts 'microphone list --json' prints", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c15", + "kind": "requirement", + "text": "Docs follow webcam-cli's shape: README gains Status/Scope/'What comes out'/'Why device identity is the hard part' sections and a verb table; docs/specs, docs/plans, docs/deliveries and a docs/acceptance-*.md land through the devague flow; docs/skill-sources.md is re-synced to guildmaster (webcam-cli's lists 8 skills, this repo's lists 7 and still cites ../devague directly)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "README has Status/Scope/CLI-table sections and docs/skill-sources.md matches webcam-cli's guildmaster provenance", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c16", + "kind": "audience", + "text": "Agents (media-cli, reachy-mini-cli, Culture mesh agents) and operators driving an XVF3800-class USB microphone array from a shell, plus media-cli as an importing/subprocessing consumer", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h16", + "text": "learn.py --json 'audience' names media-cli", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "learn.py names both readers; README Scope section names media-cli as the consumer", + "revisions": [] + }, + { + "id": "c17", + "kind": "before_state", + "text": "microphone-cli is a bare template scaffold (whoami/learn/explain/overview/doctor/cli only, template prose, prog mismatch); the only way to read DoA/AEC or set XVF3800 gain today is reachy_mini's audio_control_utils.py, which needs pyusb and the full SDK", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h17", + "text": "the CHANGELOG entry for the domain release cites the scaffold commit it replaces", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "git show ed00ba0:microphone_cli/cli/__init__.py shows no domain verbs; reachy_mini audio_control_utils.py imports usb.core", + "revisions": [] + }, + { + "id": "c18", + "kind": "after_state", + "text": "'microphone list/inspect/gain/array doa/array aec' work with --json and typed exit codes 0/1/2/3, stdlib only; a blind consumer can enumerate a mic by stable id, read its formats, read azimuth+speech flag, read AEC state, and set gain under --apply \u2014 all without reachy_mini or pyusb installed", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h18", + "text": "a blind-consumer script (like webcam-cli scripts/acceptance/blind-consumer.sh) drives every verb from --json output alone", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "uv run microphone `` --json in a venv with only microphone-cli installed; on-device run tracked in issue #3", + "revisions": [] + }, + { + "id": "c19", + "kind": "why_it_matters", + "text": "media-cli needs a microphone peer the way it needs webcam-cli, and the XVF3800's DoA/AEC controls are currently locked behind a robot SDK; a zero-dep agent-first CLI makes them a composable contract instead of a copy-pasted parameter table", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h19", + "text": "media-cli can consume microphone-cli without a decision on import-vs-subprocess because both surfaces exist", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "media-cli CLAUDE.md:23 routes capture to peers; issue #3 records the device to prove it on", + "revisions": [] + }, + { + "id": "c20", + "kind": "success_signal", + "text": "teken cli doctor --strict stays 26/26 green and coverage >= 60% with every new verb having a catalog entry, learn entry and overview line (parity tests ported from webcam-cli tests/test_cli.py:190-256)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h20", + "text": "CI lint job output shows 26/26 passed", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "uv run teken cli doctor . --strict; uv run pytest --cov=microphone_cli", + "revisions": [] + }, + { + "id": "c21", + "kind": "success_signal", + "text": "On the Reachy Mini Lite, 'microphone array doa --json' returns azimuth within 0.01 rad of reachy_mini's audio_control_utils.py DOA_VALUE_RADIANS read taken within 1 s, and 'array aec get --json' reports >= 5 AEC fields; 'gain set --apply' round-trips to the same value on 'gain get'", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h21", + "text": "acceptance doc records the two DoA readings side by side with timestamps", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "issue #3 checklist; evidence recorded in docs/acceptance-microphone-domain.md", + "revisions": [] + }, + { + "id": "c22", + "kind": "success_signal", + "text": "0 runtime dependencies in pyproject.toml after the domain lands, and every test passes on ubuntu-latest CI with no audio hardware and no apt-get step", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h22", + "text": "tests.yml diff against main is empty", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "grep 'dependencies = \\[\\]' pyproject.toml; CI tests.yml unchanged apart from names", + "revisions": [] + }, + { + "id": "c23", + "kind": "decision", + "text": "AEC surface in v1 is read AND write: 'array aec get' reports converged/bypass/high-pass/echo/mic count/geometry and 'array aec set' toggles PP_ECHOONOFF, SHF_BYPASS, AEC_HPFONOFF under --apply", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "array aec set --apply round-trips on the Reachy Mini Lite (issue #3)", + "revisions": [] + }, + { + "id": "c24", + "kind": "decision", + "text": "'array doa' supports --watch: one JSON object per poll on stdout as JSON Lines (jsonl), --interval seconds, runs until SIGINT or --count; single-shot without --watch", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "array doa --watch --count 3 --json prints exactly 3 lines each parseable by json.loads", + "revisions": [] + }, + { + "id": "c25", + "kind": "decision", + "text": "A generic 'param get ``' / 'param set `` `` --apply' escape hatch covers the full XVF3800 PARAMETERS table (name \u2192 resid, cmdid, count, rw, type); the array backend is an abstraction so more devices and array technologies can be added later", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "param get VERSION returns firmware version; param set on an 'ro' entry exits 1 with a hint; a second backend can register without touching the CLI verbs", + "revisions": [ + { + "text": "A generic 'param get ' / 'param set --apply' escape hatch covers the full XVF3800 PARAMETERS table (name \u2192 resid, cmdid, count, rw, type); the array backend is an abstraction so more devices and array technologies can be added later", + "kind": "decision", + "reason": "formatting only: backtick angle-bracket tokens for markdownlint MD033" + } + ] + }, + { + "id": "c26", + "kind": "decision", + "text": "A 'record `` ``' verb is in scope alongside 'stream audio': both via GStreamer alsasrc subprocess, both dry-run by default, both need --apply because they are physically observable; record is bounded by --duration and --max-bytes like webcam-cli's", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "record without --apply prints the pipeline and writes nothing; with --apply writes a Matroska/Opus or WAV file of bounded size", + "revisions": [ + { + "text": "A 'record ' verb is in scope alongside 'stream audio': both via GStreamer alsasrc subprocess, both dry-run by default, both need --apply because they are physically observable; record is bounded by --duration and --max-bytes like webcam-cli's", + "kind": "decision", + "reason": "formatting only: backtick angle-bracket tokens for markdownlint MD033" + } + ] + }, + { + "id": "c27", + "kind": "assumption", + "text": "A reachy-mini-daemon (running locally today, pid 3180, and on the Reachy Mini at 192.168.1.162) polls DOA_VALUE_RADIANS on the same XVF3800 while the CLI runs; the firmware answers concurrent control reads with status 64 (SERVICER_COMMAND_RETRY) and the CLI must retry like audio_control_utils.py:250-275 (up to 100 attempts, 10 ms apart) rather than fail", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h24", + "text": "a fake ioctl that returns status 64 twice then 0 yields a successful read; a fake that returns 64 forever exits 2 with a 'device busy with another controller' hint", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c28", + "kind": "assumption", + "text": "stream audio and record reuse webcam_cli/engine.py's approach verbatim (cite-don't-import): alsasrc is a core element (engine.py:71), pulsesrc/pipewire are optional (engine.py:87), gst-launch-1.0 is shelled out and its absence is a typed exit 2; on this host gst-launch-1.0 and the pipewire source are present", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h25", + "text": "microphone_cli/engine.py's element list and require_engine() behaviour match webcam_cli/engine.py for the audio subset", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c29", + "kind": "assumption", + "text": "The XVF3800 PARAMETERS table is copied from reachy_mini audio_control_utils.py (Apache-2.0) with attribution, cite-don't-import; resid/cmdid values are firmware-build-specific, so 'inspect' on an array also reports VERSION, BLD_MSG and BLD_REPO_HASH (resid 48) so a mismatch is diagnosable", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h26", + "text": "docs/ names the reachy_mini source file and license for the table; inspect --json on an array includes firmware.version and firmware.build", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c30", + "kind": "requirement", + "text": "The stdlib control-transfer layer packs struct usbdevfs_ctrltransfer exactly as /usr/include/linux/usbdevice_fs.h:40-48 (u8 bRequestType, u8 bRequest, u16 wValue, u16 wIndex, u16 wLength, u32 timeout ms, void* data) and issues USBDEVFS_CONTROL = _IOWR('U', 0, struct); the node must be opened O_RDWR, so on a host without a udev rule /dev/bus/usb/BBB/DDD (default 0664 root:root) yields FORBIDDEN exit 2 whose remediation prints the udev rule line to add", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h27", + "text": "a unit test asserts struct.calcsize of the packed layout equals ctypes.sizeof(the ctypes Structure) and the ioctl number matches the header; the forbidden path's hint contains 'SUBSYSTEM==\"usb\", ATTR{idVendor}=='", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c31", + "kind": "requirement", + "text": "Two arrays with the same VID:PID must be addressable: the stable id is built from the USB serial (sysfs 'serial' attr) with the sysfs device path as fallback; an ambiguous selector exits 1 listing candidates, as webcam_cli/devices.py:470-531 resolve() does", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h28", + "text": "a fixture tree with two 38fb:1001 devices resolves each by serial and rejects the bare product name as ambiguous", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c32", + "kind": "requirement", + "text": "array doa --watch lifecycle: SIGINT ends the loop with exit 0 after flushing; device unplug mid-loop (ENODEV/ENOENT on the node) ends with exit 2 and one error object on stderr, never a traceback; each poll re-resolves nothing (the fd stays open) so USB bus/dev renumbering cannot silently switch devices", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h29", + "text": "a fake ioctl raising OSError(ENODEV) on the third poll produces exactly two stdout lines, one stderr error, exit 2", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c33", + "kind": "requirement", + "text": "param set treats persistence and destructive commands as a separate tier: SAVE_CONFIGURATION, CLEAR_CONFIGURATION, REBOOT, TEST_CORE_BURN, TEST_AEC_DISABLE_CONTROL and the SPECIAL_CMD_* filter/model uploads require --apply AND --allow-persistent, and the hint explains that every other rw write is volatile and reverts on power-cycle \u2014 that volatility is the rollback path", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h30", + "text": "param set REBOOT --apply without --allow-persistent exits 1 and sends nothing; the same with --allow-persistent sends the transfer and is written to the activation log", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c34", + "kind": "boundary", + "text": "param get|set accepts only names present in the table (case-insensitive match, echoed upper-case), rejects 'ro' targets on set and 'wo' targets on get, enforces value count and type before packing, and never lets the user supply raw resid/cmdid bytes", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h31", + "text": "param set on an unknown name, on a ro name, or with the wrong value count each exit 1 with a hint and issue no transfer", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c35", + "kind": "requirement", + "text": "Every --apply action (record, stream audio, gain set, aec set, param set) is appended to an activation log modelled on webcam_cli/activation.py: JSONL under XDG_STATE_HOME with a MICROPHONE_ACTIVATION_LOG override, recording verb, device stable id, parameters written, and timestamps, so a consumer or operator can audit what touched the microphone", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h32", + "text": "after gain set --apply the log's last line parses as JSON with verb, device, and the value written; without --apply nothing is appended", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c36", + "kind": "decision", + "text": "v1 targets a locally attached USB XVF3800 only. Non-goals: reaching a Reachy Mini over the network through its daemon REST, speech-to-text, text-to-speech, and speaker/monitor playback \u2014 those belong to the reachy_mini/reachy_nova stack; microphone-cli must coexist with that project holding the device (busy \u2192 exit 3, DoA/AEC reads still work via the firmware retry path)", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "no network code in microphone_cli/; README Scope section lists STT/TTS/playback/remote as non-goals", + "revisions": [] + }, + { + "id": "c37", + "kind": "decision", + "text": "Sample format is not a first-class set verb: 'inspect' reports the formats/rates the card advertises (/proc/asound/cardN/stream0), and 'stream audio'/'record' negotiate with --rate/--channels/--format exactly as webcam-cli does; the firmware's USB_BIT_DEPTH (resid 48 cmd 8) changes the USB descriptor and needs re-enumeration, so it is reachable only via 'param set USB_BIT_DEPTH --apply --allow-persistent'. Rationale: ALSA hw-params negotiation is the portable path for every USB mic, the firmware knob is XVF3800-only and persistent", + "origin": "llm", + "status": "proposed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "inspect --json lists formats; record --format s16le --rate 16000 negotiates or exits 1 with the advertised list; param set USB_BIT_DEPTH without --allow-persistent exits 1", + "revisions": [] + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "No capture device is attached to the host right now (arecord -l empty, lsusb shows no 38fb/2886 device, WirePlumber default source names the Reachy Mini Audio but it is unplugged), so DoA/AEC and gain cannot be acceptance-tested until the device is connected", + "kind": "unknown_blocking", + "claim_id": "c6", + "resolved": true, + "resolution": "build against fixture trees; on-device acceptance tracked in issue #3 with the Reachy Mini Lite", + "resolution_claim_id": "c12" + }, + { + "id": "v2", + "text": "Whether AUDIO_MGR_MIC_GAIN (firmware float) and the ALSA capture mixer control on the XVF3800's UAC interface are the same knob or two independent stages \u2014 needs the device present and 'amixer -c N contents' to compare", + "kind": "unknown_nonblocking", + "claim_id": "c5", + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v3", + "text": "PipeWire holds the ALSA device open while a session runs; whether 'inspect' can read /proc/asound/cardN/stream0 formats without EBUSY, and whether gain set via amixer is overridden by WirePlumber's saved state", + "kind": "unknown_nonblocking", + "claim_id": "c3", + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v4", + "text": "Whether the PARAMETERS resid/cmdid map is identical on the older 2886:001a ReSpeaker XVF3800 firmware that reachy_mini warns about; only the 38fb:1001 build is known to match", + "kind": "unknown_nonblocking", + "claim_id": "c29", + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v5", + "text": "The usbdevfs path is unverified against real hardware: no writable USB node exists on this host today (no device plugged, other nodes 0664 root:root), so the first end-to-end control transfer happens in issue #3", + "kind": "unknown_nonblocking", + "claim_id": "c30", + "resolved": false, + "resolution": "", + "resolution_claim_id": null + } + ], + "scope_entries": [ + { + "id": "s1", + "surface": "webcam_cli/devices.py (device model, lines 105-159, 286-387)", + "finding": "Enumeration is pure filesystem parsing of /dev/v4l/by-id, /proc/asound/cards and /proc/asound/cardN/pcm*c with a root= injection param; mic identity = ALSA card id + USB sysfs parent. microphone-cli reuses the /proc/asound + sysfs walk for capture cards and drops the video half", + "seeds": [ + "c2" + ] + }, + { + "id": "s2", + "surface": "webcam_cli/access.py:64-89,354-397 and microphone_cli/cli/_errors.py:21-23", + "finding": "webcam-cli distinguishes absent/forbidden/busy with exit 1/2/3 and per-subsystem remediation; microphone-cli's scaffold only defines exit 0/1/2, so the busy category (3) must be added \u2014 media-cli CLAUDE.md:199-201 wants forbidden vs busy kept distinct", + "seeds": [ + "c3" + ] + }, + { + "id": "s3", + "surface": "microphone_cli/cli/__init__.py:64-119 + _commands/cli.py:38-40 + explain/catalog.py + CLAUDE.md 'Adding a verb or noun'", + "finding": "The registration spot is __init__.py:91-93; the rubric (teken cli doctor, 26 checks, currently 26/26 green in both repos) requires explain-per-path, overview_cli_noun_exists, --json everywhere, no traceback; webcam-cli's tests/test_cli.py:190-256 walks the live argparse tree to enforce catalog/learn/overview parity and should be ported", + "seeds": [ + "c4" + ] + }, + { + "id": "s4", + "surface": "webcam_cli/engine.py:110-148 (warm-up) + host amixer 1.2.9", + "finding": "webcam-cli never reads or writes a hardware control, so gain has no sibling convention; ALSA amixer exists on the host and shells out cleanly (zero-dep), and the XVF3800 exposes a separate firmware-level mic gain", + "seeds": [ + "c5" + ] + }, + { + "id": "s5", + "surface": "../reachy_mini/src/reachy_mini/media/audio_control_utils.py:1-330 + docs media_advanced_controls.md:56-108", + "finding": "The target is the XMOS XVF3800 audio processor class (Seeed ReSpeaker XVF3800 boards and Pollen's Reachy Mini Audio card, which is an XVF3800 derivative, per reachy_mini docs hardware.md:46). reachy_mini's audio_control_utils.py PARAMETERS table (name \u2192 resid, cmdid, count, rw, type) is the XVF3800 control-command map and reads DoA via DOA_VALUE_RADIANS; the XMOS control-command appendix is the upstream reference. A Reachy Mini Lite (USB) is available to connect for acceptance testing later", + "seeds": [ + "c6" + ] + }, + { + "id": "s6", + "surface": "pyproject.toml dependencies=[] (both repos) + reachy_mini audio_control_utils.py imports usb.core/libusb_package + host python3 3.12 ctypes/fcntl ok, pyusb not installed", + "finding": "Both siblings hold dependencies=[] deliberately (webcam_cli/engine.py:10-14 refuses even PyGObject); pyusb is not on the host, so DoA/AEC must go through usbdevfs ioctl in stdlib or break the constraint", + "seeds": [ + "c7" + ] + }, + { + "id": "s7", + "surface": "../reachy_mini audio_control_utils.py:353-411 (init_respeaker_usb) + /etc/udev/rules.d/{60-respeaker,99-reachy-mini-audio}.rules", + "finding": "The SDK tries 38fb:1001 then 2886:001a; host udev rules already grant MODE=0666 to 38fb:1001 and 2886:0018, so non-root control transfers work for the Reachy device without new setup", + "seeds": [ + "c8" + ] + }, + { + "id": "s8", + "surface": "webcam_cli/cli/_commands/stream.py (stream audio) + media-cli CLAUDE.md:23", + "finding": "webcam-cli already streams audio via GStreamer and media-cli routes capture to it; duplicating capture here would create two owners for the same lane", + "seeds": [ + "c9" + ] + }, + { + "id": "s9", + "surface": "webcam_cli/cli/_commands/doctor.py:1-18 and catalog _DOCTOR", + "finding": "webcam-cli explicitly kept doctor free of device checks and points readiness questions at list; the rubric depends on doctor's exact {healthy,checks} shape", + "seeds": [ + "c10" + ] + }, + { + "id": "s10", + "surface": "webcam-cli learn.py:27-142 hardware-touch split + media-cli CLAUDE.md:387-392", + "finding": "Both siblings treat physically observable side effects as opt-in via --apply; DoA read is a read-only vendor request so it is safe without --apply", + "seeds": [ + "c11" + ] + }, + { + "id": "s11", + "surface": ".github/workflows/tests.yml (identical between repos, no apt-get) + webcam-cli tests/fixtures/{host-baseline,host-renumbered,camera-only,degraded}", + "finding": "webcam-cli's CI installs nothing domain-specific and every task before on-host acceptance was built hardware-free; ubuntu-latest has no capture device", + "seeds": [ + "c12" + ] + }, + { + "id": "s12", + "surface": "microphone_cli template-prose grep + pyproject [project.scripts] + webcam_cli/cli/__init__.py:9-16", + "finding": "Nine template strings remain in microphone_cli/ and prog='microphone-cli' contradicts the installed 'microphone' binary; webcam-cli resolved both with a regression test", + "seeds": [ + "c13" + ] + }, + { + "id": "s13", + "surface": "../media-cli (grep: zero code hits for microphone/webcam; CLAUDE.md:23,41-42,117-158,230-267)", + "finding": "media-cli is itself a scaffold with no device code and an unresolved import-vs-subprocess question; its only settled asks are stable identity, format reporting, forbidden-vs-busy distinction and dry-run-by-default", + "seeds": [ + "c14" + ] + }, + { + "id": "s14", + "surface": "README.md headings (both repos) + docs/ listings + docs/skill-sources.md diff", + "finding": "microphone-cli's README is still the template shape and its skill-sources ledger lags webcam-cli's; webcam-cli carries spec/plan/delivery/acceptance docs for its feature", + "seeds": [ + "c15" + ] + }, + { + "id": "s15", + "surface": "challenge pass / adjacent-systems lens: reachy_mini daemon routers/state.py:68,122 + audio_control_utils.py:250-275 + pgrep reachy-mini-daemon", + "finding": "the daemon reads DoA concurrently through the same vendor-control path; the firmware's retry status is the only arbitration, so the CLI must honour it", + "seeds": [ + "c27" + ] + }, + { + "id": "s16", + "surface": "challenge pass / adjacent-systems lens: webcam_cli/engine.py:65-87,1052-1122 + which gst-launch-1.0", + "finding": "webcam-cli already solved GStreamer detection and alsasrc pipeline building for audio; reusing it avoids a second engine", + "seeds": [ + "c28" + ] + }, + { + "id": "s17", + "surface": "challenge pass / unstated-assumptions lens: reachy_mini LICENSE (Apache-2.0) + PARAMETERS table provenance", + "finding": "the table's provenance and firmware-specificity were nowhere in the frame", + "seeds": [ + "c29" + ] + }, + { + "id": "s18", + "surface": "challenge pass / unstated-assumptions lens: /usr/include/linux/usbdevice_fs.h:40-48,187 + ls -l /dev/bus/usb", + "finding": "the ioctl struct and number are verifiable from the header without hardware; permissions on a fresh host are the likely first failure", + "seeds": [ + "c30" + ] + }, + { + "id": "s19", + "surface": "challenge pass / overlooked-actors lens: webcam_cli/devices.py:470-531 resolve() + /sys/bus/usb/devices/*/serial", + "finding": "multi-array hosts were not considered; serial is the only stable discriminator", + "seeds": [ + "c31" + ] + }, + { + "id": "s20", + "surface": "challenge pass / lifecycle lens: --watch mode (decision c24) + reachy_mini examples/debug/sound_doa.py loop", + "finding": "the watch decision named the output format but not termination or unplug behaviour", + "seeds": [ + "c32" + ] + }, + { + "id": "s21", + "surface": "challenge pass / reversibility lens: PARAMETERS table 'wo' entries resid 48 (REBOOT/SAVE/CLEAR) and SPECIAL_CMD_* filter coefficient uploads", + "finding": "an unguarded generic escape hatch can persist or brick the array; volatile writes are self-reverting, persistent ones are not", + "seeds": [ + "c33" + ] + }, + { + "id": "s22", + "surface": "challenge pass / security lens: audio_control_utils.py write() validation (ro check, count check)", + "finding": "the SDK validates only ro and count; type validation and raw-byte refusal are additions", + "seeds": [ + "c34" + ] + }, + { + "id": "s23", + "surface": "challenge pass / observability-and-consent lens: webcam_cli/activation.py:28-129 + media-cli CLAUDE.md consent posture", + "finding": "the frame carried dry-run-by-default but no audit trail; microphones are privacy-sensitive and webcam-cli already has the pattern", + "seeds": [ + "c35" + ] + }, + { + "id": "s24", + "surface": "challenge pass / concurrency lens: PipeWire/WirePlumber (pgrep) + usbdevfs vs snd-usb-audio binding", + "finding": "clean beyond the retry assumption above and parked items v2/v3: device-recipient vendor control transfers do not need the audio interface claimed, so snd-usb-audio/PipeWire holding the PCM does not block DoA/AEC reads (the SDK never detaches the kernel driver); residual risk is only the ALSA mixer vs WirePlumber saved-state interplay already parked", + "seeds": [] + }, + { + "id": "s25", + "surface": "challenge pass / cheap-probes lens: usbdevice_fs.h, which gst-launch-1.0, gst-inspect-1.0 pipewire, ls -l /dev/bus/usb, pgrep reachy-mini-daemon, reachy_mini LICENSE", + "finding": "all probes were read-only; an end-to-end control-transfer probe is impossible today (no writable USB node) and is deferred to issue #3", + "seeds": [] + }, + { + "id": "s26", + "surface": "challenge pass / cheap-probes lens: live device after user connected it (lsusb, /proc/asound/cards, journalctl -k)", + "finding": "the user reports the Reachy is connected over USB but nothing enumerates on this host (no 38fb/2886 id, no capture card, no kernel USB event), so the read-only DoA probe could not run; the ioctl path stays parked (v5) for issue #3", + "seeds": [] + } + ], + "lapses": [ + { + "id": "l1", + "code": "assumption-for-measurement", + "what": "the concurrency-lens scope entry asserts device-recipient vendor control transfers need no claimed interface while snd-usb-audio is bound; that is recalled kernel behaviour (drivers/usb/core/devio.c check_ctrlrecip) plus the SDK not detaching the driver, not something read or measured in this pass", + "skipped_check": "", + "refs": [ + "challenge pass / concurrency lens" + ], + "origin": "llm", + "status": "approved" + }, + { + "id": "l2", + "code": "assumption-for-measurement", + "what": "t9 engine.build_audio_record_argv bounds a recording via alsasrc num-buffers with a fixed latency-time=10000 (10 ms period) so num_buffers = duration/0.01; the task agent reports this was never verified against a real alsasrc, so the bound may be inexact on hardware", + "skipped_check": "", + "refs": [ + "t9 microphone_cli/engine.py _bounded_source_props" + ], + "origin": "llm", + "status": "proposed" + }, + { + "id": "l3", + "code": "assumption-for-measurement", + "what": "t2 fixture trees reconstruct /proc/asound/cardN/stream0 and pcm*c/info layouts from the USB-audio driver's known output and webcam-cli's fixtures, not from a live Reachy Mini; _channels_from_stream may need adjusting on real hardware", + "skipped_check": "", + "refs": [ + "t2 tests/fixtures/** + microphone_cli/devices.py _channels_from_stream" + ], + "origin": "llm", + "status": "proposed" + }, + { + "id": "l4", + "code": "control-absent", + "what": "t2 pipewire_visible can only be True or None: enumerate_devices drops non-capture cards first and the real discriminator (udev ACP_IGNORE) is not readable from sysfs/procfs, so the field is 'nothing in procfs says otherwise', not a confirmed PipeWire node", + "skipped_check": "", + "refs": [ + "t2 microphone_cli/devices.py _pipewire_visible" + ], + "origin": "llm", + "status": "proposed" + } + ], + "obligations": [] +} diff --git a/.devague/plans/microphone-domain.json b/.devague/plans/microphone-domain.json new file mode 100644 index 0000000..896fde0 --- /dev/null +++ b/.devague/plans/microphone-domain.json @@ -0,0 +1,682 @@ +{ + "slug": "microphone-domain", + "title": "microphone domain", + "frame_slug": "microphone-domain", + "schema_version": 5, + "status": "exported", + "created": "2026-09-06T18:38:45Z", + "updated": "2026-09-06T18:42:01Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "microphone-cli enumerates USB microphones and arrays by stable id, inspects channels and formats, gets and sets gain, and reads direction-of-arrival and AEC state from XVF3800-class array firmware \u2014 zero runtime deps, --json everywhere, and a contract media-cli can compose", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h1", + "kind": "honesty", + "text": "each verb named in the announcement exists in the live argparse tree and has a catalog entry", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c2", + "kind": "requirement", + "text": "Domain modules land under microphone_cli/ as frozen dataclasses with as_dict(), mirroring webcam_cli/devices.py:105-159 (VideoNode/AudioCard/LogicalDevice) \u2014 AudioCard.alsa_address 'hw:CARD=...' is the stable handle, card index is ephemeral", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h2", + "kind": "honesty", + "text": "list output on the host-baseline fixture tree is byte-identical across a renumber fixture (card index changes, stable_id does not)", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c3", + "kind": "requirement", + "text": "Access model reuses webcam_cli/access.py's AccessState {ok,absent,forbidden,busy} \u2192 exit 1/2/3, with audio-group remediation for FORBIDDEN and /proc/*/fd holder lookup for BUSY; this repo's _errors.py (EXIT_SUCCESS/USER/ENV only) gains EXIT_BUSY_ERROR=3", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h3", + "kind": "honesty", + "text": "EXIT_BUSY_ERROR=3 is raised when another process holds the PCM open, with the holder pid/command in the message when /proc is readable", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c4", + "kind": "requirement", + "text": "New verbs: 'list' (--root PATH, --json \u2192 {devices,count}), 'inspect ``' (channels, formats, rates from /proc/asound/cardN/stream0 and pcm*c), 'gain get|set `` [--apply]', 'array doa ``', 'array aec get|set'; each registered in _build_parser, given a catalog.py entry, a learn.py command-map entry, an overview._VERBS line, and every noun group exposes '`` overview' with parser_class=type(p)", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h4", + "kind": "honesty", + "text": "tests/test_cli.py asserts registered_paths == known_paths and every path appears in overview._VERBS and learn commands", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c5", + "kind": "requirement", + "text": "Gain get/set is new ground with no webcam-cli precedent (webcam_cli has no control API; only warm-up frame discard, engine.py:110-148). Implement via ALSA mixer: 'amixer -c `` cget/cset' subprocess (present, alsa-utils 1.2.9) for generic USB mics, and AUDIO_MGR_MIC_GAIN (resid 35, cmd 0, float) via the XVF3800 vendor control path for arrays", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h5", + "kind": "honesty", + "text": "gain get/set on a non-array USB mic works through amixer alone; on an XVF3800 it also reports AUDIO_MGR_MIC_GAIN", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c6", + "kind": "requirement", + "text": "DoA and AEC are read/written through USB vendor control transfers exactly as reachy_mini/media/audio_control_utils.py does for the XVF3800: bRequest=0, wValue=cmdid (|0x80 for read), wIndex=resid, CTRL_TYPE_VENDOR|RECIPIENT_DEVICE; DOA_VALUE_RADIANS=(resid 20, cmd 19, 2 floats: azimuth radians + speech flag); AEC params on resid 33 (AEC_AECCONVERGED cmd 3 ro, AEC_HPFONOFF cmd 1 rw, SHF_BYPASS cmd 70 rw, AEC_NUM_MICS cmd 71, AEC_MIC_ARRAY_GEO cmd 74) and PP_ECHOONOFF (resid 17, cmd 23); read status byte 0=ok, 64=retry; the XMOS control-command appendix (XM-014888-PC) is the upstream reference", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h6", + "kind": "honesty", + "text": "a fake ioctl layer replays the SDK's documented request bytes (bRequest 0, wValue cmd|0x80, wIndex resid) and the parser decodes status byte 0/64 and little-endian floats exactly as audio_control_utils.py does", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c9", + "kind": "requirement", + "text": "Video is out of scope. Audio capture verbs ARE in scope: 'stream audio ``' and 'record `` ``' over GStreamer alsasrc (subprocess gst-launch-1.0, same dry-run/--probe/--apply split and typed engine-missing exit 2 as webcam_cli/engine.py), so a consumer can attach to or save the array's processed output without webcam-cli", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h9", + "kind": "honesty", + "text": "stream audio never opens the device without --apply; dry-run prints the pipeline argv and exits 0 with hardware_touched=false", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h23", + "kind": "honesty", + "text": "record without --apply writes no file; with --apply the output is bounded by --duration/--max-bytes", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c10", + "kind": "boundary", + "text": "'doctor' stays an agent-identity check (prompt file + backend consistency + skills); hardware readiness surfaces through 'list' and the typed exit codes, per webcam_cli/cli/_commands/doctor.py:1-18", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h10", + "kind": "honesty", + "text": "doctor --json shape is unchanged: {healthy, checks:[{id,passed,severity,message,remediation}]} with only identity checks", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c11", + "kind": "boundary", + "text": "Writes are dry-run by default and need --apply (gain set, aec set, any SAVE_CONFIGURATION), following webcam-cli's --probe/--apply three-level split and media-cli's dry-run-by-default convention; reads (list, inspect, doa, aec get, gain get) touch hardware only via read-only control transfers and never mutate firmware state", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h11", + "kind": "honesty", + "text": "no write path (gain set, aec set, SAVE_CONFIGURATION) issues a control transfer or amixer cset unless --apply is present", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c12", + "kind": "boundary", + "text": "CI stays hardware-free: tests replay synthetic /proc/asound, /sys/bus/usb and /dev trees under tests/fixtures/ via the root= param and monkeypatch the ioctl/subprocess boundary; no apt-get or virtual sound card is added to tests.yml", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h12", + "kind": "honesty", + "text": "the full suite passes with /dev/snd and /dev/bus/usb absent (fixture root only)", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c13", + "kind": "requirement", + "text": "Console-script/prog mismatch is fixed as part of this work: prog becomes 'microphone' to match [project.scripts] (webcam-cli did the same, __init__.py:9-16, guarded by test_no_user_facing_string_presents_webcam_cli_as_a_command); all template prose (learn.py:15-55, catalog.py:15,84, __init__.py:74, whoami.py:7, overview.py:4) is rewritten and a test_no_template_prose_survives test is ported", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h13", + "kind": "honesty", + "text": "grep -r 'microphone-cli ' on --help, learn, overview, catalog finds no runnable-command usage; prog == 'microphone'", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c14", + "kind": "requirement", + "text": "media-cli contract: expose both an importable API (microphone_cli.devices.enumerate_devices/resolve, microphone_cli.array.read_doa) and the --json CLI, because media-cli CLAUDE.md:230-245 (Q1) has not decided between import and subprocess; device ids are stable (ALSA card id + USB serial), formats are reported not assumed, and ALSA-visible-but-PipeWire-invisible devices are flagged", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h14", + "kind": "honesty", + "text": "from microphone_cli.devices import enumerate_devices works in a clean venv and returns the same dicts 'microphone list --json' prints", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c15", + "kind": "requirement", + "text": "Docs follow webcam-cli's shape: README gains Status/Scope/'What comes out'/'Why device identity is the hard part' sections and a verb table; docs/specs, docs/plans, docs/deliveries and a docs/acceptance-*.md land through the devague flow; docs/skill-sources.md is re-synced to guildmaster (webcam-cli's lists 8 skills, this repo's lists 7 and still cites ../devague directly)", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h15", + "kind": "honesty", + "text": "README has Status/Scope/CLI-table sections and docs/skill-sources.md matches webcam-cli's guildmaster provenance", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c16", + "kind": "audience", + "text": "Agents (media-cli, reachy-mini-cli, Culture mesh agents) and operators driving an XVF3800-class USB microphone array from a shell, plus media-cli as an importing/subprocessing consumer", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h16", + "kind": "honesty", + "text": "learn.py --json 'audience' names media-cli", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c17", + "kind": "before_state", + "text": "microphone-cli is a bare template scaffold (whoami/learn/explain/overview/doctor/cli only, template prose, prog mismatch); the only way to read DoA/AEC or set XVF3800 gain today is reachy_mini's audio_control_utils.py, which needs pyusb and the full SDK", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h17", + "kind": "honesty", + "text": "the CHANGELOG entry for the domain release cites the scaffold commit it replaces", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c18", + "kind": "after_state", + "text": "'microphone list/inspect/gain/array doa/array aec' work with --json and typed exit codes 0/1/2/3, stdlib only; a blind consumer can enumerate a mic by stable id, read its formats, read azimuth+speech flag, read AEC state, and set gain under --apply \u2014 all without reachy_mini or pyusb installed", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h18", + "kind": "honesty", + "text": "a blind-consumer script (like webcam-cli scripts/acceptance/blind-consumer.sh) drives every verb from --json output alone", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c19", + "kind": "why_it_matters", + "text": "media-cli needs a microphone peer the way it needs webcam-cli, and the XVF3800's DoA/AEC controls are currently locked behind a robot SDK; a zero-dep agent-first CLI makes them a composable contract instead of a copy-pasted parameter table", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h19", + "kind": "honesty", + "text": "media-cli can consume microphone-cli without a decision on import-vs-subprocess because both surfaces exist", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c20", + "kind": "success_signal", + "text": "teken cli doctor --strict stays 26/26 green and coverage >= 60% with every new verb having a catalog entry, learn entry and overview line (parity tests ported from webcam-cli tests/test_cli.py:190-256)", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h20", + "kind": "honesty", + "text": "CI lint job output shows 26/26 passed", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c21", + "kind": "success_signal", + "text": "On the Reachy Mini Lite, 'microphone array doa --json' returns azimuth within 0.01 rad of reachy_mini's audio_control_utils.py DOA_VALUE_RADIANS read taken within 1 s, and 'array aec get --json' reports >= 5 AEC fields; 'gain set --apply' round-trips to the same value on 'gain get'", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h21", + "kind": "honesty", + "text": "acceptance doc records the two DoA readings side by side with timestamps", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c22", + "kind": "success_signal", + "text": "0 runtime dependencies in pyproject.toml after the domain lands, and every test passes on ubuntu-latest CI with no audio hardware and no apt-get step", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h22", + "kind": "honesty", + "text": "tests.yml diff against main is empty", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c30", + "kind": "requirement", + "text": "The stdlib control-transfer layer packs struct usbdevfs_ctrltransfer exactly as /usr/include/linux/usbdevice_fs.h:40-48 (u8 bRequestType, u8 bRequest, u16 wValue, u16 wIndex, u16 wLength, u32 timeout ms, void* data) and issues USBDEVFS_CONTROL = _IOWR('U', 0, struct); the node must be opened O_RDWR, so on a host without a udev rule /dev/bus/usb/BBB/DDD (default 0664 root:root) yields FORBIDDEN exit 2 whose remediation prints the udev rule line to add", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h27", + "kind": "honesty", + "text": "a unit test asserts struct.calcsize of the packed layout equals ctypes.sizeof(the ctypes Structure) and the ioctl number matches the header; the forbidden path's hint contains 'SUBSYSTEM==\"usb\", ATTR{idVendor}=='", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c31", + "kind": "requirement", + "text": "Two arrays with the same VID:PID must be addressable: the stable id is built from the USB serial (sysfs 'serial' attr) with the sysfs device path as fallback; an ambiguous selector exits 1 listing candidates, as webcam_cli/devices.py:470-531 resolve() does", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h28", + "kind": "honesty", + "text": "a fixture tree with two 38fb:1001 devices resolves each by serial and rejects the bare product name as ambiguous", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c32", + "kind": "requirement", + "text": "array doa --watch lifecycle: SIGINT ends the loop with exit 0 after flushing; device unplug mid-loop (ENODEV/ENOENT on the node) ends with exit 2 and one error object on stderr, never a traceback; each poll re-resolves nothing (the fd stays open) so USB bus/dev renumbering cannot silently switch devices", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h29", + "kind": "honesty", + "text": "a fake ioctl raising OSError(ENODEV) on the third poll produces exactly two stdout lines, one stderr error, exit 2", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c33", + "kind": "requirement", + "text": "param set treats persistence and destructive commands as a separate tier: SAVE_CONFIGURATION, CLEAR_CONFIGURATION, REBOOT, TEST_CORE_BURN, TEST_AEC_DISABLE_CONTROL and the SPECIAL_CMD_* filter/model uploads require --apply AND --allow-persistent, and the hint explains that every other rw write is volatile and reverts on power-cycle \u2014 that volatility is the rollback path", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h30", + "kind": "honesty", + "text": "param set REBOOT --apply without --allow-persistent exits 1 and sends nothing; the same with --allow-persistent sends the transfer and is written to the activation log", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c34", + "kind": "boundary", + "text": "param get|set accepts only names present in the table (case-insensitive match, echoed upper-case), rejects 'ro' targets on set and 'wo' targets on get, enforces value count and type before packing, and never lets the user supply raw resid/cmdid bytes", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h31", + "kind": "honesty", + "text": "param set on an unknown name, on a ro name, or with the wrong value count each exit 1 with a hint and issue no transfer", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c35", + "kind": "requirement", + "text": "Every --apply action (record, stream audio, gain set, aec set, param set) is appended to an activation log modelled on webcam_cli/activation.py: JSONL under XDG_STATE_HOME with a MICROPHONE_ACTIVATION_LOG override, recording verb, device stable id, parameters written, and timestamps, so a consumer or operator can audit what touched the microphone", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h32", + "kind": "honesty", + "text": "after gain set --apply the log's last line parses as JSON with verb, device, and the value written; without --apply nothing is appended", + "deferred": false, + "deferred_reason": "" + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Typed errors and access model: EXIT_BUSY_ERROR=3 in cli/_errors.py and a new microphone_cli/access.py with AccessState {ok,absent,forbidden,busy}, check_access/require_access, /proc/*/fd holder lookup, audio-group and udev-rule remediations", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "microphone_cli/access.py exposes AccessState, AccessReport, check_access(path, kind), require_access(path, kind) and access_error(); tests/test_access.py covers all four states with a fake /proc tree", + "EXIT_BUSY_ERROR == 3 is exported from cli/_errors.py and access_error() maps absent\u21921, forbidden\u21922, busy\u21923 with the holder pid/command in the busy message when /proc is readable", + "forbidden on a /dev/bus/usb node produces a hint containing the udev rule line 'SUBSYSTEM==\"usb\", ATTR{idVendor}=='; forbidden on /dev/snd names the audio group" + ], + "deps": [], + "covers": [ + "c3", + "h3" + ], + "instruction": "Cite webcam_cli/access.py (webcam-cli repo, ../webcam-cli) and adapt: keep kind='audio' and add kind='usb'. Do not touch cli/__init__.py. Files owned: microphone_cli/cli/_errors.py, microphone_cli/access.py, tests/test_access.py." + }, + { + "id": "t2", + "summary": "Device identity: microphone_cli/devices.py enumerates capture cards from /proc/asound + sysfs USB parent, builds stable ids from USB serial (sysfs path fallback), flags XVF3800 arrays by idVendor:idProduct (38fb:1001, 2886:001a), and resolve() refuses ambiguous or raw hw:N selectors; fixture trees under tests/fixtures/", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "enumerate_devices(root=...) on tests/fixtures/host-baseline and tests/fixtures/host-renumbered returns identical stable_id values while card index differs", + "a tests/fixtures/two-arrays tree with two 38fb:1001 devices resolves each by serial and resolve('Reachy Mini Audio') exits 1 listing both candidates", + "from microphone_cli.devices import enumerate_devices, resolve works with no other imports; each device's as_dict() carries stable_id, label, alsa_address, card_id, usb_path, usb_ids, serial, is_array, channels, and pipewire_visible (bool, from a sysfs/procfs heuristic or None)" + ], + "deps": [], + "covers": [ + "c2", + "h2", + "c31", + "h28", + "c14", + "h14" + ], + "instruction": "Cite webcam_cli/devices.py:105-159,286-387,470-531 and drop the video half. Frozen dataclasses with as_dict(). Fixture trees are synthetic /proc/asound, /sys/bus/usb/devices, /sys/class/sound files only \u2014 no real captures. Files owned: microphone_cli/devices.py, tests/test_devices.py, tests/fixtures/**." + }, + { + "id": "t3", + "summary": "XVF3800 control layer with stdlib only: microphone_cli/usbctl.py (usbdevfs_ctrltransfer ctypes struct, USBDEVFS_CONTROL ioctl, O_RDWR open, device lookup by idVendor/idProduct/serial in sysfs) and microphone_cli/xvf3800.py (PARAMETERS table vendored from reachy_mini with Apache-2.0 attribution, typed pack/unpack, read with status-64 retry loop, write, firmware_info())", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "tests/test_usbctl.py asserts ctypes.sizeof(UsbdevfsCtrlTransfer) == struct.calcsize('BBHHHI' + 'P' with natural alignment) and USBDEVFS_CONTROL == 0xC0185500 on 64-bit", + "with a fake ioctl, read('DOA_VALUE_RADIANS') sends bRequestType 0xC0, bRequest 0, wValue 0x80|19, wIndex 20, wLength 9 and decodes two little-endian floats after a status byte; a status-64 reply twice then 0 succeeds; 64 forever raises CliError(code=2)", + "write('AUDIO_MGR_MIC_GAIN', [0.5]) sends bRequestType 0x40, wValue 0, wIndex 35, payload struct.pack('`' with --json; list --json on tests/fixtures/host-baseline prints {devices:[...], count:N} and the dicts equal enumerate_devices().as_dict()", + "inspect --json on an array fixture includes formats[], rates[], channels and a firmware{version,build} block read through a fake ioctl; on a non-array mic firmware is null" + ], + "deps": [ + "t1", + "t2", + "t3" + ], + "covers": [ + "c4" + ], + "instruction": "Cite ../webcam-cli/webcam_cli/cli/_commands/list_devices.py. Read stream0 via the devices root param so tests stay hardware-free. Files owned: microphone_cli/cli/_commands/list_devices.py, microphone_cli/cli/_commands/inspect.py, tests/test_list.py, tests/test_inspect.py." + }, + { + "id": "t6", + "summary": "Verb 'gain get|set': _commands/gain.py plus microphone_cli/mixer.py wrapping 'amixer -c `` cget/cset' for the ALSA capture control, and AUDIO_MGR_MIC_GAIN through xvf3800 on arrays; set is dry-run unless --apply and is written to the activation log", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "gain get --json on a non-array fixture reports {alsa:{control,value,min,max}} using a fake subprocess.run; on an array it adds {firmware:{mic_gain}}", + "gain set `` 0.5 without --apply prints the planned amixer argv/control transfer and calls neither; with --apply both are issued and the activation log gains one line" + ], + "deps": [ + "t1", + "t3", + "t4" + ], + "covers": [ + "c5", + "h5", + "c11", + "h11" + ], + "instruction": "Parse 'amixer -c N contents' output for the capture-volume control; never call amixer in tests (monkeypatch subprocess.run). Files owned: microphone_cli/cli/_commands/gain.py, microphone_cli/mixer.py, tests/test_gain.py." + }, + { + "id": "t7", + "summary": "Noun 'array': _commands/array.py with 'array overview', 'array doa ``' (single-shot or --watch JSON Lines with --interval/--count, SIGINT\u2192exit 0, ENODEV\u2192exit 2) and 'array aec get|set' (converged, bypass, high-pass, echo on/off, mic count, geometry; set needs --apply and logs)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "array doa --json prints {azimuth_rad, speech, source:'DOA_VALUE_RADIANS'} once; --watch --count 3 prints exactly 3 JSON lines; a fake ioctl raising OSError(ENODEV) on the third poll yields two stdout lines, one stderr error object, exit 2", + "array aec get --json reports at least converged, bypass, hpf, echo, num_mics, geometry; array aec set --echo off without --apply sends nothing; with --apply writes PP_ECHOONOFF and appends to the activation log", + "the subparser group is built with parser_class=type(p) and bare 'array' prints the overview" + ], + "deps": [ + "t3", + "t4" + ], + "covers": [ + "c32", + "h29" + ], + "instruction": "Cite ../webcam-cli/webcam_cli/cli/_commands/stream.py:1603-1672 for the noun-group pattern and cli.py:38-40 for parser_class. Raw firmware radians, no coordinate transform. Files owned: microphone_cli/cli/_commands/array.py, tests/test_array.py." + }, + { + "id": "t8", + "summary": "Noun 'param': _commands/param.py with 'param overview', 'param list', 'param get ``', 'param set `` `` --apply [--allow-persistent]' over the full XVF3800 table; validates name/rw/count/type before packing; persistent/destructive names need --allow-persistent", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "param set on an unknown name, a 'ro' name, or the wrong value count exits 1 with a hint and issues no transfer; param get on a 'wo' name exits 1", + "param set REBOOT 1 --apply exits 1 with a hint naming --allow-persistent and explaining volatile-vs-persistent; with --allow-persistent the transfer is sent and logged; SAVE_CONFIGURATION, CLEAR_CONFIGURATION, TEST_CORE_BURN, TEST_AEC_DISABLE_CONTROL and SPECIAL_CMD_* are in the same tier", + "param list --json emits every table entry with name, resid, cmdid, count, access, type, persistent(bool)" + ], + "deps": [ + "t3", + "t4" + ], + "covers": [ + "c33", + "h30", + "c34", + "h31" + ], + "instruction": "Names match case-insensitively and echo upper-case. The persistent tier is a frozenset in xvf3800.py consumed here. Files owned: microphone_cli/cli/_commands/param.py, tests/test_param.py (may add PERSISTENT set to microphone_cli/xvf3800.py only if t3 did not)." + }, + { + "id": "t9", + "summary": "GStreamer audio engine: microphone_cli/engine.py detects gst-launch-1.0/gst-inspect-1.0 and the alsasrc/matroskamux/opusenc elements, and builds pure argv for an audio stream (RTP/UDP passthrough or opus) and a bounded record (Matroska/Opus or WAV) from an alsa_address, rate, channels, format", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "detect() with shutil.which monkeypatched to None returns Capability(available=False) and require_engine() raises CliError(code=2) whose hint names the apt package", + "build_audio_stream_argv(...) and build_audio_record_argv(...) are pure (no subprocess) and tests assert the exact argv for passthrough and opus, including '-e' for EOS on shutdown and a queue element" + ], + "deps": [], + "covers": [], + "instruction": "Cite the audio subset of ../webcam-cli/webcam_cli/engine.py (lines 65-87 element lists, 1052-1122 audio pipeline builders, 351-369 require_engine). No PyGObject/gi import. Files owned: microphone_cli/engine.py, tests/test_engine.py." + }, + { + "id": "t10", + "summary": "Verbs 'stream audio' and 'record': _commands/stream.py (noun with overview + 'stream audio ``' --port --rate --channels --format --encode --apply --probe) and _commands/record.py ('record `` ``' --duration --max-bytes --rate --channels --format --apply --probe); dry-run by default, engine-missing exit 2, busy exit 3, activation-logged", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "stream audio and record without --apply print the pipeline argv, set hardware_touched=false in --json, write no file and spawn nothing (subprocess booby-trapped in tests)", + "record --apply with a fake spawn honours --duration and --max-bytes (stopped_reason in the JSON), writes to the given path only, and appends an activation line; --duration > 3600 or --max-bytes > 4GiB exits 1" + ], + "deps": [ + "t1", + "t2", + "t4", + "t9" + ], + "covers": [ + "c9", + "h9", + "h23" + ], + "instruction": "Cite ../webcam-cli/webcam_cli/cli/_commands/stream.py and record.py, audio paths only; keep the dry-run/--probe/--apply three-level split and the _payload keys. Files owned: microphone_cli/cli/_commands/stream.py, microphone_cli/cli/_commands/record.py, tests/test_stream.py, tests/test_record.py." + }, + { + "id": "t11", + "summary": "Surface wiring: register every new module in cli/__init__.py, set prog='microphone', rewrite learn.py (_TEXT and _as_json_payload with hardware-touch split, exit codes 0/1/2/3, audience naming media-cli), overview.py _VERBS/_HARDWARE/_CONTRACTS, explain/catalog.py entries for every path, purge template prose, keep doctor unchanged; port webcam-cli's parity and no-template-prose tests", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "tests/test_cli.py walks the live argparse tree and asserts registered_paths == known_paths, every path appears in overview._VERBS and learn commands, no help/learn/overview/catalog text contains 'microphone-cli ' as a command or the words clonable/template/scaffold", + "uv run teken cli doctor . --strict passes 26/26; doctor --json shape is unchanged; learn --json audience names media-cli", + "the full suite passes with /dev/snd and /dev/bus/usb absent and coverage >= 60%" + ], + "deps": [ + "t5", + "t6", + "t7", + "t8", + "t10" + ], + "covers": [ + "c1", + "h1", + "c4", + "h4", + "c10", + "h10", + "c12", + "h12", + "c13", + "h13", + "c16", + "h16", + "c20", + "h20" + ], + "instruction": "This is the only task that edits cli/__init__.py, learn.py, overview.py, catalog.py. Cite ../webcam-cli/webcam_cli/cli/_commands/{learn,overview}.py, explain/catalog.py and tests/test_cli.py:190-327. Files owned: those four modules, tests/test_cli.py, tests/test_cli_introspection.py." + }, + { + "id": "t12", + "summary": "Docs and release: README rewritten in webcam-cli's shape (Status, Scope with non-goals: video, remote, STT/TTS, playback; CLI verb table; What comes out; Why device identity is the hard part), CLAUDE.md updated from scaffold-state to domain-state, docs/skill-sources.md re-synced to guildmaster, XVF3800 table attribution doc, CHANGELOG entry citing scaffold commit 5f9b1bd, version bump; CI workflows untouched", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "README has Status, Scope, CLI table, What comes out sections; markdownlint-cli2 passes on all changed markdown", + "CHANGELOG top entry names 5f9b1bd and the new verbs; pyproject version is bumped; git diff main -- .github/workflows is empty; pyproject dependencies == []" + ], + "deps": [ + "t11" + ], + "covers": [ + "c15", + "h15", + "c17", + "h17", + "c19", + "h19", + "c22", + "h22" + ], + "instruction": "Use the version-bump skill. Cite ../webcam-cli/README.md headings and CLAUDE.md structure. Files owned: README.md, CLAUDE.md, CHANGELOG.md, pyproject.toml (version only), docs/skill-sources.md, docs/xvf3800-parameters.md." + }, + { + "id": "t13", + "summary": "On-device acceptance (issue #3): scripts/acceptance/run.sh and blind-consumer.sh drive every verb from --json alone against the Reachy Mini Lite; compare array doa with reachy_mini's DOA_VALUE_RADIANS read; record evidence and the parked gain/PipeWire unknowns in docs/acceptance-microphone-domain.md", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "docs/acceptance-microphone-domain.md records: list/inspect output, two DoA readings (CLI vs reachy_mini) with timestamps within 1 s and |delta| <= 0.01 rad, aec get with >= 5 fields, gain set/get round-trip, and the resolution of parks v2/v3/v4/v5", + "scripts/acceptance/blind-consumer.sh never reads /dev or is told a device id; it uses only the JSON payloads; all media under mktemp -d and deleted on exit" + ], + "deps": [ + "t12" + ], + "covers": [ + "c18", + "h18", + "c21", + "h21" + ], + "instruction": "Cite ../webcam-cli/scripts/acceptance/{run.sh,blind-consumer.sh} and docs/acceptance-a-v-streaming.md. Requires the device enumerated on the host (lsusb shows 38fb:1001) \u2014 if absent, stop and report, do not fake evidence. Files owned: scripts/acceptance/**, docs/acceptance-microphone-domain.md." + } + ], + "risks": [ + { + "id": "r1", + "text": "WirePlumber may override amixer gain changes and PipeWire may hold the PCM (EBUSY) during gain (t6) and stream/record (t10) acceptance; the CLI must report busy (exit 3) honestly rather than retry", + "kind": "unknown_nonblocking", + "task_id": "t6", + "resolved": false, + "resolution": "" + }, + { + "id": "r2", + "text": "The PARAMETERS map is only known-good for the 38fb:1001 firmware build; on 2886:001a inspect's firmware block is the only diagnostic", + "kind": "unknown_nonblocking", + "task_id": "t3", + "resolved": false, + "resolution": "" + }, + { + "id": "r3", + "text": "The Reachy Mini Lite is not enumerated on this host today (user connected it, another project runs on it, nothing in lsusb); t13 cannot start until lsusb shows 38fb:1001", + "kind": "unknown_nonblocking", + "task_id": "t13", + "resolved": false, + "resolution": "" + } + ], + "obligations": [] +} diff --git a/.gitignore b/.gitignore index 95a383a..7ce909a 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,8 @@ __marimo__/ # Per-machine skills config (copy from skills.local.yaml.example) skills.local.yaml + +# devague working state (not committed by default) +.devague/questions/ + +.devague/reviews/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 887d095..18ac73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-09-06 + +### Added + +- **The microphone domain** — `list`, `inspect`, `gain get|set`, `array doa` (single-shot or `--watch` JSON Lines), `array aec get|set`, `param list|get|set` over the full XVF3800 table with a persistent tier behind `--allow-persistent`, `stream audio` (RTP/UDP passthrough or Opus), and `record` (bounded WAV/Matroska), built on seven zero-dependency modules: `devices` (stable ids from USB serial), `access` (ok/absent/forbidden/busy → exit 0/1/2/3), `usbctl` (stdlib `usbdevfs` control transfers), `xvf3800` (vendored parameter table, typed codec, status-64 retry), `mixer` (`amixer`), `engine` (GStreamer argv), and `activation` (append-only log of every `--apply`). Spec: `docs/specs/2026-09-06-microphone-domain.md`; plan: `docs/plans/2026-09-06-microphone-domain.md`. +- **Per-firmware parameter overlays** (`xvf3800.FIRMWARE_OVERLAYS`, `parameters_for`, `Xvf3800(vendor=…)`, `param list --vendor`) and a `uint16` codec, because Seeed's USB firmware (`2886`) has no `DOA_VALUE_RADIANS` and its `DOA_VALUE` is two `uint16`; `array doa` now reports `azimuth_deg` alongside `azimuth_rad` and names its `source` command. +- **docs/acceptance-microphone-domain.md** and `scripts/acceptance/` — on-device acceptance against a ReSpeaker XVF3800 (Seeed USB firmware 2.1.0): DoA matched the vendor's reference reader exactly, all volatile writes round-tripped, record and stream produced real audio. +- **docs/xvf3800-parameters.md** — attribution and resid-group guide for the vendored XVF3800 parameter table (Pollen Robotics' reachy_mini, Apache-2.0), the persistent tier, and how the array/param/gain verbs map onto it. + +### Fixed + +- **Seven defects found only on hardware** (see the acceptance doc): the announced RTP L16 consumer hardcoded `clock-rate=48000` (now follows the negotiated rate and channel count); the udev permission hint named `38fb:1001` instead of the refused device's own ids; `list --root ` probed the host's real `/dev/snd` node; the `amixer contents` parser dropped a second same-named control's `,index=1` suffix so gain readback was stale; passthrough streaming handed `S16LE` to `rtpL16pay`, which only takes `S16BE`; fixed 48 kHz mono defaults could never open a 16 kHz stereo device — `stream audio`/`record` now default to the advertised format and report each field's source; and the parameter map was assumed identical across firmwares. + +### Changed + +- **Review round on PR #6** — ten Qodo findings fixed (startup errors are structured, byte/half-word parameter values are range-checked and short replies rejected, `gain set` validates its 0.0..1.0 domain, a recording child that ignores SIGTERM is killed before the bound is reported, an artifact that finishes on its own over `--max-bytes` is an error with the file kept, `stream audio --apply` detects a pipeline that dies at startup and quotes GStreamer's diagnostics, a live stream's audit line is open-ended, the activation log is opened before the action and completes short writes); 38 SonarCloud findings cleared by behaviour-preserving refactors (parsers split into helpers, backtracking-free regexes, handlers return `None` under the dispatch contract, a shared `JSON_FLAG_HELP`, `_payload` takes a `_Plan`); and `tests/test_repo_hygiene.py` now enforces a 1000-line ceiling on every tracked Python file. +- **README.md and CLAUDE.md replace the scaffold-state narrative left by `5f9b1bd` ("scaffold microphone-cli from culture-agent-template")** with the domain state: `list`, `inspect`, `gain get`/`gain set`, `array doa`, `array aec get`/`array aec set`, `param list`/`param get`/`param set`, `stream audio`, and `record` now sit alongside the six agent-first verbs the scaffold shipped (`whoami`, `learn`, `explain`, `overview`, `doctor`, `cli overview`) — 13 top-level verbs, 276 tests, 92% coverage. +- **README.md rewritten for the domain state** — Status (13 verbs landed, on-device acceptance pending issue #3), Scope (non-goals: video, remote Reachy Mini, STT/TTS, playback, DoA coordinate transforms), a full CLI verb table, What comes out (JSON/JSON Lines shapes), What touches the hardware (the three-level split, activation log, persistent tier), and Why device identity is the hard part (stable ids, udev access). +- **CLAUDE.md rewritten from scaffold-state to domain-state** — a module map for the seven `microphone_cli/` domain modules, the three-level hardware split, the testing seams (`root=`, `_open_array`, `_ioctl`, `_spawn`, `_sleep`, `run=`), the fixture trees under `tests/fixtures/`, the parity tests that keep the catalog/learn/overview surfaces in sync with the registered parser, and the hardware-acceptance status. +- **Console-script note resolved** — `pyproject.toml`'s `microphone` script and argparse's `prog` now agree; the CLAUDE.md note that used to flag the mismatch now records it as fixed. +- **docs/skill-sources.md** gained rows for the `recall`/`remember` skills, which were vendored (from `eidetic-cli`, not guildmaster) but never entered into the provenance ledger. + ## [0.8.2] - 2026-09-06 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index db519f1..981aa36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,18 +6,105 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **microphone-cli** — an agent-first CLI for USB microphones and microphone arrays: enumerate devices, select and inspect channels, control gain and sample -format, and read direction-of-arrival from array firmware. - -**Current state: scaffold only.** The repo was cloned from the AgentCulture -agent template (`5f9b1bd scaffold microphone-cli from culture-agent-template`). -The package is renamed and the CI/identity/skills baseline is live, but **no -microphone domain code exists yet** — the only verbs are the template's -agent-first introspection surface (`whoami`, `learn`, `explain`, `overview`, -`doctor`, `cli overview`). Several strings still describe the template -("a clonable template for AgentCulture mesh agents") rather than the microphone -domain: `microphone_cli/cli/_commands/learn.py`, `overview.py` (`_ARTIFACTS`), -`microphone_cli/explain/catalog.py`, and `README.md`. Rewrite those as the -domain lands. +format, and read direction-of-arrival and echo-canceller state from +XVF3800-class array firmware. + +**Current state: the domain surface is built.** The repo started as a clone +of the AgentCulture agent template +(`5f9b1bd scaffold microphone-cli from culture-agent-template`), which shipped +only the agent-first introspection surface. The microphone domain has since +landed on top of it: 13 top-level verbs, 285 tests, 92% coverage, +`teken cli doctor . --strict` at 26/26. The converged spec and plan it was +built from live at `docs/specs/2026-09-06-microphone-domain.md` and +`docs/plans/2026-09-06-microphone-domain.md`. + +### Module map + +| Module | Owns | +|--------|------| +| `microphone_cli/devices.py` | Device identity: `/proc/asound` + sysfs parsing, stable-id synthesis (udev-style, from USB manufacturer/product/serial), `resolve()` by selector. Never opens a device or checks permissions. Cited from `webcam_cli/devices.py` with the video half dropped. | +| `microphone_cli/access.py` | Typed device-access state: `ok` / `absent` / `forbidden` / `busy`, for both `audio` (ALSA capture nodes) and `usb` (raw USB nodes) device kinds, each with its own remediation. Cited from `webcam-cli/webcam_cli/access.py`. | +| `microphone_cli/usbctl.py` | Stdlib `usbdevfs` control transfers (`USBDEVFS_CONTROL` ioctl via `fcntl.ioctl`) — no `pyusb`, no `libusb`. `find_devices()`/`open_device()` plus the `_ioctl` testing seam. | +| `microphone_cli/xvf3800.py` | The XVF3800 vendor control protocol: `PARAMETERS` (vendored verbatim from Pollen Robotics' `reachy_mini`, Apache-2.0), `PERSISTENT`, `Xvf3800` read/write. See `docs/xvf3800-parameters.md`. | +| `microphone_cli/mixer.py` | ALSA mixer control via `amixer` subprocess calls (no `libasound` bindings) — `list_controls`/`get_gain`/`set_gain`, all taking a `run` seam. | +| `microphone_cli/engine.py` | GStreamer boundary: capability detection and pipeline construction, shelling out to `gst-launch-1.0`/`gst-inspect-1.0`. No `gi`/PyGObject import, ever. Cited (audio subset) from `webcam-cli/webcam_cli/engine.py`. | +| `microphone_cli/activation.py` | Append-only activation log: one JSON line per `--apply` action. Path resolution order and shape cited from `webcam-cli/webcam_cli/activation.py`. | + +### The three-level hardware split + +`array`/`param` (reads open the USB node directly — there is no cheaper probe +stage) and `stream`/`record` (which do have a probe stage) share one rule, +readable from the invocation alone: + +| Invocation | What it touches | +|------------|------------------| +| default (no flag) | Nothing. Resolves the device, validates the request, prints the plan. Not logged. | +| `--probe` (`stream`/`record` only) | Detects the GStreamer engine and checks the capture node's access state, still without opening it. Not logged. | +| `--apply` | Opens the device and acts (writes gain, flips AEC state, writes a firmware parameter, streams, or records). Logged — one JSON line appended to the activation log (`$MICROPHONE_ACTIVATION_LOG`, else `$XDG_STATE_HOME/microphone-cli/activation.jsonl`, else `~/.local/state/microphone-cli/activation.jsonl`). | + +`param set` on a name in `xvf3800.PERSISTENT` (`SAVE_CONFIGURATION`, +`CLEAR_CONFIGURATION`, `REBOOT`, `TEST_CORE_BURN`, +`TEST_AEC_DISABLE_CONTROL`, `USB_BIT_DEPTH`, every `SPECIAL_CMD_*`) needs +`--allow-persistent` in addition to `--apply` — an ordinary `rw` write is +volatile and reverts on power-cycle, this tier is not. + +### Testing seams + +No test ever touches real hardware. Every module that would open a device or +spawn a process exposes a callable seam that tests monkeypatch: + +- `root=` (`devices.py`, `access.py`, and every command module that resolves + a device) — points filesystem parsing at a synthetic tree instead of `/`. +- `_open_array` (`cli/_commands/array.py`, `cli/_commands/param.py`) — + resolves a device and opens an `Xvf3800`; tests replace it. +- `_ioctl` (`usbctl.py`) — module-level callable defaulting to + `fcntl.ioctl`; tests replace it so no test ever touches `/dev`. +- `_spawn` (`cli/_commands/record.py`, `cli/_commands/stream.py`) — spawns + the `gst-launch-1.0` subprocess. +- `_sleep` (`cli/_commands/record.py`, and `array.py`'s DoA `--watch` poll + loop) — the retry/poll delay. +- `run=` (`mixer.py`'s `RunFunc`) — defaults to `subprocess.run`; every + `amixer`-calling function takes it so tests inject a fake. + +Fixture trees live under `tests/fixtures/`: `host-baseline` (one array, one +plain mic), `host-renumbered` (the same devices after a simulated replug — +proves selectors survive card-index churn), `respeaker` (an older ReSpeaker +XVF3800, `2886:001a`), and `two-arrays` (disambiguation when more than one +`38fb:1001`/`2886:001a` device is attached). + +### Parity tests + +`tests/test_cli.py` enforces that the hand-maintained surfaces stay in sync +with the registered parser: `test_every_catalog_path_resolves` and +`test_every_registered_path_has_a_catalog_entry` (catalog ↔ parser, both +directions), `test_every_registered_path_appears_in_overview_verbs` +(`overview._VERBS` ↔ parser), and +`test_learn_json_command_map_matches_the_registered_surface` +(`learn._TEXT`/`_as_json_payload()` ↔ parser). Adding a verb without updating +all four fails CI, not just the rubric gate. + +### Hardware acceptance + +Run on 2026-09-06 against a Seeed ReSpeaker XVF3800 (`2886:001a`, Seeed USB +firmware 2.1.0) attached to the dev host; see +`docs/acceptance-microphone-domain.md` for the evidence and the seven defects it +surfaced. Two facts from that run shape the code: + +- **The parameter map is firmware-specific.** `xvf3800.PARAMETERS` is Pollen's + `38fb:1001` map; `xvf3800.FIRMWARE_OVERLAYS` patches it per USB vendor id + (`2886` = Seeed: `DOA_VALUE` is two `uint16` degrees/speech, no + `DOA_VALUE_RADIANS`). Always construct `Xvf3800(fd, vendor=...)` and resolve + parameter names *after* resolving the device. +- **Capture format is advertised, never assumed.** `stream audio` and + `record` default `--rate/--channels/--format` from `stream0` + (`stream.advertised_format`) because the exact caps filter never falls back. + +Still open: the Reachy Mini Lite (`38fb:1001`) named in the plan +([issue #3](https://github.com/agentculture/microphone-cli/issues/3)), CLI +support for the DFU/firmware bring-up +([issue #4](https://github.com/agentculture/microphone-cli/issues/4)), and +voice-activity exposure +([issue #5](https://github.com/agentculture/microphone-cli/issues/5)). ## Commands @@ -53,13 +140,14 @@ markdownlint-cli2 "**/*.md" "#node_modules" "#.local" "#.claude/skills" Config lives in `.markdownlint-cli2.yaml` (MD013 and MD060 off, MD024 siblings-only for the changelog; `.claude/skills/**` ignored). -### Console-script name +### Console-script name — RESOLVED -`pyproject.toml` declares `microphone = "microphone_cli.cli:main"` — the binary -is **`microphone`**, not `microphone-cli`. The argparse `prog` is -`"microphone-cli"`, so `--help` and every doc string say `microphone-cli …` -while the actual command is `microphone …`. Either rename the script or the -`prog` before this ships; until then, prefer `microphone` in anything runnable. +`pyproject.toml` declares `microphone = "microphone_cli.cli:main"` and +argparse's `prog` is also `"microphone"` — the binary and the program name +both agree now. `--help` output and every doc string say `microphone …`; +nothing presents `microphone-cli ` as something to type. +`microphone-cli` still correctly names the *project*, the PyPI *distribution*, +and the mesh *nick* — do not blanket-replace it. ## Architecture @@ -116,6 +204,12 @@ guidance file. Changing `backend` means adding the matching prompt file or ### Adding a verb or noun +The domain modules (`devices.py`, `access.py`, `usbctl.py`, `xvf3800.py`, +`mixer.py`, `engine.py`, `activation.py`) are the domain logic; a new verb on +an *existing* noun almost never touches them. Scope the change to +`microphone_cli/cli/_commands/*.py` plus the three hand-maintained surfaces +below — that is the whole checklist: + 1. New module in `microphone_cli/cli/_commands/` exposing `register(sub)`, with `--json` and a `func` default. 2. Register it in `_build_parser()` (there is a marked spot). diff --git a/README.md b/README.md index c6bdd80..beacec2 100644 --- a/README.md +++ b/README.md @@ -2,52 +2,196 @@ Agent-first CLI for USB microphones and microphone arrays: enumerate devices, select and inspect channels, control gain and sample format, and read -direction-of-arrival from array firmware. - -> **Status: scaffold.** The identity, CI, and agent-first CLI baseline are in -> place; the microphone domain verbs are not implemented yet. What ships today -> is the introspection surface below. - -## What you get - -- **An agent-first CLI** cited from [teken](https://github.com/agentculture/teken) - (`afi-cli`) — the runtime package has no third-party dependencies. -- **A mesh identity** — `culture.yaml` (`suffix` + `backend`) and the matching - resident prompt file (`AGENTS.colleague.md`, since this agent runs - `backend: colleague`). -- **The canonical guildmaster skill kit** under `.claude/skills/`, vendored - cite-don't-import. See [`docs/skill-sources.md`](docs/skill-sources.md). -- **A build + deploy baseline** — pytest, lint, the agent-first rubric gate, and - PyPI Trusted Publishing wired into GitHub Actions. +direction-of-arrival and echo-canceller state from XVF3800-class array +firmware. + +## Status + +**The domain surface has landed.** `list`, `inspect`, `gain get|set`, +`array doa|aec`, `param list|get|set`, `stream audio`, and `record` are +implemented and wired into the CLI, alongside the agent-first baseline +(`whoami`, `learn`, `explain`, `overview`, `doctor`, `cli overview`) — 13 +top-level verbs, 285 tests, 92% coverage, `teken cli doctor . --strict` at +26/26. + +**On-device acceptance has run** on a Seeed ReSpeaker XVF3800 (`2886:001a`, +USB firmware 2.1.0): every verb was exercised against the real array, the +CLI's direction-of-arrival matched Seeed's reference reader exactly, and seven +defects found only on hardware were fixed. Evidence and findings are in +[docs/acceptance-microphone-domain.md](docs/acceptance-microphone-domain.md). +The Reachy Mini Lite (`38fb:1001`) named in the plan is still open in +[issue #3](https://github.com/agentculture/microphone-cli/issues/3); the +firmware bring-up the ReSpeaker needed is documented in +[issue #4](https://github.com/agentculture/microphone-cli/issues/4). + +## Scope + +microphone-cli owns local USB **audio capture** devices — microphones and +microphone arrays — and the acts of enumerating them, reading their state, +and getting samples off them. It produces stable ids, honest JSON, and +(for `stream`/`record`) an artifact, and stops there. + +It does **not** own: + +- **Video** — webcam enumeration and capture is `webcam-cli`'s lane. +- **Reaching a Reachy Mini over the network** — this tool only ever opens a + USB node on the host it runs on; it has no remote-device concept. +- **Speech-to-text or text-to-speech** — no ASR/TTS model lives here. +- **Speaker or monitor playback** — sound *out* is the `reachy_mini` / + `reachy_nova` stack's job. +- **Coordinate transforms of direction-of-arrival** — `array doa` reports the + firmware's own radian value, untouched; re-basing onto a robot or world + frame is a consumer's job, and it can only be done correctly starting from + the raw value. ## Quickstart ```bash uv sync uv run pytest -n auto # run the test suite +uv run microphone learn # start here: the self-teaching prompt (add --json) +uv run microphone list # what capture hardware is attached, and can it be opened uv run microphone whoami # identity from culture.yaml -uv run microphone learn # self-teaching prompt (add --json) uv run teken cli doctor . --strict # the agent-first rubric gate CI runs ``` -The installed console script is **`microphone`**. Argparse still prints -`microphone-cli` as the program name in `--help` output; the runnable command is -`microphone`. +The console command is **`microphone`**. The import package is +`microphone_cli` and the PyPI distribution / mesh nick is `microphone-cli`. +Only `microphone` is ever typed — argparse's `prog` also reads `microphone` +now, so nothing in `--help` output presents `microphone-cli ` as +something to run. ## CLI | Verb | What it does | |------|--------------| -| `whoami` | Report this agent's nick, version, backend, and model from `culture.yaml`. | -| `learn` | Print a structured self-teaching prompt. | -| `explain ` | Markdown docs for any noun/verb path. | -| `overview` | Read-only descriptive snapshot of the agent. | +| `list` | List attached microphones: stable id, ALSA address, and access status. | +| `inspect ` | Inspect one microphone's capture formats, rates, channels, and firmware identity. | +| `gain overview` | Describe the `gain` verb group. | +| `gain get ` | Read the current ALSA capture gain (and, on an array, the firmware `AUDIO_MGR_MIC_GAIN`). | +| `gain set ` | Plan or apply a new capture gain, 0.0–1.0 (`--apply`, `--target alsa\|firmware\|both`). | +| `array overview` | Describe the `array` verb group. | +| `array doa ` | Read `DOA_VALUE_RADIANS` once, or continuously as JSON Lines (`--watch`, `--interval`, `--count`). | +| `array aec get ` | Echo-canceller state: converged, bypass, HPF, echo on/off, mic count, geometry, RT60. | +| `array aec set ` | Flip `--echo`/`--bypass`/`--hpf` on or off (dry run without `--apply`). | +| `param overview` | Describe the `param` noun: verbs and the persistent tier. | +| `param list` | List every row of the XVF3800 parameter table. | +| `param get ` | Read one raw firmware parameter. | +| `param set ` | Write one raw firmware parameter (`--apply`; `--allow-persistent` for the persistent/destructive tier). | +| `stream overview` | Describe the `stream` verb group. | +| `stream audio ` | Serve a live microphone stream over RTP/UDP (dry-run by default; `--probe`, `--apply`). | +| `record ` | Record a bounded clip (`.mka` Opus or `.wav` PCM) from a resolved microphone. | +| `whoami` | Report this agent's nick, version, backend, and served model. | +| `learn` | Print a structured self-teaching prompt for agent consumers. | +| `explain ` | Print markdown docs for a noun/verb path. | +| `overview` | Read-only descriptive snapshot of the agent (identity, verbs, contracts, consent). | | `doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). | | `cli overview` | Describe the CLI surface itself. | Every command supports `--json`. Results go to stdout, errors/diagnostics to -stderr (never mixed). Exit codes: `0` success, `1` user error, `2` environment -error, `3+` reserved. +stderr (never mixed); text errors render `error:` + `hint:`. Exit codes: +`0` success, `1` user error, `2` environment error, `3` device busy, +`4+` reserved. + +Two device classes get different verb sets. Any USB audio capture device +gets `list`/`inspect`/`gain` (ALSA side)/`stream`/`record`. `array`, `param`, +and the firmware side of `gain` need an **XMOS XVF3800** array — Seeed +ReSpeaker XVF3800 boards, or Pollen Robotics' Reachy Mini Audio card (an +XVF3800 derivative) — matched by USB id `38fb:1001` (Reachy Mini Audio) or +`2886:001a` (older ReSpeaker firmware). + +## What comes out + +`array doa` emits one JSON object per sample: + +```json +{"device": "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001", + "azimuth_rad": 0.612831, "speech": true, + "source": "DOA_VALUE_RADIANS", "ts": "2026-09-06T12:00:00+00:00"} +``` + +`--watch` prints one such object per line (JSON Lines), polling every +`--interval` seconds (default 0.5) until `--count` samples or Ctrl-C, which +exits `0`. + +`list --json` reports each device's stable id, ALSA address, card index, USB +path and ids, serial, whether it is an array, channel count, and access +state. `inspect --json` adds capture formats, rates, and — on an array — +firmware identity. `param list --json` reports every row of the XVF3800 +table: `name`, `resid`, `cmdid`, `count`, `access` (`ro`/`rw`), `type`, and +`persistent`. `stream audio --json` announces the RTP/UDP `uri`, `caps`, and +a ready-to-run consumer pipeline per codec, so a second process needs +nothing else to attach. + +## What touches the hardware + +`stream` and `record` share a three-level split, readable from the +invocation alone: + +| Invocation | What it touches | +|------------|------------------| +| default (no flag) | Nothing. Resolves the device, validates the request, prints the plan. Not logged. | +| `--probe` | Checks the GStreamer engine and the capture node's access state, still without opening it. Not logged. | +| `--apply` | Opens the device and streams or records. Logged. | + +`array` and `param` reads open the USB node directly (a read has no +cheaper probe stage); only `array aec set --apply` and `param set --apply` +write. `gain set --apply` writes the ALSA and/or firmware gain. Every +`--apply` run — on any verb — appends one JSON line to the **activation +log**: `$MICROPHONE_ACTIVATION_LOG` if set, else +`$XDG_STATE_HOME/microphone-cli/activation.jsonl`, else +`~/.local/state/microphone-cli/activation.jsonl`. + +Most firmware writes are **volatile** — they revert on power-cycle. A +smaller, explicitly named **persistent tier** — `SAVE_CONFIGURATION`, +`CLEAR_CONFIGURATION`, `REBOOT`, `TEST_CORE_BURN`, +`TEST_AEC_DISABLE_CONTROL`, `USB_BIT_DEPTH`, and every `SPECIAL_CMD_*` name — +persists across reboot, triggers a reboot, or is otherwise destructive; +writing one of these with `--apply` additionally requires +`--allow-persistent`. See [`docs/xvf3800-parameters.md`](docs/xvf3800-parameters.md) +for the full table and its provenance. + +`list` opens nothing beyond one non-blocking access probe per node. + +## Why device identity is the hard part + +ALSA card numbers (`hw:1`) are plug-order, not identity — a replug can hand +the same physical microphone a different index, and the raw USB device path +under `/dev/bus/usb/BBB/DDD` renumbers the same way. Nothing in this CLI is +keyed on either: every selector resolves to a **stable id** synthesized from +USB manufacturer/product/serial descriptors the way udev derives them (e.g. +`usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001`), falling back to a sysfs-path +form when a device has no serial. An ALSA card index is refused outright as +a selector — it is exactly the kind of handle that looks stable in one +session and is wrong in the next. + +Raw USB access (needed for `array`/`param`) is gated by a udev rule, not +group membership: non-root access to a `38fb:1001` node needs a rule like +`/etc/udev/rules.d/99-reachy-mini-audio.rules` granting `MODE=0666` by +vendor/product id. When a raw node is forbidden, the CLI's remediation +prints that rule line verbatim so an agent (or a human) can apply the fix +without hunting for the syntax. + +## What this repo carries + +- **An agent-first CLI** cited from [teken](https://github.com/agentculture/teken) + (`afi-cli`) — the runtime package has no third-party dependencies + (`dependencies = []`); DoA/AEC/param go through a stdlib `usbdevfs` ioctl, + gain through `amixer`, and stream/record shell out to `gst-launch-1.0`. +- **A mesh identity** — `culture.yaml` (`suffix` + `backend`) and the matching + resident prompt file (`AGENTS.colleague.md`, since this agent runs + `backend: colleague`). +- **The canonical guildmaster skill kit** under `.claude/skills/`, vendored + cite-don't-import. See [`docs/skill-sources.md`](docs/skill-sources.md). +- **A build + deploy baseline** — pytest, lint, the agent-first rubric gate, + and PyPI Trusted Publishing wired into GitHub Actions. +- **The converged spec and plan** this domain was built from: + [`docs/specs/2026-09-06-microphone-domain.md`](docs/specs/2026-09-06-microphone-domain.md) + and [`docs/plans/2026-09-06-microphone-domain.md`](docs/plans/2026-09-06-microphone-domain.md). + +See [`CLAUDE.md`](CLAUDE.md) for the architecture, the domain constraints, +and the conventions (version-bump-every-PR, the `cicd` PR lane, testing +seams). ## Development @@ -60,22 +204,19 @@ uv run flake8 microphone_cli tests uv run bandit -c pyproject.toml -r microphone_cli ``` -Every PR bumps the version in `pyproject.toml` and adds a `CHANGELOG.md` entry, -even for docs- and CI-only changes. CI's `version-check` job catches a forgotten -bump by failing when the version still matches `main`; it does not verify the -version moved *forward*. +Every PR bumps the version in `pyproject.toml` and adds a `CHANGELOG.md` +entry, even for docs- and CI-only changes. CI's `version-check` job catches +a forgotten bump by failing when the version still matches `main`; it does +not verify the version moved *forward*. -Markdown lint is an npm tool, not a `uv` one — install it separately, pinned to -the version CI uses: +Markdown lint is an npm tool, not a `uv` one — install it separately, pinned +to the version CI uses: ```bash npm install -g markdownlint-cli2@0.21.0 markdownlint-cli2 "**/*.md" "#node_modules" "#.local" "#.claude/skills" ``` -See [`CLAUDE.md`](CLAUDE.md) for the architecture, the CLI contracts (errors, -stream split, `--json`), and how to add a verb or noun group. - ## License Apache 2.0 — see [`LICENSE`](LICENSE). diff --git a/docs/acceptance-microphone-domain.md b/docs/acceptance-microphone-domain.md new file mode 100644 index 0000000..0747cda --- /dev/null +++ b/docs/acceptance-microphone-domain.md @@ -0,0 +1,152 @@ +# On-device acceptance: microphone domain + +Plan task t13 of `docs/plans/2026-09-06-microphone-domain.md`, run on +2026-09-06 against real hardware. Tracked in +[issue #3](https://github.com/agentculture/microphone-cli/issues/3); the +firmware bring-up it required is documented in +[issue #4](https://github.com/agentculture/microphone-cli/issues/4). + +## Device + +| | | +|---|---| +| Board | Seeed ReSpeaker XVF3800 4-Mic Array, XIAO ESP32-S3 variant | +| USB id | `2886:001a`, serial `114993702263100642` | +| Firmware | Seeed USB firmware v2.1.0, build `ua-io16-sqr`, repo hash `a4e685cf…` (read from the chip through `inspect`) | +| ALSA | card 1 `Array`, `hw:CARD=Array`, capture S16_LE, 2 ch, 16 kHz | +| Host | `spark` (Ubuntu, kernel 6.17, PipeWire + WirePlumber running, `alsa-utils` 1.2.9, `gst-launch-1.0` present, `dfu-util` 0.11) | +| Access | `/etc/udev/rules.d/99-microphone-cli.rules` granting `2886:001a` `MODE="0666"`, exactly the line the CLI's permission hint prints | + +**Deviation from the plan.** The plan named a Reachy Mini Lite (`38fb:1001`). +The robot on hand turned out to be a Reachy Mini whose array is wired to its +own Pi, unreachable from this host, so acceptance ran on a standalone +ReSpeaker board instead (plan deviation d1). That board shipped with I2S +firmware, silent on USB, and was reflashed to USB firmware via safe mode and +`dfu-util` (issue #4). The Reachy Mini Lite check remains open in issue #3. + +## Privacy posture of this run + +Every recording and stream artifact was written under a `mktemp -d` +directory and deleted at the end of the step that made it. Nothing captured +from the microphone is in this repository or in the logs quoted below; only +byte counts, formats, parameter values, and timestamps survive. + +## Read-only pass + +All reads ran before any write and left the activation log empty. + +| Verb | Evidence | +|------|----------| +| `list --json` | one device, `stable_id` `usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_114993702263100642`, `is_array: true`, `audio_access.state: ok` on `/dev/snd/pcmC1D0c` | +| `inspect --json` | `formats: ["S16_LE"]`, `rates: [16000]`, `channels: 2`, `firmware: {version: "2.1.0", build: "ua-io16-sqr", host: "NA", repo_hash: "a4e685cf35c4c7e9c9ef7877a494a78d127c9051"}` | +| `gain get --json` | `alsa: {control: "Headset Capture Volume", numid: 10, value: 60, min: 0, max: 60}`, `firmware: {mic_gain: 90.0}` | +| `array aec get --json` | `converged: false`, `bypass: false`, `hpf: true`, `echo: true`, `num_mics: 4`, `geometry_type: 2`, 12-float geometry (±0.0333 m square), `rt60` | +| `param get` | `VERSION [2, 1, 0]`, `BLD_MSG`, `BLD_HOST`, `BLD_REPO_HASH`, `AEC_NUM_MICS [4]`, `AEC_MIC_ARRAY_TYPE [2]`, `AEC_AZIMUTH_VALUES` (4 radians), `AEC_SPENERGY_VALUES`, `LED_EFFECT [4]`, `GPO_READ_VALUES` all decode correctly | +| `param list --vendor 2886 --json` | 117 entries (the base Reachy table has 126) | + +This is the first end-to-end proof of the stdlib `usbdevfs` control-transfer +path (`microphone_cli/usbctl.py`): every value above came through +`fcntl.ioctl(USBDEVFS_CONTROL)` with no `pyusb` installed. Parked unknown v5 +on the spec frame is answered. + +## Direction of arrival: CLI versus the vendor's reference reader + +Seeed's own `python_control/respeaker_get_doa.py` (pyusb, run in a throwaway +environment) and `microphone array doa --json` were read back to back three +times, each pair within one second: + +| Time (UTC) | CLI `azimuth_deg` / `speech` | Seeed reader `DOA_VALUE` / `SPEECH_DETECTED` | +|------------|------------------------------|-----------------------------------------------| +| 20:13:20.985 | 182.0 / false | 182 / 0 | +| 20:13:27.268 | 182.0 / false | 182 / 0 | +| 20:13:33.554 | 182.0 / false | 182 / 0 | + +Delta 0.0 rad in every pair (success signal c21 asks for ≤ 0.01 rad). +`array doa --watch --count 3 --interval 0.3` emitted exactly three JSON +Lines with monotonic timestamps. + +## Volatile writes (approved: no `--allow-persistent` at any point) + +| Step | Dry run | `--apply` | +|------|---------|-----------| +| `gain set 0.5 --target alsa` | printed `amixer -c 1 cset numid=10 30`, nothing sent | readback `value: 30`; raw `amixer cget` agreed (`values=30,30`); restored to 60 | +| `param set AUDIO_MGR_MIC_GAIN 80` | printed the plan, nothing sent | `readback: [80.0]`; restored to `[90.0]` | +| `array aec set --echo off` | printed `PP_ECHOONOFF [0]`, nothing sent | `state.echo: false`; `--echo on` restored `state.echo: true` | +| `param set SAVE_CONFIGURATION 1 --apply` | — | refused, exit 1, hint names `--allow-persistent` and explains volatile versus persistent | +| `record clip.wav --duration 3` (explicit 16 kHz / 2 ch / S16LE) | printed the pipeline, wrote nothing | `bytes_written: 192044`, `stopped_reason: eos`; `file` reports `WAVE audio, Microsoft PCM, 16 bit, stereo 16000 Hz` | +| `record auto.wav --duration 2` (no format flags) | — | `bytes_written: 128044`, format `source: advertised` for all three fields | +| `stream audio --port 5004` (no format flags) | printed the pipeline, spawned nothing | child alive after 3 s; a second-process `udpsrc port=5004 ! fakesink` received 20 RTP packets; SIGINT stopped it cleanly | +| `scripts/acceptance/run.sh --writes --media` (final run) | — | 10 of 10 steps passed; the blind consumer ran the announced command for 4 s without error and depayloaded 50 RTP buffers | + +The activation log gained exactly one line per applied action (16 lines for +the 16 applies above), and nothing for any dry run or read. + +## Findings + +Each of these was found only because the hardware was real. All are fixed in +this branch except where noted. + +1. **The udev hint named the wrong device.** The permission remediation + hardcoded `38fb:1001`; the board that was refused was `2886:001a`. The + hint now prints the refused device's own ids. +2. **`list --root ` probed the host's real `/dev/snd` node.** The + fixture's card 1 collided with the live card 1, so a hardware-free test + changed answer when a device was plugged in. The node path is root-joined. +3. **The parameter map is firmware-specific.** Seeed's USB 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. Reads with the + Reachy table failed with firmware status 66 (length mismatch). Added a + `uint16` codec and per-vendor overlays (`xvf3800.FIRMWARE_OVERLAYS`); + `array doa` reads whichever command the firmware has and reports both + `azimuth_deg` and `azimuth_rad`. Parked unknown v4 is answered: the map is + not identical across `2886:001a` and `38fb:1001`. Plan deviation d2. +4. **Gain readback reported the wrong control.** The XVF3800 exposes two + `Headset Capture Volume` controls, the second tagged `,index=1`. The + `amixer contents` parser did not accept that suffix, so the second block's + value overwrote the first. Fixed; the write itself had always worked. +5. **Passthrough streaming could not start.** `rtpL16pay` accepts only + `S16BE`; the pipeline handed it `S16LE` (`could not link queue0 to + rtpl16pay0`). An `audioconvert ! audio/x-raw,format=S16BE` stage now + precedes the payloader. +6. **Fixed 48 kHz mono defaults could never open this device.** The exact + caps filter (by design never falls back) failed on a 16 kHz stereo array. + `stream audio` and `record` now default each unset field to what the + device advertises in `/proc/asound/cardN/stream0` and record the source of + each value (`explicit` / `advertised` / `default`). +7. **The announced RTP L16 consumer said `clock-rate=48000` for a 16 kHz + stream.** A blind consumer following the announcement decoded at the wrong + speed. The passthrough consumer caps now carry the negotiated + `clock-rate` and `encoding-params`/`channels`; Opus stays at 48 kHz on the + wire as the RTP spec requires. +8. **Bring-up needs CLI support (not fixed here).** Two USB-C ports, I2S + firmware that is silent on USB, safe mode via the Mute button, `dfu-util` + flashing, and a missing udev rule cost an hour of manual diagnosis. + Tracked in issue #4. Voice-activity exposure is tracked in issue #5. + +## Parked unknowns, answered on this hardware + +| Park | Answer | +|------|--------| +| v2 firmware `AUDIO_MGR_MIC_GAIN` vs ALSA capture volume | Two independent stages: ALSA `Headset Capture Volume` (0–60) moved without changing the firmware value (90.0), and vice versa. | +| v3 PipeWire holding the PCM / WirePlumber reverting gain | Nobody held `pcmC1D0c` during the run; `amixer` writes persisted for the run's duration with WirePlumber running. Not observed on this host; still possible with an active PipeWire client. | +| v4 parameter map identical on `2886:001a`? | No. See finding 3. | +| v5 `usbdevfs` path unverified | Verified end to end; see the read-only pass. | + +## Not covered + +- The Reachy Mini Lite (`38fb:1001`) named in the plan. Its firmware's + `DOA_VALUE_RADIANS` path is exercised only by the fake in + `tests/test_array.py`. +- Multiple arrays attached at once (fixture `two-arrays` only). +- Opus streaming was started and ran without error for 5 s in a manual + check, but no consumer decoded it in this run. + +## How to re-run + +```bash +scripts/acceptance/run.sh --device usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_114993702263100642 +``` + +Read-only by default; pass `--writes` to include the volatile gain and AEC +round trips and `--media` to include record and stream. It never passes +`--allow-persistent`. diff --git a/docs/plans/2026-09-06-microphone-domain-split.md b/docs/plans/2026-09-06-microphone-domain-split.md new file mode 100644 index 0000000..1cdbe7a --- /dev/null +++ b/docs/plans/2026-09-06-microphone-domain-split.md @@ -0,0 +1,183 @@ +# Implementation Split Plan — microphone domain + +slug: `microphone-domain` · generated by `assign-to-workforce.sh split-plan --write` from `devague plan waves --json` (issue #82). Re-running this command overwrites this file in place; edits made to the **Task assignments** table below survive a regeneration, matched by task id. + +## Dependency waves + +- Wave 1: `t1`, `t2`, `t3`, `t4`, `t9` +- Wave 2: `t5`, `t6`, `t7`, `t8`, `t10` +- Wave 3: `t11` +- Wave 4: `t12` +- Wave 5: `t13` + +## Task assignments + +Edit the Owner/Model columns before approving gate 2 — the default Model is a presentation-only proposal (`sonnet`), never a recommendation. Re-running `split-plan --write` preserves your edits here, matched by task id. + +| Task | Owner | Model | +| --- | --- | --- | +| `t1` | | sonnet | +| `t2` | | sonnet | +| `t3` | | sonnet | +| `t4` | | sonnet | +| `t9` | | sonnet | +| `t5` | | sonnet | +| `t6` | | sonnet | +| `t7` | | sonnet | +| `t8` | | sonnet | +| `t10` | | sonnet | +| `t11` | | sonnet | +| `t12` | | sonnet | +| `t13` | | sonnet | + +## Wave 1 + +### t1 — Typed errors and access model: `EXIT_BUSY_ERROR`=3 in cli/`_errors.py` and a new `microphone_cli`/access.py with AccessState {ok,absent,forbidden,busy}, `check_access`/`require_access`, /proc/\*/fd holder lookup, audio-group and udev-rule remediations + +- instruction: Cite `webcam_cli`/access.py (webcam-cli repo, ../webcam-cli) and adapt: keep kind='audio' and add kind='usb'. Do not touch cli/`__init__.py`. Files owned: `microphone_cli`/cli/`_errors.py`, `microphone_cli`/access.py, tests/`test_access.py`. +- covers: c3, h3 +- acceptance: + - `microphone_cli`/access.py exposes AccessState, AccessReport, `check_access`(path, kind), `require_access`(path, kind) and `access_error`(); tests/`test_access.py` covers all four states with a fake /proc tree + - `EXIT_BUSY_ERROR` == 3 is exported from cli/`_errors.py` and `access_error`() maps absent→1, forbidden→2, busy→3 with the holder pid/command in the busy message when /proc is readable + - forbidden on a /dev/bus/usb node produces a hint containing the udev rule line 'SUBSYSTEM=="usb", ATTR{idVendor}=='; forbidden on /dev/snd names the audio group + +### t2 — Device identity: `microphone_cli`/devices.py enumerates capture cards from /proc/asound + sysfs USB parent, builds stable ids from USB serial (sysfs path fallback), flags XVF3800 arrays by idVendor:idProduct (38fb:1001, 2886:001a), and resolve() refuses ambiguous or raw hw:N selectors; fixture trees under tests/fixtures/ + +- instruction: Cite `webcam_cli`/devices.py:105-159,286-387,470-531 and drop the video half. Frozen dataclasses with `as_dict`(). Fixture trees are synthetic /proc/asound, /sys/bus/usb/devices, /sys/class/sound files only — no real captures. Files owned: `microphone_cli`/devices.py, tests/`test_devices.py`, tests/fixtures/\*\*. +- covers: c2, h2, c31, h28, c14, h14 +- acceptance: + - `enumerate_devices`(root=...) on tests/fixtures/host-baseline and tests/fixtures/host-renumbered returns identical `stable_id` values while card index differs + - a tests/fixtures/two-arrays tree with two 38fb:1001 devices resolves each by serial and resolve('Reachy Mini Audio') exits 1 listing both candidates + - from `microphone_cli`.devices import `enumerate_devices`, resolve works with no other imports; each device's `as_dict`() carries `stable_id`, label, `alsa_address`, `card_id`, `usb_path`, `usb_ids`, serial, `is_array`, channels, and `pipewire_visible` (bool, from a sysfs/procfs heuristic or None) + +### t3 — XVF3800 control layer with stdlib only: `microphone_cli`/usbctl.py (`usbdevfs_ctrltransfer` ctypes struct, `USBDEVFS_CONTROL` ioctl, `O_RDWR` open, device lookup by idVendor/idProduct/serial in sysfs) and `microphone_cli`/xvf3800.py (PARAMETERS table vendored from `reachy_mini` with Apache-2.0 attribution, typed pack/unpack, read with status-64 retry loop, write, `firmware_info`()) + +- instruction: Port the protocol from ../`reachy_mini`/src/`reachy_mini`/media/`audio_control_utils.py` exactly (resid→wIndex, cmdid→wValue, 0x80 read bit, status byte 0/64). Keep the PARAMETERS table verbatim with a docstring citing the source file and license. Inject the ioctl via a module-level callable so tests never open /dev. Files owned: `microphone_cli`/usbctl.py, `microphone_cli`/xvf3800.py, tests/`test_usbctl.py`, tests/`test_xvf3800.py`. +- covers: c6, h6, c30, h27 +- acceptance: + - tests/`test_usbctl.py` asserts ctypes.sizeof(UsbdevfsCtrlTransfer) == struct.calcsize('BBHHHI' + 'P' with natural alignment) and `USBDEVFS_CONTROL` == 0xC0185500 on 64-bit + - with a fake ioctl, read('`DOA_VALUE_RADIANS`') sends bRequestType 0xC0, bRequest 0, wValue 0x80|19, wIndex 20, wLength 9 and decodes two little-endian floats after a status byte; a status-64 reply twice then 0 succeeds; 64 forever raises CliError(code=2) + - write('`AUDIO_MGR_MIC_GAIN`', \[0.5\]) sends bRequestType 0x40, wValue 0, wIndex 35, payload struct.pack('`' with --json; list --json on tests/fixtures/host-baseline prints {devices:\[...\], count:N} and the dicts equal `enumerate_devices`().`as_dict`() + - inspect --json on an array fixture includes formats\[\], rates\[\], channels and a firmware{version,build} block read through a fake ioctl; on a non-array mic firmware is null + +### t6 — Verb 'gain get|set': `_commands`/gain.py plus `microphone_cli`/mixer.py wrapping 'amixer -c `` cget/cset' for the ALSA capture control, and `AUDIO_MGR_MIC_GAIN` through xvf3800 on arrays; set is dry-run unless --apply and is written to the activation log + +- instruction: Parse 'amixer -c N contents' output for the capture-volume control; never call amixer in tests (monkeypatch subprocess.run). Files owned: `microphone_cli`/cli/`_commands`/gain.py, `microphone_cli`/mixer.py, tests/`test_gain.py`. +- covers: c5, h5, c11, h11 +- acceptance: + - gain get --json on a non-array fixture reports {alsa:{control,value,min,max}} using a fake subprocess.run; on an array it adds {firmware:{`mic_gain`}} + - gain set `` 0.5 without --apply prints the planned amixer argv/control transfer and calls neither; with --apply both are issued and the activation log gains one line + +### t7 — Noun 'array': `_commands`/array.py with 'array overview', 'array doa ``' (single-shot or --watch JSON Lines with --interval/--count, SIGINT→exit 0, ENODEV→exit 2) and 'array aec get|set' (converged, bypass, high-pass, echo on/off, mic count, geometry; set needs --apply and logs) + +- instruction: Cite ../webcam-cli/`webcam_cli`/cli/`_commands`/stream.py:1603-1672 for the noun-group pattern and cli.py:38-40 for `parser_class`. Raw firmware radians, no coordinate transform. Files owned: `microphone_cli`/cli/`_commands`/array.py, tests/`test_array.py`. +- covers: c32, h29 +- acceptance: + - array doa --json prints {`azimuth_rad`, speech, source:'`DOA_VALUE_RADIANS`'} once; --watch --count 3 prints exactly 3 JSON lines; a fake ioctl raising OSError(ENODEV) on the third poll yields two stdout lines, one stderr error object, exit 2 + - array aec get --json reports at least converged, bypass, hpf, echo, `num_mics`, geometry; array aec set --echo off without --apply sends nothing; with --apply writes `PP_ECHOONOFF` and appends to the activation log + - the subparser group is built with `parser_class`=type(p) and bare 'array' prints the overview + +### t8 — Noun 'param': `_commands`/param.py with 'param overview', 'param list', 'param get ``', 'param set `` `` --apply \[--allow-persistent\]' over the full XVF3800 table; validates name/rw/count/type before packing; persistent/destructive names need --allow-persistent + +- instruction: Names match case-insensitively and echo upper-case. The persistent tier is a frozenset in xvf3800.py consumed here. Files owned: `microphone_cli`/cli/`_commands`/param.py, tests/`test_param.py` (may add PERSISTENT set to `microphone_cli`/xvf3800.py only if t3 did not). +- covers: c33, h30, c34, h31 +- acceptance: + - param set on an unknown name, a 'ro' name, or the wrong value count exits 1 with a hint and issues no transfer; param get on a 'wo' name exits 1 + - param set REBOOT 1 --apply exits 1 with a hint naming --allow-persistent and explaining volatile-vs-persistent; with --allow-persistent the transfer is sent and logged; `SAVE_CONFIGURATION`, `CLEAR_CONFIGURATION`, `TEST_CORE_BURN`, `TEST_AEC_DISABLE_CONTROL` and `SPECIAL_CMD_`\* are in the same tier + - param list --json emits every table entry with name, resid, cmdid, count, access, type, persistent(bool) + +### t10 — Verbs 'stream audio' and 'record': `_commands`/stream.py (noun with overview + 'stream audio ``' --port --rate --channels --format --encode --apply --probe) and `_commands`/record.py ('record `` ``' --duration --max-bytes --rate --channels --format --apply --probe); dry-run by default, engine-missing exit 2, busy exit 3, activation-logged + +- instruction: Cite ../webcam-cli/`webcam_cli`/cli/`_commands`/stream.py and record.py, audio paths only; keep the dry-run/--probe/--apply three-level split and the `_payload` keys. Files owned: `microphone_cli`/cli/`_commands`/stream.py, `microphone_cli`/cli/`_commands`/record.py, tests/`test_stream.py`, tests/`test_record.py`. +- covers: c9, h9, h23 +- acceptance: + - stream audio and record without --apply print the pipeline argv, set `hardware_touched`=false in --json, write no file and spawn nothing (subprocess booby-trapped in tests) + - record --apply with a fake spawn honours --duration and --max-bytes (`stopped_reason` in the JSON), writes to the given path only, and appends an activation line; --duration > 3600 or --max-bytes > 4GiB exits 1 + +## Wave 3 + +### t11 — Surface wiring: register every new module in cli/`__init__.py`, set prog='microphone', rewrite learn.py (`_TEXT` and `_as_json_payload` with hardware-touch split, exit codes 0/1/2/3, audience naming media-cli), overview.py `_VERBS`/`_HARDWARE`/`_CONTRACTS`, explain/catalog.py entries for every path, purge template prose, keep doctor unchanged; port webcam-cli's parity and no-template-prose tests + +- instruction: This is the only task that edits cli/`__init__.py`, learn.py, overview.py, catalog.py. Cite ../webcam-cli/`webcam_cli`/cli/`_commands`/{learn,overview}.py, explain/catalog.py and tests/`test_cli.py`:190-327. Files owned: those four modules, tests/`test_cli.py`, tests/`test_cli_introspection.py`. +- covers: c1, h1, c4, h4, c10, h10, c12, h12, c13, h13, c16, h16, c20, h20 +- acceptance: + - tests/`test_cli.py` walks the live argparse tree and asserts `registered_paths` == `known_paths`, every path appears in overview.`_VERBS` and learn commands, no help/learn/overview/catalog text contains 'microphone-cli ' as a command or the words clonable/template/scaffold + - uv run teken cli doctor . --strict passes 26/26; doctor --json shape is unchanged; learn --json audience names media-cli + - the full suite passes with /dev/snd and /dev/bus/usb absent and coverage >= 60% + +## Wave 4 + +### t12 — Docs and release: README rewritten in webcam-cli's shape (Status, Scope with non-goals: video, remote, STT/TTS, playback; CLI verb table; What comes out; Why device identity is the hard part), CLAUDE.md updated from scaffold-state to domain-state, docs/skill-sources.md re-synced to guildmaster, XVF3800 table attribution doc, CHANGELOG entry citing scaffold commit 5f9b1bd, version bump; CI workflows untouched + +- instruction: Use the version-bump skill. Cite ../webcam-cli/README.md headings and CLAUDE.md structure. Files owned: README.md, CLAUDE.md, CHANGELOG.md, pyproject.toml (version only), docs/skill-sources.md, docs/xvf3800-parameters.md. +- covers: c15, h15, c17, h17, c19, h19, c22, h22 +- acceptance: + - README has Status, Scope, CLI table, What comes out sections; markdownlint-cli2 passes on all changed markdown + - CHANGELOG top entry names 5f9b1bd and the new verbs; pyproject version is bumped; git diff main -- .github/workflows is empty; pyproject dependencies == \[\] + +## Wave 5 + +### t13 — On-device acceptance (issue #3): scripts/acceptance/run.sh and blind-consumer.sh drive every verb from --json alone against the Reachy Mini Lite; compare array doa with `reachy_mini`'s `DOA_VALUE_RADIANS` read; record evidence and the parked gain/PipeWire unknowns in docs/acceptance-microphone-domain.md + +- instruction: Cite ../webcam-cli/scripts/acceptance/{run.sh,blind-consumer.sh} and docs/acceptance-a-v-streaming.md. Requires the device enumerated on the host (lsusb shows 38fb:1001) — if absent, stop and report, do not fake evidence. Files owned: scripts/acceptance/\*\*, docs/acceptance-microphone-domain.md. +- covers: c18, h18, c21, h21 +- acceptance: + - docs/acceptance-microphone-domain.md records: list/inspect output, two DoA readings (CLI vs `reachy_mini`) with timestamps within 1 s and |delta| `<= 0.01 rad, aec get with >`= 5 fields, gain set/get round-trip, and the resolution of parks v2/v3/v4/v5 + - scripts/acceptance/blind-consumer.sh never reads /dev or is told a device id; it uses only the JSON payloads; all media under mktemp -d and deleted on exit + +## End state + +### Announcement + +- microphone-cli enumerates USB microphones and arrays by stable id, inspects channels and formats, gets and sets gain, and reads direction-of-arrival and AEC state from XVF3800-class array firmware — zero runtime deps, --json everywhere, and a contract media-cli can compose + +### After state + +- 'microphone list/inspect/gain/array doa/array aec' work with --json and typed exit codes 0/1/2/3, stdlib only; a blind consumer can enumerate a mic by stable id, read its formats, read azimuth+speech flag, read AEC state, and set gain under --apply — all without reachy_mini or pyusb installed + +### Success signals + +- teken cli doctor --strict stays 26/26 green and coverage >= 60% with every new verb having a catalog entry, learn entry and overview line (parity tests ported from webcam-cli tests/test_cli.py:190-256) +- On the Reachy Mini Lite, 'microphone array doa --json' returns azimuth within 0.01 rad of reachy_mini's audio_control_utils.py DOA_VALUE_RADIANS read taken within 1 s, and 'array aec get --json' reports >= 5 AEC fields; 'gain set --apply' round-trips to the same value on 'gain get' +- 0 runtime dependencies in pyproject.toml after the domain lands, and every test passes on ubuntu-latest CI with no audio hardware and no apt-get step + +### Terminal tasks + +- `t13` — On-device acceptance (issue #3): scripts/acceptance/run.sh and blind-consumer.sh drive every verb from --json alone against the Reachy Mini Lite; compare array doa with reachy_mini's DOA_VALUE_RADIANS read; record evidence and the parked gain/PipeWire unknowns in docs/acceptance-microphone-domain.md + - docs/acceptance-microphone-domain.md records: list/inspect output, two DoA readings (CLI vs reachy_mini) with timestamps within 1 s and |delta| <= 0.01 rad, aec get with >= 5 fields, gain set/get round-trip, and the resolution of parks v2/v3/v4/v5 + - scripts/acceptance/blind-consumer.sh never reads /dev or is told a device id; it uses only the JSON payloads; all media under mktemp -d and deleted on exit + +### Open items + +- [unknown_nonblocking] Whether AUDIO_MGR_MIC_GAIN (firmware float) and the ALSA capture mixer control on the XVF3800's UAC interface are the same knob or two independent stages — needs the device present and 'amixer -c N contents' to compare +- [unknown_nonblocking] PipeWire holds the ALSA device open while a session runs; whether 'inspect' can read /proc/asound/cardN/stream0 formats without EBUSY, and whether gain set via amixer is overridden by WirePlumber's saved state +- [unknown_nonblocking] Whether the PARAMETERS resid/cmdid map is identical on the older 2886:001a ReSpeaker XVF3800 firmware that reachy_mini warns about; only the 38fb:1001 build is known to match +- [unknown_nonblocking] The usbdevfs path is unverified against real hardware: no writable USB node exists on this host today (no device plugged, other nodes 0664 root:root), so the first end-to-end control transfer happens in issue #3 +- [unknown_nonblocking] WirePlumber may override amixer gain changes and PipeWire may hold the PCM (EBUSY) during gain (t6) and stream/record (t10) acceptance; the CLI must report busy (exit 3) honestly rather than retry +- [unknown_nonblocking] The PARAMETERS map is only known-good for the 38fb:1001 firmware build; on 2886:001a inspect's firmware block is the only diagnostic +- [unknown_nonblocking] The Reachy Mini Lite is not enumerated on this host today (user connected it, another project runs on it, nothing in lsusb); t13 cannot start until lsusb shows 38fb:1001 diff --git a/docs/plans/2026-09-06-microphone-domain.md b/docs/plans/2026-09-06-microphone-domain.md new file mode 100644 index 0000000..7b0b32e --- /dev/null +++ b/docs/plans/2026-09-06-microphone-domain.md @@ -0,0 +1,131 @@ +# Build Plan — microphone domain + +slug: `microphone-domain` · status: `exported` · from frame: `microphone-domain` + +> microphone-cli enumerates USB microphones and arrays by stable id, inspects channels and formats, gets and sets gain, and reads direction-of-arrival and AEC state from XVF3800-class array firmware — zero runtime deps, --json everywhere, and a contract media-cli can compose + +## Tasks + +### t1 — Typed errors and access model: `EXIT_BUSY_ERROR`=3 in cli/`_errors.py` and a new `microphone_cli`/access.py with AccessState {ok,absent,forbidden,busy}, `check_access`/`require_access`, /proc/\*/fd holder lookup, audio-group and udev-rule remediations + +- instruction: Cite `webcam_cli`/access.py (webcam-cli repo, ../webcam-cli) and adapt: keep kind='audio' and add kind='usb'. Do not touch cli/`__init__.py`. Files owned: `microphone_cli`/cli/`_errors.py`, `microphone_cli`/access.py, tests/`test_access.py`. +- covers: c3, h3 +- acceptance: + - `microphone_cli`/access.py exposes AccessState, AccessReport, `check_access`(path, kind), `require_access`(path, kind) and `access_error`(); tests/`test_access.py` covers all four states with a fake /proc tree + - `EXIT_BUSY_ERROR` == 3 is exported from cli/`_errors.py` and `access_error`() maps absent→1, forbidden→2, busy→3 with the holder pid/command in the busy message when /proc is readable + - forbidden on a /dev/bus/usb node produces a hint containing the udev rule line 'SUBSYSTEM=="usb", ATTR{idVendor}=='; forbidden on /dev/snd names the audio group + +### t2 — Device identity: `microphone_cli`/devices.py enumerates capture cards from /proc/asound + sysfs USB parent, builds stable ids from USB serial (sysfs path fallback), flags XVF3800 arrays by idVendor:idProduct (38fb:1001, 2886:001a), and resolve() refuses ambiguous or raw hw:N selectors; fixture trees under tests/fixtures/ + +- instruction: Cite `webcam_cli`/devices.py:105-159,286-387,470-531 and drop the video half. Frozen dataclasses with `as_dict`(). Fixture trees are synthetic /proc/asound, /sys/bus/usb/devices, /sys/class/sound files only — no real captures. Files owned: `microphone_cli`/devices.py, tests/`test_devices.py`, tests/fixtures/\*\*. +- covers: c2, h2, c31, h28, c14, h14 +- acceptance: + - `enumerate_devices`(root=...) on tests/fixtures/host-baseline and tests/fixtures/host-renumbered returns identical `stable_id` values while card index differs + - a tests/fixtures/two-arrays tree with two 38fb:1001 devices resolves each by serial and resolve('Reachy Mini Audio') exits 1 listing both candidates + - from `microphone_cli`.devices import `enumerate_devices`, resolve works with no other imports; each device's `as_dict`() carries `stable_id`, label, `alsa_address`, `card_id`, `usb_path`, `usb_ids`, serial, `is_array`, channels, and `pipewire_visible` (bool, from a sysfs/procfs heuristic or None) + +### t3 — XVF3800 control layer with stdlib only: `microphone_cli`/usbctl.py (`usbdevfs_ctrltransfer` ctypes struct, `USBDEVFS_CONTROL` ioctl, `O_RDWR` open, device lookup by idVendor/idProduct/serial in sysfs) and `microphone_cli`/xvf3800.py (PARAMETERS table vendored from `reachy_mini` with Apache-2.0 attribution, typed pack/unpack, read with status-64 retry loop, write, `firmware_info`()) + +- instruction: Port the protocol from ../`reachy_mini`/src/`reachy_mini`/media/`audio_control_utils.py` exactly (resid→wIndex, cmdid→wValue, 0x80 read bit, status byte 0/64). Keep the PARAMETERS table verbatim with a docstring citing the source file and license. Inject the ioctl via a module-level callable so tests never open /dev. Files owned: `microphone_cli`/usbctl.py, `microphone_cli`/xvf3800.py, tests/`test_usbctl.py`, tests/`test_xvf3800.py`. +- covers: c6, h6, c30, h27 +- acceptance: + - tests/`test_usbctl.py` asserts ctypes.sizeof(UsbdevfsCtrlTransfer) == struct.calcsize('BBHHHI' + 'P' with natural alignment) and `USBDEVFS_CONTROL` == 0xC0185500 on 64-bit + - with a fake ioctl, read('`DOA_VALUE_RADIANS`') sends bRequestType 0xC0, bRequest 0, wValue 0x80|19, wIndex 20, wLength 9 and decodes two little-endian floats after a status byte; a status-64 reply twice then 0 succeeds; 64 forever raises CliError(code=2) + - write('`AUDIO_MGR_MIC_GAIN`', \[0.5\]) sends bRequestType 0x40, wValue 0, wIndex 35, payload struct.pack('`' with --json; list --json on tests/fixtures/host-baseline prints {devices:\[...\], count:N} and the dicts equal `enumerate_devices`().`as_dict`() + - inspect --json on an array fixture includes formats\[\], rates\[\], channels and a firmware{version,build} block read through a fake ioctl; on a non-array mic firmware is null + +### t6 — Verb 'gain get|set': `_commands`/gain.py plus `microphone_cli`/mixer.py wrapping 'amixer -c `` cget/cset' for the ALSA capture control, and `AUDIO_MGR_MIC_GAIN` through xvf3800 on arrays; set is dry-run unless --apply and is written to the activation log + +- instruction: Parse 'amixer -c N contents' output for the capture-volume control; never call amixer in tests (monkeypatch subprocess.run). Files owned: `microphone_cli`/cli/`_commands`/gain.py, `microphone_cli`/mixer.py, tests/`test_gain.py`. +- depends on: t1, t3, t4 +- covers: c5, h5, c11, h11 +- acceptance: + - gain get --json on a non-array fixture reports {alsa:{control,value,min,max}} using a fake subprocess.run; on an array it adds {firmware:{`mic_gain`}} + - gain set `` 0.5 without --apply prints the planned amixer argv/control transfer and calls neither; with --apply both are issued and the activation log gains one line + +### t7 — Noun 'array': `_commands`/array.py with 'array overview', 'array doa ``' (single-shot or --watch JSON Lines with --interval/--count, SIGINT→exit 0, ENODEV→exit 2) and 'array aec get|set' (converged, bypass, high-pass, echo on/off, mic count, geometry; set needs --apply and logs) + +- instruction: Cite ../webcam-cli/`webcam_cli`/cli/`_commands`/stream.py:1603-1672 for the noun-group pattern and cli.py:38-40 for `parser_class`. Raw firmware radians, no coordinate transform. Files owned: `microphone_cli`/cli/`_commands`/array.py, tests/`test_array.py`. +- depends on: t3, t4 +- covers: c32, h29 +- acceptance: + - array doa --json prints {`azimuth_rad`, speech, source:'`DOA_VALUE_RADIANS`'} once; --watch --count 3 prints exactly 3 JSON lines; a fake ioctl raising OSError(ENODEV) on the third poll yields two stdout lines, one stderr error object, exit 2 + - array aec get --json reports at least converged, bypass, hpf, echo, `num_mics`, geometry; array aec set --echo off without --apply sends nothing; with --apply writes `PP_ECHOONOFF` and appends to the activation log + - the subparser group is built with `parser_class`=type(p) and bare 'array' prints the overview + +### t8 — Noun 'param': `_commands`/param.py with 'param overview', 'param list', 'param get ``', 'param set `` `` --apply \[--allow-persistent\]' over the full XVF3800 table; validates name/rw/count/type before packing; persistent/destructive names need --allow-persistent + +- instruction: Names match case-insensitively and echo upper-case. The persistent tier is a frozenset in xvf3800.py consumed here. Files owned: `microphone_cli`/cli/`_commands`/param.py, tests/`test_param.py` (may add PERSISTENT set to `microphone_cli`/xvf3800.py only if t3 did not). +- depends on: t3, t4 +- covers: c33, h30, c34, h31 +- acceptance: + - param set on an unknown name, a 'ro' name, or the wrong value count exits 1 with a hint and issues no transfer; param get on a 'wo' name exits 1 + - param set REBOOT 1 --apply exits 1 with a hint naming --allow-persistent and explaining volatile-vs-persistent; with --allow-persistent the transfer is sent and logged; `SAVE_CONFIGURATION`, `CLEAR_CONFIGURATION`, `TEST_CORE_BURN`, `TEST_AEC_DISABLE_CONTROL` and `SPECIAL_CMD_`\* are in the same tier + - param list --json emits every table entry with name, resid, cmdid, count, access, type, persistent(bool) + +### t9 — GStreamer audio engine: `microphone_cli`/engine.py detects gst-launch-1.0/gst-inspect-1.0 and the alsasrc/matroskamux/opusenc elements, and builds pure argv for an audio stream (RTP/UDP passthrough or opus) and a bounded record (Matroska/Opus or WAV) from an `alsa_address`, rate, channels, format + +- instruction: Cite the audio subset of ../webcam-cli/`webcam_cli`/engine.py (lines 65-87 element lists, 1052-1122 audio pipeline builders, 351-369 `require_engine`). No PyGObject/gi import. Files owned: `microphone_cli`/engine.py, tests/`test_engine.py`. +- acceptance: + - detect() with shutil.which monkeypatched to None returns Capability(available=False) and `require_engine`() raises CliError(code=2) whose hint names the apt package + - `build_audio_stream_argv`(...) and `build_audio_record_argv`(...) are pure (no subprocess) and tests assert the exact argv for passthrough and opus, including '-e' for EOS on shutdown and a queue element + +### t10 — Verbs 'stream audio' and 'record': `_commands`/stream.py (noun with overview + 'stream audio ``' --port --rate --channels --format --encode --apply --probe) and `_commands`/record.py ('record `` ``' --duration --max-bytes --rate --channels --format --apply --probe); dry-run by default, engine-missing exit 2, busy exit 3, activation-logged + +- instruction: Cite ../webcam-cli/`webcam_cli`/cli/`_commands`/stream.py and record.py, audio paths only; keep the dry-run/--probe/--apply three-level split and the `_payload` keys. Files owned: `microphone_cli`/cli/`_commands`/stream.py, `microphone_cli`/cli/`_commands`/record.py, tests/`test_stream.py`, tests/`test_record.py`. +- depends on: t1, t2, t4, t9 +- covers: c9, h9, h23 +- acceptance: + - stream audio and record without --apply print the pipeline argv, set `hardware_touched`=false in --json, write no file and spawn nothing (subprocess booby-trapped in tests) + - record --apply with a fake spawn honours --duration and --max-bytes (`stopped_reason` in the JSON), writes to the given path only, and appends an activation line; --duration > 3600 or --max-bytes > 4GiB exits 1 + +### t11 — Surface wiring: register every new module in cli/`__init__.py`, set prog='microphone', rewrite learn.py (`_TEXT` and `_as_json_payload` with hardware-touch split, exit codes 0/1/2/3, audience naming media-cli), overview.py `_VERBS`/`_HARDWARE`/`_CONTRACTS`, explain/catalog.py entries for every path, purge template prose, keep doctor unchanged; port webcam-cli's parity and no-template-prose tests + +- instruction: This is the only task that edits cli/`__init__.py`, learn.py, overview.py, catalog.py. Cite ../webcam-cli/`webcam_cli`/cli/`_commands`/{learn,overview}.py, explain/catalog.py and tests/`test_cli.py`:190-327. Files owned: those four modules, tests/`test_cli.py`, tests/`test_cli_introspection.py`. +- depends on: t5, t6, t7, t8, t10 +- covers: c1, h1, c4, h4, c10, h10, c12, h12, c13, h13, c16, h16, c20, h20 +- acceptance: + - tests/`test_cli.py` walks the live argparse tree and asserts `registered_paths` == `known_paths`, every path appears in overview.`_VERBS` and learn commands, no help/learn/overview/catalog text contains 'microphone-cli ' as a command or the words clonable/template/scaffold + - uv run teken cli doctor . --strict passes 26/26; doctor --json shape is unchanged; learn --json audience names media-cli + - the full suite passes with /dev/snd and /dev/bus/usb absent and coverage >= 60% + +### t12 — Docs and release: README rewritten in webcam-cli's shape (Status, Scope with non-goals: video, remote, STT/TTS, playback; CLI verb table; What comes out; Why device identity is the hard part), CLAUDE.md updated from scaffold-state to domain-state, docs/skill-sources.md re-synced to guildmaster, XVF3800 table attribution doc, CHANGELOG entry citing scaffold commit 5f9b1bd, version bump; CI workflows untouched + +- instruction: Use the version-bump skill. Cite ../webcam-cli/README.md headings and CLAUDE.md structure. Files owned: README.md, CLAUDE.md, CHANGELOG.md, pyproject.toml (version only), docs/skill-sources.md, docs/xvf3800-parameters.md. +- depends on: t11 +- covers: c15, h15, c17, h17, c19, h19, c22, h22 +- acceptance: + - README has Status, Scope, CLI table, What comes out sections; markdownlint-cli2 passes on all changed markdown + - CHANGELOG top entry names 5f9b1bd and the new verbs; pyproject version is bumped; git diff main -- .github/workflows is empty; pyproject dependencies == \[\] + +### t13 — On-device acceptance (issue #3): scripts/acceptance/run.sh and blind-consumer.sh drive every verb from --json alone against the Reachy Mini Lite; compare array doa with `reachy_mini`'s `DOA_VALUE_RADIANS` read; record evidence and the parked gain/PipeWire unknowns in docs/acceptance-microphone-domain.md + +- instruction: Cite ../webcam-cli/scripts/acceptance/{run.sh,blind-consumer.sh} and docs/acceptance-a-v-streaming.md. Requires the device enumerated on the host (lsusb shows 38fb:1001) — if absent, stop and report, do not fake evidence. Files owned: scripts/acceptance/\*\*, docs/acceptance-microphone-domain.md. +- depends on: t12 +- covers: c18, h18, c21, h21 +- acceptance: + - docs/acceptance-microphone-domain.md records: list/inspect output, two DoA readings (CLI vs `reachy_mini`) with timestamps within 1 s and |delta| <= 0.01 rad, aec get with >= 5 fields, gain set/get round-trip, and the resolution of parks v2/v3/v4/v5 + - scripts/acceptance/blind-consumer.sh never reads /dev or is told a device id; it uses only the JSON payloads; all media under mktemp -d and deleted on exit + +## Risks + +- [unknown_nonblocking] WirePlumber may override amixer gain changes and PipeWire may hold the PCM (EBUSY) during gain (t6) and stream/record (t10) acceptance; the CLI must report busy (exit 3) honestly rather than retry (task t6) +- [unknown_nonblocking] The PARAMETERS map is only known-good for the 38fb:1001 firmware build; on 2886:001a inspect's firmware block is the only diagnostic (task t3) +- [unknown_nonblocking] The Reachy Mini Lite is not enumerated on this host today (user connected it, another project runs on it, nothing in lsusb); t13 cannot start until lsusb shows 38fb:1001 (task t13) diff --git a/docs/skill-sources.md b/docs/skill-sources.md index fad1462..d206dc0 100644 --- a/docs/skill-sources.md +++ b/docs/skill-sources.md @@ -40,6 +40,8 @@ is load-bearing, even where guildmaster's upstream copy omits it. | `agent-config` | `../guildmaster/.claude/skills/agent-config/` | guildmaster (origin steward) | Shows a Culture agent's full config; run `scripts/show.sh` directly (no `guild` binary required). `scripts/show.sh` + `data/backend-fingerprints.yaml` verbatim. Verbatim except added `type: command`. | 2026-05-26 (guildmaster 0.6.0) | | `doc-test-alignment` | `../guildmaster/.claude/skills/doc-test-alignment/` | guildmaster | **STUB** — `scripts/check.sh` exits not-yet-implemented; the contract lives in SKILL.md. Verbatim except added `type: command`. | 2026-05-26 (guildmaster 0.6.0) | | `pypi-maintainer` | `../guildmaster/.claude/skills/pypi-maintainer/` | guildmaster | Switch a package install between PyPI / TestPyPI / local editable (`scripts/switch-source.sh`). Verbatim except added `type: command`. | 2026-05-26 (guildmaster 0.6.0) | +| `recall` | `../eidetic-cli/.claude/skills/recall/` | **eidetic-cli** (first-party, not guildmaster) | Read half of the shared eidetic memory surface — `eidetic recall` (exact/approximate/keyword/hybrid search), returning provenanced, ranked hits. Consumer-identifying prose adapted (`--scope microphone-cli`). eidetic-cli owns its memory surface directly; downstream repos cite, not import — this is not routed through guildmaster. Pairs with `remember`. | 2026-09-05 | +| `remember` | `../eidetic-cli/.claude/skills/remember/` | **eidetic-cli** (first-party, not guildmaster) | Write half of the shared eidetic memory surface — `eidetic remember` (idempotent upsert by id, content-hash dedup, NDJSON batch ingest). Consumer-identifying prose adapted (`--scope microphone-cli`). Same first-party-to-eidetic-cli provenance as `recall`. | 2026-09-05 | | `run-tests` | `../guildmaster/.claude/skills/run-tests/` | guildmaster | pytest + xdist + coverage (`scripts/test.sh`). Verbatim except added `type: command`. | 2026-05-26 (guildmaster 0.6.0) | | `sonarclaude` | `../guildmaster/.claude/skills/sonarclaude/` | guildmaster | SonarCloud API queries (`scripts/sonar.sh`). Verbatim except added `type: command`. | 2026-05-26 (guildmaster 0.6.0) | | `think` | `../guildmaster/.claude/skills/think/` | **devague** (re-broadcast via guildmaster) | idea→spec leg of the devague workflow chain. Verbatim (already carried `type: command` at guildmaster). Origin/broadcast prose left verbatim. | 2026-09-05 (devague 0.24.1 via guildmaster) | diff --git a/docs/specs/2026-09-06-microphone-domain.md b/docs/specs/2026-09-06-microphone-domain.md new file mode 100644 index 0000000..fed6def --- /dev/null +++ b/docs/specs/2026-09-06-microphone-domain.md @@ -0,0 +1,173 @@ +# microphone domain + +> microphone-cli enumerates USB microphones and arrays by stable id, inspects channels and formats, gets and sets gain, and reads direction-of-arrival and AEC state from XVF3800-class array firmware — zero runtime deps, --json everywhere, and a contract media-cli can compose +> instruction: verified when every announced verb runs with --json in a clean venv (uv run microphone `` --json) and issue #3's on-device checklist is complete + +## Audience + +- Agents (media-cli, reachy-mini-cli, Culture mesh agents) and operators driving an XVF3800-class USB microphone array from a shell, plus media-cli as an importing/subprocessing consumer + - instruction: learn.py names both readers; README Scope section names media-cli as the consumer + +## Before → After + +- Before: microphone-cli is a bare template scaffold (whoami/learn/explain/overview/doctor/cli only, template prose, prog mismatch); the only way to read DoA/AEC or set XVF3800 gain today is `reachy_mini`'s `audio_control_utils.py`, which needs pyusb and the full SDK + - instruction: git show ed00ba0:`microphone_cli`/cli/`__init__.py` shows no domain verbs; `reachy_mini` `audio_control_utils.py` imports usb.core +- After: 'microphone list/inspect/gain/array doa/array aec' work with --json and typed exit codes 0/1/2/3, stdlib only; a blind consumer can enumerate a mic by stable id, read its formats, read azimuth+speech flag, read AEC state, and set gain under --apply — all without `reachy_mini` or pyusb installed + - instruction: uv run microphone `` --json in a venv with only microphone-cli installed; on-device run tracked in issue #3 + +## Why it matters + +- media-cli needs a microphone peer the way it needs webcam-cli, and the XVF3800's DoA/AEC controls are currently locked behind a robot SDK; a zero-dep agent-first CLI makes them a composable contract instead of a copy-pasted parameter table + - instruction: media-cli CLAUDE.md:23 routes capture to peers; issue #3 records the device to prove it on + +## Requirements + +- Domain modules land under `microphone_cli`/ as frozen dataclasses with `as_dict`(), mirroring `webcam_cli`/devices.py:105-159 (VideoNode/AudioCard/LogicalDevice) — AudioCard.`alsa_address` 'hw:CARD=...' is the stable handle, card index is ephemeral + - honesty: list output on the host-baseline fixture tree is byte-identical across a renumber fixture (card index changes, `stable_id` does not) +- Access model reuses `webcam_cli`/access.py's AccessState {ok,absent,forbidden,busy} → exit 1/2/3, with audio-group remediation for FORBIDDEN and /proc/\*/fd holder lookup for BUSY; this repo's `_errors.py` (`EXIT_SUCCESS`/USER/ENV only) gains `EXIT_BUSY_ERROR`=3 + - honesty: `EXIT_BUSY_ERROR`=3 is raised when another process holds the PCM open, with the holder pid/command in the message when /proc is readable +- New verbs: 'list' (--root PATH, --json → {devices,count}), 'inspect ``' (channels, formats, rates from /proc/asound/cardN/stream0 and pcm\*c), 'gain get|set `` \[--apply\]', 'array doa ``', 'array aec get|set'; each registered in `_build_parser`, given a catalog.py entry, a learn.py command-map entry, an overview.`_VERBS` line, and every noun group exposes '`` overview' with `parser_class`=type(p) + - honesty: tests/`test_cli.py` asserts `registered_paths` == `known_paths` and every path appears in overview.`_VERBS` and learn commands +- Gain get/set is new ground with no webcam-cli precedent (`webcam_cli` has no control API; only warm-up frame discard, engine.py:110-148). Implement via ALSA mixer: 'amixer -c `` cget/cset' subprocess (present, alsa-utils 1.2.9) for generic USB mics, and `AUDIO_MGR_MIC_GAIN` (resid 35, cmd 0, float) via the XVF3800 vendor control path for arrays + - honesty: gain get/set on a non-array USB mic works through amixer alone; on an XVF3800 it also reports `AUDIO_MGR_MIC_GAIN` +- DoA and AEC are read/written through USB vendor control transfers exactly as `reachy_mini`/media/`audio_control_utils.py` does for the XVF3800: bRequest=0, wValue=cmdid (|0x80 for read), wIndex=resid, `CTRL_TYPE_VENDOR`|`RECIPIENT_DEVICE`; `DOA_VALUE_RADIANS`=(resid 20, cmd 19, 2 floats: azimuth radians + speech flag); AEC params on resid 33 (`AEC_AECCONVERGED` cmd 3 ro, `AEC_HPFONOFF` cmd 1 rw, `SHF_BYPASS` cmd 70 rw, `AEC_NUM_MICS` cmd 71, `AEC_MIC_ARRAY_GEO` cmd 74) and `PP_ECHOONOFF` (resid 17, cmd 23); read status byte 0=ok, 64=retry; the XMOS control-command appendix (XM-014888-PC) is the upstream reference + - honesty: a fake ioctl layer replays the SDK's documented request bytes (bRequest 0, wValue cmd|0x80, wIndex resid) and the parser decodes status byte 0/64 and little-endian floats exactly as `audio_control_utils.py` does +- Video is out of scope. Audio capture verbs ARE in scope: 'stream audio ``' and 'record `` ``' over GStreamer alsasrc (subprocess gst-launch-1.0, same dry-run/--probe/--apply split and typed engine-missing exit 2 as `webcam_cli`/engine.py), so a consumer can attach to or save the array's processed output without webcam-cli + - honesty: stream audio never opens the device without --apply; dry-run prints the pipeline argv and exits 0 with `hardware_touched`=false + - honesty: record without --apply writes no file; with --apply the output is bounded by --duration/--max-bytes +- Console-script/prog mismatch is fixed as part of this work: prog becomes 'microphone' to match \[project.scripts\] (webcam-cli did the same, `__init__.py`:9-16, guarded by `test_no_user_facing_string_presents_webcam_cli_as_a_command`); all template prose (learn.py:15-55, catalog.py:15,84, `__init__.py`:74, whoami.py:7, overview.py:4) is rewritten and a `test_no_template_prose_survives` test is ported + - honesty: grep -r 'microphone-cli ' on --help, learn, overview, catalog finds no runnable-command usage; prog == 'microphone' +- media-cli contract: expose both an importable API (`microphone_cli`.devices.`enumerate_devices`/resolve, `microphone_cli`.array.`read_doa`) and the --json CLI, because media-cli CLAUDE.md:230-245 (Q1) has not decided between import and subprocess; device ids are stable (ALSA card id + USB serial), formats are reported not assumed, and ALSA-visible-but-PipeWire-invisible devices are flagged + - honesty: from `microphone_cli`.devices import `enumerate_devices` works in a clean venv and returns the same dicts 'microphone list --json' prints +- Docs follow webcam-cli's shape: README gains Status/Scope/'What comes out'/'Why device identity is the hard part' sections and a verb table; docs/specs, docs/plans, docs/deliveries and a docs/acceptance-\*.md land through the devague flow; docs/skill-sources.md is re-synced to guildmaster (webcam-cli's lists 8 skills, this repo's lists 7 and still cites ../devague directly) + - honesty: README has Status/Scope/CLI-table sections and docs/skill-sources.md matches webcam-cli's guildmaster provenance +- The stdlib control-transfer layer packs struct `usbdevfs_ctrltransfer` exactly as /usr/include/linux/`usbdevice_fs`.h:40-48 (u8 bRequestType, u8 bRequest, u16 wValue, u16 wIndex, u16 wLength, u32 timeout ms, void\* data) and issues `USBDEVFS_CONTROL` = `_IOWR`('U', 0, struct); the node must be opened `O_RDWR`, so on a host without a udev rule /dev/bus/usb/BBB/DDD (default 0664 root:root) yields FORBIDDEN exit 2 whose remediation prints the udev rule line to add + - honesty: a unit test asserts struct.calcsize of the packed layout equals ctypes.sizeof(the ctypes Structure) and the ioctl number matches the header; the forbidden path's hint contains 'SUBSYSTEM=="usb", ATTR{idVendor}==' +- Two arrays with the same VID:PID must be addressable: the stable id is built from the USB serial (sysfs 'serial' attr) with the sysfs device path as fallback; an ambiguous selector exits 1 listing candidates, as `webcam_cli`/devices.py:470-531 resolve() does + - honesty: a fixture tree with two 38fb:1001 devices resolves each by serial and rejects the bare product name as ambiguous +- array doa --watch lifecycle: SIGINT ends the loop with exit 0 after flushing; device unplug mid-loop (ENODEV/ENOENT on the node) ends with exit 2 and one error object on stderr, never a traceback; each poll re-resolves nothing (the fd stays open) so USB bus/dev renumbering cannot silently switch devices + - honesty: a fake ioctl raising OSError(ENODEV) on the third poll produces exactly two stdout lines, one stderr error, exit 2 +- param set treats persistence and destructive commands as a separate tier: `SAVE_CONFIGURATION`, `CLEAR_CONFIGURATION`, REBOOT, `TEST_CORE_BURN`, `TEST_AEC_DISABLE_CONTROL` and the `SPECIAL_CMD_`\* filter/model uploads require --apply AND --allow-persistent, and the hint explains that every other rw write is volatile and reverts on power-cycle — that volatility is the rollback path + - honesty: param set REBOOT --apply without --allow-persistent exits 1 and sends nothing; the same with --allow-persistent sends the transfer and is written to the activation log +- Every --apply action (record, stream audio, gain set, aec set, param set) is appended to an activation log modelled on `webcam_cli`/activation.py: JSONL under `XDG_STATE_HOME` with a `MICROPHONE_ACTIVATION_LOG` override, recording verb, device stable id, parameters written, and timestamps, so a consumer or operator can audit what touched the microphone + - honesty: after gain set --apply the log's last line parses as JSON with verb, device, and the value written; without --apply nothing is appended + +## Honesty conditions + +- each verb named in the announcement exists in the live argparse tree and has a catalog entry +- pyproject dependencies stays \[\] and 'import usb' appears nowhere in `microphone_cli`/ +- 38fb:1001 and 2886:001a are matched from /sys/bus/usb/devices/\*/idVendor+idProduct; any other id is reported as 'not an XVF3800 array' rather than probed +- doctor --json shape is unchanged: {healthy, checks:\[{id,passed,severity,message,remediation}\]} with only identity checks +- no write path (gain set, aec set, `SAVE_CONFIGURATION`) issues a control transfer or amixer cset unless --apply is present +- the full suite passes with /dev/snd and /dev/bus/usb absent (fixture root only) +- learn.py --json 'audience' names media-cli +- the CHANGELOG entry for the domain release cites the scaffold commit it replaces +- a blind-consumer script (like webcam-cli scripts/acceptance/blind-consumer.sh) drives every verb from --json output alone +- media-cli can consume microphone-cli without a decision on import-vs-subprocess because both surfaces exist +- CI lint job output shows 26/26 passed +- acceptance doc records the two DoA readings side by side with timestamps +- tests.yml diff against main is empty +- a fake ioctl that returns status 64 twice then 0 yields a successful read; a fake that returns 64 forever exits 2 with a 'device busy with another controller' hint +- `microphone_cli`/engine.py's element list and `require_engine`() behaviour match `webcam_cli`/engine.py for the audio subset +- docs/ names the `reachy_mini` source file and license for the table; inspect --json on an array includes firmware.version and firmware.build +- param set on an unknown name, on a ro name, or with the wrong value count each exit 1 with a hint and issue no transfer + +## Success signals + +- teken cli doctor --strict stays 26/26 green and coverage >= 60% with every new verb having a catalog entry, learn entry and overview line (parity tests ported from webcam-cli tests/`test_cli.py`:190-256) + - instruction: uv run teken cli doctor . --strict; uv run pytest --cov=`microphone_cli` +- On the Reachy Mini Lite, 'microphone array doa --json' returns azimuth within 0.01 rad of `reachy_mini`'s `audio_control_utils.py` `DOA_VALUE_RADIANS` read taken within 1 s, and 'array aec get --json' reports >= 5 AEC fields; 'gain set --apply' round-trips to the same value on 'gain get' + - instruction: issue #3 checklist; evidence recorded in docs/acceptance-microphone-domain.md +- 0 runtime dependencies in pyproject.toml after the domain lands, and every test passes on ubuntu-latest CI with no audio hardware and no apt-get step + - instruction: grep 'dependencies = \[\]' pyproject.toml; CI tests.yml unchanged apart from names + +## Scope / boundaries + +- 'doctor' stays an agent-identity check (prompt file + backend consistency + skills); hardware readiness surfaces through 'list' and the typed exit codes, per `webcam_cli`/cli/`_commands`/doctor.py:1-18 +- Writes are dry-run by default and need --apply (gain set, aec set, any `SAVE_CONFIGURATION`), following webcam-cli's --probe/--apply three-level split and media-cli's dry-run-by-default convention; reads (list, inspect, doa, aec get, gain get) touch hardware only via read-only control transfers and never mutate firmware state +- CI stays hardware-free: tests replay synthetic /proc/asound, /sys/bus/usb and /dev trees under tests/fixtures/ via the root= param and monkeypatch the ioctl/subprocess boundary; no apt-get or virtual sound card is added to tests.yml +- param get|set accepts only names present in the table (case-insensitive match, echoed upper-case), rejects 'ro' targets on set and 'wo' targets on get, enforces value count and type before packing, and never lets the user supply raw resid/cmdid bytes + +## Assumptions + +- Array firmware identification targets the XVF3800 class: match USB ids 38fb:1001 (Reachy Mini Audio firmware) and 2886:001a (Seeed ReSpeaker XVF3800 firmware, warn that `reachy_mini` considers it old), per `reachy_mini` `init_respeaker_usb`(); other ReSpeaker products (e.g. 2886:0018, a different XMOS part) stay out until their control map is verified. On-device acceptance runs against a Reachy Mini Lite over USB when it is connected +- A reachy-mini-daemon (running locally today, pid 3180, and on the Reachy Mini at 192.168.1.162) polls `DOA_VALUE_RADIANS` on the same XVF3800 while the CLI runs; the firmware answers concurrent control reads with status 64 (`SERVICER_COMMAND_RETRY`) and the CLI must retry like `audio_control_utils.py`:250-275 (up to 100 attempts, 10 ms apart) rather than fail +- stream audio and record reuse `webcam_cli`/engine.py's approach verbatim (cite-don't-import): alsasrc is a core element (engine.py:71), pulsesrc/pipewire are optional (engine.py:87), gst-launch-1.0 is shelled out and its absence is a typed exit 2; on this host gst-launch-1.0 and the pipewire source are present +- The XVF3800 PARAMETERS table is copied from `reachy_mini` `audio_control_utils.py` (Apache-2.0) with attribution, cite-don't-import; resid/cmdid values are firmware-build-specific, so 'inspect' on an array also reports VERSION, `BLD_MSG` and `BLD_REPO_HASH` (resid 48) so a mismatch is diagnosable + +## Scope exploration + +- `s1` — `webcam_cli/devices.py (device model, lines 105-159, 286-387)`: Enumeration is pure filesystem parsing of /dev/v4l/by-id, /proc/asound/cards and /proc/asound/cardN/pcm\*c with a root= injection param; mic identity = ALSA card id + USB sysfs parent. microphone-cli reuses the /proc/asound + sysfs walk for capture cards and drops the video half + - seeds: `c2` +- `s2` — `webcam_cli/access.py:64-89,354-397 and microphone_cli/cli/_errors.py:21-23`: webcam-cli distinguishes absent/forbidden/busy with exit 1/2/3 and per-subsystem remediation; microphone-cli's scaffold only defines exit 0/1/2, so the busy category (3) must be added — media-cli CLAUDE.md:199-201 wants forbidden vs busy kept distinct + - seeds: `c3` +- `s3` — `microphone_cli/cli/__init__.py:64-119 + _commands/cli.py:38-40 + explain/catalog.py + CLAUDE.md 'Adding a verb or noun'`: The registration spot is `__init__.py`:91-93; the rubric (teken cli doctor, 26 checks, currently 26/26 green in both repos) requires explain-per-path, `overview_cli_noun_exists`, --json everywhere, no traceback; webcam-cli's tests/`test_cli.py`:190-256 walks the live argparse tree to enforce catalog/learn/overview parity and should be ported + - seeds: `c4` +- `s4` — `webcam_cli/engine.py:110-148 (warm-up) + host amixer 1.2.9`: webcam-cli never reads or writes a hardware control, so gain has no sibling convention; ALSA amixer exists on the host and shells out cleanly (zero-dep), and the XVF3800 exposes a separate firmware-level mic gain + - seeds: `c5` +- `s5` — `../reachy_mini/src/reachy_mini/media/audio_control_utils.py:1-330 + docs media_advanced_controls.md:56-108`: The target is the XMOS XVF3800 audio processor class (Seeed ReSpeaker XVF3800 boards and Pollen's Reachy Mini Audio card, which is an XVF3800 derivative, per `reachy_mini` docs hardware.md:46). `reachy_mini`'s `audio_control_utils.py` PARAMETERS table (name → resid, cmdid, count, rw, type) is the XVF3800 control-command map and reads DoA via `DOA_VALUE_RADIANS`; the XMOS control-command appendix is the upstream reference. A Reachy Mini Lite (USB) is available to connect for acceptance testing later + - seeds: `c6` +- `s6` — `pyproject.toml dependencies=[] (both repos) + reachy_mini audio_control_utils.py imports usb.core/libusb_package + host python3 3.12 ctypes/fcntl ok, pyusb not installed`: Both siblings hold dependencies=\[\] deliberately (`webcam_cli`/engine.py:10-14 refuses even PyGObject); pyusb is not on the host, so DoA/AEC must go through usbdevfs ioctl in stdlib or break the constraint + - seeds: `c7` +- `s7` — `../reachy_mini audio_control_utils.py:353-411 (init_respeaker_usb) + /etc/udev/rules.d/{60-respeaker,99-reachy-mini-audio}.rules`: The SDK tries 38fb:1001 then 2886:001a; host udev rules already grant MODE=0666 to 38fb:1001 and 2886:0018, so non-root control transfers work for the Reachy device without new setup + - seeds: `c8` +- `s8` — `webcam_cli/cli/_commands/stream.py (stream audio) + media-cli CLAUDE.md:23`: webcam-cli already streams audio via GStreamer and media-cli routes capture to it; duplicating capture here would create two owners for the same lane + - seeds: `c9` +- `s9` — `webcam_cli/cli/_commands/doctor.py:1-18 and catalog _DOCTOR`: webcam-cli explicitly kept doctor free of device checks and points readiness questions at list; the rubric depends on doctor's exact {healthy,checks} shape + - seeds: `c10` +- `s10` — `webcam-cli learn.py:27-142 hardware-touch split + media-cli CLAUDE.md:387-392`: Both siblings treat physically observable side effects as opt-in via --apply; DoA read is a read-only vendor request so it is safe without --apply + - seeds: `c11` +- `s11` — `.github/workflows/tests.yml (identical between repos, no apt-get) + webcam-cli tests/fixtures/{host-baseline,host-renumbered,camera-only,degraded}`: webcam-cli's CI installs nothing domain-specific and every task before on-host acceptance was built hardware-free; ubuntu-latest has no capture device + - seeds: `c12` +- `s12` — `microphone_cli template-prose grep + pyproject [project.scripts] + webcam_cli/cli/__init__.py:9-16`: Nine template strings remain in `microphone_cli`/ and prog='microphone-cli' contradicts the installed 'microphone' binary; webcam-cli resolved both with a regression test + - seeds: `c13` +- `s13` — `../media-cli (grep: zero code hits for microphone/webcam; CLAUDE.md:23,41-42,117-158,230-267)`: media-cli is itself a scaffold with no device code and an unresolved import-vs-subprocess question; its only settled asks are stable identity, format reporting, forbidden-vs-busy distinction and dry-run-by-default + - seeds: `c14` +- `s14` — `README.md headings (both repos) + docs/ listings + docs/skill-sources.md diff`: microphone-cli's README is still the template shape and its skill-sources ledger lags webcam-cli's; webcam-cli carries spec/plan/delivery/acceptance docs for its feature + - seeds: `c15` +- `s15` — `challenge pass / adjacent-systems lens: reachy_mini daemon routers/state.py:68,122 + audio_control_utils.py:250-275 + pgrep reachy-mini-daemon`: the daemon reads DoA concurrently through the same vendor-control path; the firmware's retry status is the only arbitration, so the CLI must honour it + - seeds: `c27` +- `s16` — `challenge pass / adjacent-systems lens: webcam_cli/engine.py:65-87,1052-1122 + which gst-launch-1.0`: webcam-cli already solved GStreamer detection and alsasrc pipeline building for audio; reusing it avoids a second engine + - seeds: `c28` +- `s17` — `challenge pass / unstated-assumptions lens: reachy_mini LICENSE (Apache-2.0) + PARAMETERS table provenance`: the table's provenance and firmware-specificity were nowhere in the frame + - seeds: `c29` +- `s18` — `challenge pass / unstated-assumptions lens: /usr/include/linux/usbdevice_fs.h:40-48,187 + ls -l /dev/bus/usb`: the ioctl struct and number are verifiable from the header without hardware; permissions on a fresh host are the likely first failure + - seeds: `c30` +- `s19` — `challenge pass / overlooked-actors lens: webcam_cli/devices.py:470-531 resolve() + /sys/bus/usb/devices/*/serial`: multi-array hosts were not considered; serial is the only stable discriminator + - seeds: `c31` +- `s20` — `challenge pass / lifecycle lens: --watch mode (decision c24) + reachy_mini examples/debug/sound_doa.py loop`: the watch decision named the output format but not termination or unplug behaviour + - seeds: `c32` +- `s21` — `challenge pass / reversibility lens: PARAMETERS table 'wo' entries resid 48 (REBOOT/SAVE/CLEAR) and SPECIAL_CMD_* filter coefficient uploads`: an unguarded generic escape hatch can persist or brick the array; volatile writes are self-reverting, persistent ones are not + - seeds: `c33` +- `s22` — `challenge pass / security lens: audio_control_utils.py write() validation (ro check, count check)`: the SDK validates only ro and count; type validation and raw-byte refusal are additions + - seeds: `c34` +- `s23` — `challenge pass / observability-and-consent lens: webcam_cli/activation.py:28-129 + media-cli CLAUDE.md consent posture`: the frame carried dry-run-by-default but no audit trail; microphones are privacy-sensitive and webcam-cli already has the pattern + - seeds: `c35` +- `s24` — `challenge pass / concurrency lens: PipeWire/WirePlumber (pgrep) + usbdevfs vs snd-usb-audio binding`: clean beyond the retry assumption above and parked items v2/v3: device-recipient vendor control transfers do not need the audio interface claimed, so snd-usb-audio/PipeWire holding the PCM does not block DoA/AEC reads (the SDK never detaches the kernel driver); residual risk is only the ALSA mixer vs WirePlumber saved-state interplay already parked +- `s25` — `challenge pass / cheap-probes lens: usbdevice_fs.h, which gst-launch-1.0, gst-inspect-1.0 pipewire, ls -l /dev/bus/usb, pgrep reachy-mini-daemon, reachy_mini LICENSE`: all probes were read-only; an end-to-end control-transfer probe is impossible today (no writable USB node) and is deferred to issue #3 +- `s26` — `challenge pass / cheap-probes lens: live device after user connected it (lsusb, /proc/asound/cards, journalctl -k)`: the user reports the Reachy is connected over USB but nothing enumerates on this host (no 38fb/2886 id, no capture card, no kernel USB event), so the read-only DoA probe could not run; the ioctl path stays parked (v5) for issue #3 + +## Decisions + +- Zero runtime deps is kept: the vendor control transfer is issued with stdlib only — ctypes/fcntl `USBDEVFS_CONTROL` ioctl on /dev/bus/usb/BBB/DDD (device located by matching idVendor/idProduct in /sys/bus/usb/devices/\*/) — instead of pyusb + `libusb_package` that `reachy_mini` imports +- AEC surface in v1 is read AND write: 'array aec get' reports converged/bypass/high-pass/echo/mic count/geometry and 'array aec set' toggles `PP_ECHOONOFF`, `SHF_BYPASS`, `AEC_HPFONOFF` under --apply + - instruction: array aec set --apply round-trips on the Reachy Mini Lite (issue #3) +- 'array doa' supports --watch: one JSON object per poll on stdout as JSON Lines (jsonl), --interval seconds, runs until SIGINT or --count; single-shot without --watch + - instruction: array doa --watch --count 3 --json prints exactly 3 lines each parseable by json.loads +- A generic 'param get ``' / 'param set `` `` --apply' escape hatch covers the full XVF3800 PARAMETERS table (name → resid, cmdid, count, rw, type); the array backend is an abstraction so more devices and array technologies can be added later + - instruction: param get VERSION returns firmware version; param set on an 'ro' entry exits 1 with a hint; a second backend can register without touching the CLI verbs +- A 'record `` ``' verb is in scope alongside 'stream audio': both via GStreamer alsasrc subprocess, both dry-run by default, both need --apply because they are physically observable; record is bounded by --duration and --max-bytes like webcam-cli's + - instruction: record without --apply prints the pipeline and writes nothing; with --apply writes a Matroska/Opus or WAV file of bounded size +- v1 targets a locally attached USB XVF3800 only. Non-goals: reaching a Reachy Mini over the network through its daemon REST, speech-to-text, text-to-speech, and speaker/monitor playback — those belong to the `reachy_mini`/`reachy_nova` stack; microphone-cli must coexist with that project holding the device (busy → exit 3, DoA/AEC reads still work via the firmware retry path) + - instruction: no network code in `microphone_cli`/; README Scope section lists STT/TTS/playback/remote as non-goals + +## Open parks + +- [unknown_nonblocking] Whether `AUDIO_MGR_MIC_GAIN` (firmware float) and the ALSA capture mixer control on the XVF3800's UAC interface are the same knob or two independent stages — needs the device present and 'amixer -c N contents' to compare +- [unknown_nonblocking] PipeWire holds the ALSA device open while a session runs; whether 'inspect' can read /proc/asound/cardN/stream0 formats without EBUSY, and whether gain set via amixer is overridden by WirePlumber's saved state +- [unknown_nonblocking] Whether the PARAMETERS resid/cmdid map is identical on the older 2886:001a ReSpeaker XVF3800 firmware that `reachy_mini` warns about; only the 38fb:1001 build is known to match +- [unknown_nonblocking] The usbdevfs path is unverified against real hardware: no writable USB node exists on this host today (no device plugged, other nodes 0664 root:root), so the first end-to-end control transfer happens in issue #3 + +## Resolved vagueness + +- [unknown_blocking] No capture device is attached to the host right now (arecord -l empty, lsusb shows no 38fb/2886 device, WirePlumber default source names the Reachy Mini Audio but it is unplugged), so DoA/AEC and gain cannot be acceptance-tested until the device is connected — resolved: build against fixture trees; on-device acceptance tracked in issue #3 with the Reachy Mini Lite diff --git a/docs/xvf3800-parameters.md b/docs/xvf3800-parameters.md new file mode 100644 index 0000000..3b3fe2f --- /dev/null +++ b/docs/xvf3800-parameters.md @@ -0,0 +1,86 @@ +# XVF3800 parameter table — provenance and guide + +`microphone_cli.xvf3800.PARAMETERS` (126 entries, keyed by name to +`(resid, cmdid, count, access, type)`) is vendored **verbatim** from Pollen +Robotics' [`reachy_mini`](https://github.com/pollen-robotics/reachy_mini), +file `src/reachy_mini/media/audio_control_utils.py`, licensed **Apache-2.0**. +The wire protocol `microphone_cli/xvf3800.py` implements is a port of the +same file, with the transport rewritten onto a stdlib `usbdevfs` ioctl +(`microphone_cli/usbctl.py`) instead of `pyusb`, since microphone-cli carries +no runtime dependencies. Two deliberate deviations from the upstream port are +called out in the module docstring — both bug fixes to how a multi-value +reply is unpacked, not changes to any parameter's identity. + +The upstream reference for the protocol and every parameter's meaning is the +XMOS control-command appendix, document **XM-014888-PC**: +. +This file is a guide to the table `microphone-cli` carries, not a +restatement of that appendix — consult it for what an individual parameter +actually does. + +## The `resid` groups + +Every parameter belongs to one of five XMOS "resource id" groups. The table +below is `microphone-cli`'s parameter count per group, not the XMOS spec's: + +| resid | Group | Params in this table | Example names | +|-------|-------|----------------------:|----------------| +| 48 | Application | 11 | `VERSION`, `BLD_MSG`, `BLD_HOST`, `BLD_REPO_HASH` | +| 33 | AEC (acoustic echo cancellation) | 33 | `SHF_BYPASS`, `AEC_NUM_MICS`, `AEC_MIC_ARRAY_TYPE`, `AEC_AECCONVERGED` | +| 35 | Audio manager | 28 | `AUDIO_MGR_MIC_GAIN`, `AUDIO_MGR_REF_GAIN`, `AUDIO_MGR_CURRENT_IDLE_TIME` | +| 20 | GPO / LED / DoA | 15 | `GPO_READ_VALUES`, `GPO_WRITE_VALUE`, `GPO_PORT_PIN_INDEX`, `GPO_PIN_VAL` | +| 17 | Post-processing | 39 | `PP_CURRENT_IDLE_TIME`, `PP_MIN_IDLE_TIME`, `PP_RESET_MIN_IDLE_TIME` | + +`microphone array doa` reads `DOA_VALUE_RADIANS` — the direction-of-arrival +parameter, in the GPO/LED/DoA group (resid 20) alongside the array's GPIO and +LED controls. `microphone array aec get|set` reads and writes a curated +subset of the AEC group (resid 33): `AEC_AECCONVERGED`, `SHF_BYPASS`, +`AEC_HPFONOFF`, `PP_ECHOONOFF`, `AEC_NUM_MICS`, `AEC_MIC_ARRAY_TYPE`, +`AEC_MIC_ARRAY_GEO`, `AEC_RT60`. `microphone gain set --target firmware` +writes `AUDIO_MGR_MIC_GAIN` in the audio-manager group (resid 35). Every +other row in the table is reachable through `microphone param get|set`, keyed +by name, case-insensitive. + +## The persistent tier + +`PERSISTENT` names a fixed set of 18 parameters that either persist across a +power-cycle, trigger a reboot, or are otherwise destructive — unlike an +ordinary `rw` write, which is volatile and reverts when the array loses +power: + +- `SAVE_CONFIGURATION`, `CLEAR_CONFIGURATION` — write or erase the array's + persisted configuration. +- `REBOOT` — restarts the array firmware. +- `TEST_CORE_BURN`, `TEST_AEC_DISABLE_CONTROL` — test/diagnostic commands + with side effects beyond a normal parameter write. +- `USB_BIT_DEPTH` — changes the USB audio interface's bit depth. +- Every `SPECIAL_CMD_*` name — filter-coefficient and equalization + loading/offset commands (`SPECIAL_CMD_AEC_FILTER_COEFFS`, + `SPECIAL_CMD_PP_EQUALIZATION`, `SPECIAL_CMD_NLMODEL_START`, and their + siblings) that write into firmware-resident tables rather than an ordinary + runtime register. + +`microphone param set` on any of these names requires `--allow-persistent` +in addition to `--apply` — the two flags are deliberately separate so that +"I want to write this parameter" and "I understand this survives a +power-cycle or is destructive" are two distinct, explicit opt-ins. Every +other `rw` parameter needs only `--apply`. + +## Firmware overlays + +The table above is Pollen Robotics' `38fb:1001` map. Seeed's own USB firmware +(`2886:001a`, v2.1.0 at the time of writing) differs, and the CLI patches the +base table per USB vendor id in `xvf3800.FIRMWARE_OVERLAYS`: + +| Name | Reachy (`38fb`) | Seeed (`2886`) | +|------|-----------------|----------------| +| `DOA_VALUE` | 20/18, 2 × `uint32` | 20/18, 2 × `uint16` (degrees 0–359, speech flag) | +| `DOA_VALUE_RADIANS` | 20/19, 2 × radians | not implemented | +| `LED_RING_COLOR` | — | 20/19, 12 × `uint32` | +| `AIC3104_HP_LEVEL`, `AIC3104_LINEOUT_LEVEL` | — | 48/11, 48/12, `uint8` | +| `GPO_PIN_PWM_DUTY`, `GPO_PIN_FLASH_MASK`, `SPECIAL_CMD_*` (NL model, equalisation) | present | not implemented | + +`microphone param list --vendor 2886` prints the Seeed view. Source: +`python_control/xvf_host.py` in +[respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY](https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY). +Verified on hardware on 2026-09-06 (`docs/acceptance-microphone-domain.md`). diff --git a/microphone_cli/__init__.py b/microphone_cli/__init__.py index 2779e1c..de5ab23 100644 --- a/microphone_cli/__init__.py +++ b/microphone_cli/__init__.py @@ -1,4 +1,10 @@ -"""microphone-cli — agent-first CLI for an AgentCulture mesh agent.""" +"""microphone-cli — agent-first CLI for USB microphones and microphone arrays. + +Enumerate what is attached, inspect capture formats, read and set gain, reach the +XVF3800 array firmware (direction-of-arrival, echo-canceller state, raw +parameters), and serve or record audio. Installed as the ``microphone`` command; +the import package is ``microphone_cli`` and the distribution is ``microphone-cli``. +""" from __future__ import annotations diff --git a/microphone_cli/access.py b/microphone_cli/access.py new file mode 100644 index 0000000..5f2435b --- /dev/null +++ b/microphone_cli/access.py @@ -0,0 +1,387 @@ +"""Typed device-access errors: absent, forbidden, and busy device nodes. + +Cited from webcam-cli ``webcam_cli/access.py`` and adapted for this project's +device kinds: ``audio`` (ALSA capture nodes, e.g. ``/dev/snd/pcmC0D0c``) and +``usb`` (raw USB nodes for array firmware access, e.g. ``/dev/bus/usb/001/004``). + +Scope: this module owns exactly one question — *can this device node be +opened right now, and if not, why not and what fixes it*. It does not own +device enumeration or pairing and it does not own sample formats or gain. +Callers pass a plain path string; this module never imports +``microphone_cli`` sibling modules besides ``cli._errors``. + +Three failure states matter and must never be conflated: + +* **absent** — no node at that path at all (a bad/stale path, or hardware + unplugged). The agent named a device that is not there: a user error. +* **forbidden** — the node exists but ``open()`` fails with ``EACCES``/ + ``EPERM``. ALSA capture nodes under ``/dev/snd`` are typically gated by + ``audio``-group membership; raw USB nodes under ``/dev/bus/usb`` are + typically gated by a udev rule granting access by vendor/product id (no + group membership fixes a USB node without one). The two subsystems fail + for different reasons and need differently-worded fixes. +* **busy** — the node exists, is permitted, but is already held open by + another process (``EBUSY``). :func:`find_holder` makes a best-effort, + bounded, non-blocking scan of ``/proc/*/fd`` to name the holder; when it + cannot (permission-limited for another user's process, a race on a + vanishing pid, a dangling symlink), it degrades to ``holder=None`` rather + than raising or hanging. A busy report with an unknown holder is a normal, + expected outcome — not a bug. + +:func:`check_access` never raises for an inaccessible device; it is the +reporting path used by ``list``-shaped verbs, which must exit 0 and simply +show a bad device as bad. :func:`require_access` is the enforcing path used +by capture verbs: it raises the typed +:class:`~microphone_cli.cli._errors.CliError` for anything other than +``AccessState.OK``. +""" + +from __future__ import annotations + +import errno +import os +from dataclasses import dataclass +from enum import Enum + +from microphone_cli.cli._errors import EXIT_BUSY_ERROR, EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError + +__all__ = [ + "AccessState", + "Holder", + "AccessReport", + "busy_error", + "check_access", + "require_access", + "find_holder", + "access_error", +] + +_KINDS = ("audio", "usb") + + +class AccessState(str, Enum): + """The outcome of attempting to open a device node.""" + + OK = "ok" + ABSENT = "absent" + FORBIDDEN = "forbidden" + BUSY = "busy" + + +@dataclass(frozen=True) +class Holder: + """The process a busy device is currently held open by, best-effort.""" + + pid: int + command: str # process name, best-effort (from /proc//comm) + + +@dataclass(frozen=True) +class AccessReport: + """The result of :func:`check_access` — never raised, always returned.""" + + path: str + kind: str # "audio" | "usb" + state: AccessState + remediation: str # "" when state is OK + holder: Holder | None = None # populated only when state is BUSY and determinable + + +def _validate_kind(kind: str) -> None: + if kind not in _KINDS: + raise ValueError(f"kind must be one of {_KINDS!r}, got {kind!r}") + + +def _open_flags(kind: str) -> int: + # O_NONBLOCK so a slow/blocking-on-open node can never hang us here; this + # module only ever asks "can it be opened", it does not read or stream. + # O_CLOEXEC so a probe never leaks an fd into a child process. + extra = getattr(os, "O_CLOEXEC", 0) + if kind == "audio": + return os.O_RDONLY | os.O_NONBLOCK | extra + return os.O_RDWR | os.O_NONBLOCK | extra + + +def _absent_remediation(kind: str, path: str) -> str: + if kind == "audio": + return ( + f"{path} does not exist — check the microphone/card is plugged in and " + "listed by 'arecord -l' or /proc/asound/cards; ALSA card numbers renumber " + "on replug, so a stale path is the most common cause" + ) + return ( + f"{path} does not exist — check the microphone array is plugged in and " + "listed by 'lsusb' or under /dev/bus/usb/; USB bus/device numbers renumber " + "on replug, so a stale path is the most common cause" + ) + + +def _vanished_remediation(kind: str, path: str) -> str: + subsystem = "microphone/card" if kind == "audio" else "USB device" + listing = "'arecord -l'" if kind == "audio" else "'lsusb'" + return ( + f"{path} exists but has no device behind it — the {subsystem} was unplugged " + f"after the node was enumerated; re-enumerate against {listing} and use the " + "current path, since node numbers change on replug" + ) + + +def _forbidden_remediation(kind: str, path: str) -> str: + if kind == "audio": + return ( + f"permission denied opening {path} — ALSA capture devices are gated by " + "'audio'-group membership; add the invoking user to the 'audio' group and " + "re-login" + ) + return ( + f"permission denied opening {path} — raw USB device nodes are gated by udev " + "and are not fixed by group membership alone; add a udev rule granting access " + "by vendor/product id, e.g. a file under /etc/udev/rules.d/ containing:\n" + ' SUBSYSTEM=="usb", ATTR{idVendor}=="XXXX", ATTR{idProduct}=="YYYY", MODE="0666"\n' + "then 'udevadm control --reload-rules && udevadm trigger', with idVendor/" + "idProduct read from 'lsusb' for this device" + ) + + +def _busy_remediation(kind: str, holder: Holder | None) -> str: + subsystem = "ALSA capture device" if kind == "audio" else "USB device" + if holder is not None: + who = f"{holder.command} (pid {holder.pid})" + else: + who = ( + "another process that could not be identified " + "(no permission to read its /proc//fd)" + ) + return ( + f"{subsystem} is already open by {who}; only one exclusive capture client is " + "supported at a time — stop that process (or wait for it to release the device), " + "then retry" + ) + + +def check_access(path: str, kind: str) -> AccessReport: + """Report whether ``path`` (a ``kind`` device node) can be opened right now. + + Never raises for an inaccessible device — this is the reporting path used + by ``list``-shaped verbs, which must exit 0 while still showing a bad + device as bad. Only an invalid ``kind`` (a caller bug, not a device-access + outcome) raises ``ValueError``. + + Makes exactly one non-blocking open attempt (``O_NONBLOCK``) and closes + the descriptor immediately on success; no retries, no sleeps, no loop — + so this call is bounded by however long a single ``open(2)`` takes, which + for a character device is not a wait on remote I/O. + """ + _validate_kind(kind) + try: + fd = os.open(path, _open_flags(kind)) + except FileNotFoundError: + return AccessReport( + path=path, + kind=kind, + state=AccessState.ABSENT, + remediation=_absent_remediation(kind, path), + ) + except PermissionError: + return AccessReport( + path=path, + kind=kind, + state=AccessState.FORBIDDEN, + remediation=_forbidden_remediation(kind, path), + ) + except OSError as exc: + if exc.errno == errno.EBUSY: + holder = find_holder(path) + return AccessReport( + path=path, + kind=kind, + state=AccessState.BUSY, + remediation=_busy_remediation(kind, holder), + holder=holder, + ) + if exc.errno in (errno.ENODEV, errno.ENXIO): + # The node exists but nothing is behind it: the device was + # unplugged between enumeration and open. That is an absent + # device, not a permission problem — reporting FORBIDDEN here + # would hand back group/udev remediation for a device that is + # simply gone. + return AccessReport( + path=path, + kind=kind, + state=AccessState.ABSENT, + remediation=_vanished_remediation(kind, path), + ) + # Any other OSError is still an environment problem the caller cannot + # fix by blindly retrying — report it rather than silently claim OK. + return AccessReport( + path=path, + kind=kind, + state=AccessState.FORBIDDEN, + remediation=_forbidden_remediation(kind, path), + ) + else: + os.close(fd) + return AccessReport(path=path, kind=kind, state=AccessState.OK, remediation="") + + +def require_access(path: str, kind: str) -> None: + """Raise the typed :class:`CliError` unless ``path`` is openable now. + + Used by capture paths, which need to fail loudly rather than report. + """ + report = check_access(path, kind) + if report.state is not AccessState.OK: + raise access_error(report) + + +def busy_error(path: str, kind: str) -> CliError: + """Build the typed BUSY error for ``path``, looking the holder up first. + + For callers that learned the device is busy some way *other* than + ``open(2)``. + """ + _validate_kind(kind) + holder = find_holder(path) + return access_error( + AccessReport( + path=path, + kind=kind, + state=AccessState.BUSY, + remediation=_busy_remediation(kind, holder), + holder=holder, + ) + ) + + +def _list_proc_pids() -> list[str]: + return os.listdir("/proc") + + +def _list_fds(pid: str) -> list[str]: + return os.listdir(f"/proc/{pid}/fd") + + +def _readlink(fd_path: str) -> str: + return os.readlink(fd_path) + + +def _read_command(pid: str) -> str: + with open(f"/proc/{pid}/comm", encoding="utf-8") as handle: + return handle.read().strip() + + +def _holder_from_fd(entry: str, fd_name: str, target: str) -> Holder | None: + """Check one ``/proc//fd/`` entry against ``target``. + + Returns the :class:`Holder` if this descriptor is the one holding + ``target`` open, else ``None`` — including when the symlink cannot be + read at all (a race on a closing fd, a dangling entry). + """ + try: + link = _readlink(f"/proc/{entry}/fd/{fd_name}") + except OSError: + return None + if link != target: + return None + try: + command = _read_command(entry) + except OSError: + command = "unknown" + return Holder(pid=int(entry), command=command) + + +def _holder_in_pid(entry: str, target: str) -> Holder | None: + """Scan every open fd of one pid for ``target``, degrading to ``None``. + + A pid's ``fd`` directory can be unreadable (owned by another user, the + common case) or the pid can exit mid-scan — either way this is not ours + to read, so it is skipped rather than guessed at. + """ + try: + fd_names = _list_fds(entry) + except OSError: + return None + for fd_name in fd_names: + holder = _holder_from_fd(entry, fd_name, target) + if holder is not None: + return holder + return None + + +def find_holder(path: str) -> Holder | None: + """Best-effort, bounded scan of ``/proc/*/fd`` for a process with ``path`` open. + + Degrades gracefully to ``None`` — never raises — on anything short of a + clean match: ``/proc`` unavailable, a pid's ``fd`` directory unreadable + (owned by another user, the common case), a pid that exits mid-scan, or a + dangling symlink. A busy report with ``holder=None`` is correct and + expected, not a failure of this function. + + The scan is a single linear pass over ``/proc`` with no retries and no + sleeps, so it is bounded by the number of processes and open descriptors + on the host at the moment of the call — it cannot hang waiting on a + device, because it never opens one. + """ + try: + target = os.path.realpath(path) + except OSError: + return None + + try: + pids = _list_proc_pids() + except OSError: + return None + + for entry in pids: + if not entry.isdigit(): + continue + holder = _holder_in_pid(entry, target) + if holder is not None: + return holder + return None + + +def access_error(report: AccessReport) -> CliError: + """Map a non-OK :class:`AccessReport` to the typed :class:`CliError`. + + Exit-code policy: + + * ``ABSENT`` -> ``EXIT_USER_ERROR`` (1) — the agent named a device that isn't there. + * ``FORBIDDEN`` -> ``EXIT_ENV_ERROR`` (2) — the host/session is misconfigured; not + retryable without a config fix (audio group, udev rule). + * ``BUSY`` -> ``EXIT_BUSY_ERROR`` (3) — another process holds the device. + Deliberately distinct from ``FORBIDDEN``: BUSY is retryable (wait for the + holder to release it, or stop it) while FORBIDDEN is not, and an agent + cannot tell those apart from a shared exit code without string-matching + the message — exactly what a typed code exists to prevent. + + Calling this with an ``OK`` report is a programming error, not a device + outcome, and raises ``ValueError``. + """ + if report.state is AccessState.OK: + raise ValueError("access_error() called with an OK report; there is nothing to map") + + if report.state is AccessState.ABSENT: + return CliError( + code=EXIT_USER_ERROR, + message=f"no {report.kind} device at {report.path}", + remediation=report.remediation, + ) + + if report.state is AccessState.FORBIDDEN: + return CliError( + code=EXIT_ENV_ERROR, + message=f"permission denied opening {report.kind} device {report.path}", + remediation=report.remediation, + ) + + # BUSY — retryable, so it gets its own code rather than sharing + # EXIT_ENV_ERROR with FORBIDDEN (which is not retryable). + holder_desc = "" + if report.holder is not None: + holder_desc = f" (held by {report.holder.command}, pid {report.holder.pid})" + return CliError( + code=EXIT_BUSY_ERROR, + message=f"{report.kind} device {report.path} is busy{holder_desc}", + remediation=report.remediation, + ) diff --git a/microphone_cli/activation.py b/microphone_cli/activation.py new file mode 100644 index 0000000..7882bbb --- /dev/null +++ b/microphone_cli/activation.py @@ -0,0 +1,266 @@ +"""Activation log — one JSON line per ``--apply`` action. + +Cited from ``webcam-cli/webcam_cli/activation.py:26-129`` (module docstring, +``log_path()``, ``Activation``, ``record_activation()``, ``activation_scope()``) +and adapted to microphone-cli's field set: every activation records a verb, a +device (its stable id), the parameters that were applied, and when the action +started and ended — nothing about the audio itself. + +Resolution order for the log path mirrors the source module: + +1. ``$MICROPHONE_ACTIVATION_LOG`` — an explicit override, used verbatim. +2. ``$XDG_STATE_HOME/microphone-cli/activation.jsonl`` — XDG state dir. +3. ``~/.local/state/microphone-cli/activation.jsonl`` — XDG fallback, used + when ``$XDG_STATE_HOME`` is unset. + +Zero runtime dependencies: standard library only. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from microphone_cli.cli._errors import EXIT_ENV_ERROR, CliError + +# Env var that overrides the default activation-log location. +ENV_LOG_PATH = "MICROPHONE_ACTIVATION_LOG" + +_ENV_XDG_STATE_HOME = "XDG_STATE_HOME" +_STATE_SUBPATH = Path("microphone-cli") / "activation.jsonl" + + +def log_path() -> Path: + """Return the documented default activation-log location. + + Resolution order: + + 1. ``$MICROPHONE_ACTIVATION_LOG`` — an explicit override, used verbatim. + 2. ``$XDG_STATE_HOME/microphone-cli/activation.jsonl`` — XDG state dir. + 3. ``~/.local/state/microphone-cli/activation.jsonl`` — XDG fallback, + used when ``$XDG_STATE_HOME`` is unset. + + This function never touches the filesystem — it only computes a path. + Callers that write (:func:`record_activation`) create parent + directories on demand. + """ + override = os.environ.get(ENV_LOG_PATH) + if override: + return Path(override) + + xdg_state_home = os.environ.get(_ENV_XDG_STATE_HOME) + base = Path(xdg_state_home) if xdg_state_home else Path.home() / ".local" / "state" + return base / _STATE_SUBPATH + + +@dataclass(frozen=True) +class Activation: + """One ``--apply`` action against a device. + + ``ended_at`` is ``None`` while the action is still running; once it is + populated the activation is complete — including when it ended by + crashing (see :func:`activation_scope`), so a failed action never + simply vanishes from the record. + """ + + verb: str + device: str + params: dict[str, object] + started_at: str + ended_at: str | None + + def to_dict(self) -> dict[str, object]: + return { + "verb": self.verb, + "device": self.device, + "params": self.params, + "started_at": self.started_at, + "ended_at": self.ended_at, + } + + +def _write_all(fd: int, payload: bytes) -> None: + """Write every byte of ``payload`` to ``fd``, looping over short writes. + + ``os.write`` is not guaranteed to consume the whole buffer in a single + call — under ``O_APPEND`` the kernel still serialises writers to the same + regular file, but a *single* write it accepts may still be partial (e.g. + interrupted by a signal, or a pipe/special file with a small buffer). + Looping here keeps single-open, single-line-atomic-under-``PIPE_BUF`` + semantics while guaranteeing the whole line lands. A ``0`` return (or a + raised ``OSError``) is treated as a hard failure and propagated — it is + never swallowed, since a silently-lost line would break the guarantee + that every ``--apply`` action is logged. + """ + view = memoryview(payload) + total = len(view) + written = 0 + while written < total: + n = os.write(fd, view[written:]) + if n <= 0: + raise OSError(f"os.write() returned {n} writing to fd {fd} (expected > 0)") + written += n + + +def record_activation(activation: Activation, *, path: Path | None = None) -> None: + """Append exactly one JSON line for ``activation`` to the activation log. + + Writes via a single ``os.open(..., O_APPEND)`` followed by one or more + ``os.write()`` calls (see :func:`_write_all`) until the complete + ``line + "\\n"`` has been written. Under ``O_APPEND`` the kernel + serialises writers to the same regular file, so a line under + ``PIPE_BUF`` still cannot interleave with another writer's line even + though it may take more than one syscall to land. + + Any failure to write — permission denied, missing parent, disk full, a + parent path component that is not a directory, a short write that never + completes, ... — propagates to the caller as the underlying ``OSError``. + It is never swallowed here: a silently-lost line would break the + guarantee that every ``--apply`` action is logged. + """ + target = path if path is not None else log_path() + target.parent.mkdir(parents=True, exist_ok=True) + + line = json.dumps(activation.to_dict()) + payload = (line + "\n").encode("utf-8") + + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + try: + _write_all(fd, payload) + os.fsync(fd) + finally: + os.close(fd) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _ensure_log_writable(target: Path) -> None: + """Fail fast if ``target`` cannot be appended to, before anything else runs. + + Creates the parent directory and opens (then immediately closes) the log + file in append mode. This is the same open call :func:`record_activation` + will make later; doing it up front means a permission problem, a full + disk, or a bad parent path component is surfaced as a :class:`CliError` + *before* the caller's hardware-touching action runs, instead of after — + so a command never applies a change it then fails to report. + """ + try: + target.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + os.close(fd) + except OSError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"activation log at {target} is not writable: {exc}", + remediation=( + f"Fix permissions or free space for {target}, or point " + f"${ENV_LOG_PATH} at a writable location, then retry." + ), + ) from exc + + +def _finished(activation: Activation, params: dict[str, object]) -> Activation: + """The closed-out twin of ``activation``: same identity, ``ended_at`` stamped now. + + Built explicitly rather than via ``dataclasses.replace`` so static analysis + sees an :class:`Activation` flowing into the audit write (SonarCloud S5655). + """ + return Activation( + verb=activation.verb, + device=activation.device, + params=dict(params), + started_at=activation.started_at, + ended_at=_now_iso(), + ) + + +def _record_or_report_applied( + finished: Activation, *, verb: str, device: str, target: Path, path: Path | None +) -> None: + """Write ``finished``; if that write itself fails, say so without hiding the action. + + ``_ensure_log_writable`` already ran before the caller's action, so this + should only fail for a problem that appeared *during* the action (disk + filled up, log file/directory removed underneath us, ...). In that case + the caller's action already happened — raising the raw ``OSError`` (or + silently discarding it) would read like the action itself failed and + invite an unsafe retry of something non-idempotent. Instead this raises a + :class:`CliError` whose message says plainly that the action was applied + and only the audit-log write failed. + """ + try: + record_activation(finished, path=path) + except OSError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=( + f"{verb} on {device} was applied, but writing the activation log to " + f"{target} failed: {exc}. Do not retry the action — it already ran." + ), + remediation=( + f"Fix permissions or free space for {target}, or point " + f"${ENV_LOG_PATH} at a writable location, then record this activation " + "manually if an audit trail is required." + ), + ) from exc + + +@contextmanager +def activation_scope( + verb: str, + device: str, + params: dict[str, object] | None = None, + *, + path: Path | None = None, +) -> Iterator[Activation]: + """Context manager around one ``--apply`` action; writes exactly one line on exit. + + Audit availability is established *before* the caller's action runs: + entering this context manager resolves the log path, creates its parent + directories, and opens (then closes) the log file once, so a permission + or disk-space problem raises :class:`CliError` up front instead of after + the (non-idempotent) hardware action has already happened. + + The activation is recorded once, on exit — never on entry, so a still + running action never appears in the log — and exactly once whether the + body finishes cleanly or raises. On a raise, the exception's type and + message are folded into ``params["error"]`` (without overwriting an + "error" key the caller already set) before the single line is written, + and the original exception is re-raised unchanged. This is deliberate: + an action that dies mid-flight must still leave a completed record with + ``ended_at`` set, not vanish silently. + + If the final write fails anyway (e.g. the disk filled up *during* the + action), the caller's action has already run: this raises + :class:`CliError` saying so explicitly rather than a bare ``OSError``, + so nothing downstream mistakes it for "the action failed" and retries a + change that already took effect. + """ + target: Path = path if path is not None else log_path() + _ensure_log_writable(target) + + activation = Activation( + verb=verb, + device=device, + params=dict(params) if params is not None else {}, + started_at=_now_iso(), + ended_at=None, + ) + try: + yield activation + except BaseException as exc: + crash_params = dict(activation.params) + crash_params.setdefault("error", f"{type(exc).__name__}: {exc}") + finished = _finished(activation, crash_params) + _record_or_report_applied(finished, verb=verb, device=device, target=target, path=path) + raise + else: + finished = _finished(activation, activation.params) + _record_or_report_applied(finished, verb=verb, device=device, target=target, path=path) diff --git a/microphone_cli/cli/__init__.py b/microphone_cli/cli/__init__.py index 59428fb..7b8f82a 100644 --- a/microphone_cli/cli/__init__.py +++ b/microphone_cli/cli/__init__.py @@ -1,9 +1,20 @@ -"""Unified CLI entry point for microphone-cli. - -The agent-first global verbs (``whoami``, ``learn``, ``explain``, ``overview``, -``doctor``) are registered here under :mod:`microphone_cli.cli._commands`, -alongside the ``cli`` noun group. Future noun groups register via their own -``register()`` functions following the same pattern. +"""Unified CLI entry point, installed as the ``microphone`` command. + +Two families of verbs register here under :mod:`microphone_cli.cli._commands`: +the microphone surface (``list``, ``inspect``, the ``gain``, ``array``, +``param`` and ``stream`` noun groups, ``record``) and the agent-first +introspection verbs (``whoami``, ``learn``, ``explain``, ``overview``, +``doctor``) alongside the ``cli`` noun group. Further noun groups register via +their own ``register()`` functions following the same pattern. + +Three names, one typable +------------------------ +The console command is ``microphone`` (``[project.scripts]``), the import +package is ``microphone_cli``, and the PyPI distribution is ``microphone-cli``. +``prog`` is therefore ``microphone``: ``--help``, every argparse hint, and every +doc string an agent reads must name something it can actually run. +``microphone-cli`` stays correct when referring to the project, the +distribution, or the mesh nick — it is only wrong presented as a command. Error propagation contract -------------------------- @@ -62,16 +73,29 @@ def _argv_has_json(argv: list[str] | None) -> bool: def _build_parser() -> argparse.ArgumentParser: + from microphone_cli.cli._commands import array as _array_group from microphone_cli.cli._commands import cli as _cli_group from microphone_cli.cli._commands import doctor as _doctor_cmd from microphone_cli.cli._commands import explain as _explain_cmd + from microphone_cli.cli._commands import gain as _gain_group + from microphone_cli.cli._commands import inspect as _inspect_cmd from microphone_cli.cli._commands import learn as _learn_cmd + from microphone_cli.cli._commands import list_devices as _list_cmd from microphone_cli.cli._commands import overview as _overview_cmd + from microphone_cli.cli._commands import param as _param_group + from microphone_cli.cli._commands import record as _record_cmd + from microphone_cli.cli._commands import stream as _stream_group from microphone_cli.cli._commands import whoami as _whoami_cmd parser = _CliArgumentParser( - prog="microphone-cli", - description="microphone-cli — a clonable template for AgentCulture mesh agents.", + prog="microphone", + description=( + "microphone — own the USB microphones and microphone arrays attached to " + "this host: enumerate them, inspect their formats, read and set gain, " + "read direction-of-arrival and firmware parameters off an array, and " + "serve or record audio. Dry-run by default; no verb opens a device or " + "writes to firmware without --probe or --apply." + ), ) parser.add_argument( "--version", @@ -82,15 +106,22 @@ def _build_parser() -> argparse.ArgumentParser: # through _CliArgumentParser too. sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + # The microphone surface first: it is what this agent exists to do, so it is + # what `microphone --help` shows an agent before the introspection verbs. + _list_cmd.register(sub) + _inspect_cmd.register(sub) + _gain_group.register(sub) + _array_group.register(sub) + _param_group.register(sub) + _stream_group.register(sub) + _record_cmd.register(sub) + _whoami_cmd.register(sub) _learn_cmd.register(sub) _explain_cmd.register(sub) _overview_cmd.register(sub) _doctor_cmd.register(sub) _cli_group.register(sub) - # Register your own noun groups here: - # from microphone_cli.cli._commands import my_noun as _my_noun_group - # _my_noun_group.register(sub) return parser @@ -122,8 +153,22 @@ def _dispatch(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: # Pre-parse peek so argparse-level errors honour --json. _CliArgumentParser._json_hint = _argv_has_json(argv) - parser = _build_parser() - args = parser.parse_args(argv) + try: + parser = _build_parser() + args = parser.parse_args(argv) + except (SystemExit, KeyboardInterrupt): + # SystemExit: argparse's own --help/--version and _CliArgumentParser.error() + # already emitted the right thing (or nothing, for --help). Let both pass + # through unchanged. + raise + except Exception as err: # noqa: BLE001 - last-resort; wrap and route cleanly + wrapped = CliError( + code=EXIT_USER_ERROR, + message=f"unexpected: {err.__class__.__name__}: {err}", + remediation=f"file a bug at {_ISSUES_URL}", + ) + emit_error(wrapped, json_mode=_CliArgumentParser._json_hint) + return wrapped.code if args.command is None: parser.print_help() diff --git a/microphone_cli/cli/_commands/__init__.py b/microphone_cli/cli/_commands/__init__.py index 23b774b..c14d56e 100644 --- a/microphone_cli/cli/_commands/__init__.py +++ b/microphone_cli/cli/_commands/__init__.py @@ -1 +1,5 @@ """CLI command modules. Each exposes a ``register(sub)`` function.""" + +#: Help text for the ``--json`` flag every parser in this package adds. One +#: constant so the wording stays identical across every verb and noun. +JSON_FLAG_HELP = "Emit structured JSON." diff --git a/microphone_cli/cli/_commands/array.py b/microphone_cli/cli/_commands/array.py new file mode 100644 index 0000000..8e5a81e --- /dev/null +++ b/microphone_cli/cli/_commands/array.py @@ -0,0 +1,563 @@ +"""``microphone array`` — direction-of-arrival and AEC state for an XVF3800 array. + +Two verb families hang off this noun: + +* ``array doa `` — read ``DOA_VALUE_RADIANS`` from the firmware, once + or continuously (``--watch``, JSON Lines). The azimuth is reported **exactly + as the firmware reports it**: raw radians in the array's own frame. No + coordinate transform, no degree conversion, no re-basing onto a robot frame + happens here — a consumer that needs another frame owns that conversion and + can only do it correctly if it starts from the untouched firmware value. +* ``array aec get|set `` — the echo-canceller's observable state + (converged, bypass, high-pass filter, echo suppression, mic count, array + geometry) and the three switches that are safe to flip. ``set`` is a dry run + unless ``--apply`` is passed; ``--apply`` writes inside an + :func:`~microphone_cli.activation.activation_scope`, so every hardware-touching + run leaves exactly one line in the activation log. + +The noun group follows the pattern cited from ``webcam-cli``'s +``webcam_cli/cli/_commands/stream.py`` (lines 1603-1672) and the local +:mod:`microphone_cli.cli._commands.cli`: a bare ``array`` prints the noun's own +overview, and **every** nested ``add_subparsers`` call passes +``parser_class=type(p)`` so parse errors keep routing through the structured +error contract instead of argparse's default ``exit(2)``. + +Hardware access is funnelled through the module-level :func:`_open_array` and +:data:`_sleep` seams; tests replace both, so no test opens a device node. +""" + +from __future__ import annotations + +import argparse +import errno +import math +import time +from datetime import datetime, timezone +from typing import Any, Callable + +from microphone_cli import devices, usbctl +from microphone_cli.activation import activation_scope +from microphone_cli.cli._commands.overview import emit_overview +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from microphone_cli.cli._output import emit_result +from microphone_cli.xvf3800 import SEEED_VENDOR, Xvf3800 + +_JSON_HELP = "Emit structured JSON." + +#: The one DoA parameter this verb reads. Raw firmware radians, never converted. +DOA_PARAM = "DOA_VALUE_RADIANS" +#: Seeed's USB firmware has no radians command; its DOA_VALUE is (degrees, speech). +DOA_PARAM_DEGREES = "DOA_VALUE" + +#: Speech flag threshold: the firmware reports a float alongside the azimuth. +_SPEECH_THRESHOLD = 0.5 + +#: ``array aec get`` reads these; the payload key is the dict key. +_AEC_READS: tuple[tuple[str, str, str], ...] = ( + ("converged", "AEC_AECCONVERGED", "bool"), + ("bypass", "SHF_BYPASS", "bool"), + ("hpf", "AEC_HPFONOFF", "bool"), + ("echo", "PP_ECHOONOFF", "bool"), + ("num_mics", "AEC_NUM_MICS", "int"), + ("geometry_type", "AEC_MIC_ARRAY_TYPE", "int"), + ("geometry", "AEC_MIC_ARRAY_GEO", "floats"), + ("rt60", "AEC_RT60", "float"), +) + +#: ``array aec set`` switches: flag name -> (parameter, value type). +_AEC_WRITES: tuple[tuple[str, str], ...] = ( + ("echo", "PP_ECHOONOFF"), + ("bypass", "SHF_BYPASS"), + ("hpf", "AEC_HPFONOFF"), +) + +# Testing seams. +_sleep: Callable[[float], None] = time.sleep + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# device plumbing +# --------------------------------------------------------------------------- + + +def _resolve_array(selector: str, root: str) -> devices.MicrophoneDevice: + """Resolve ``selector`` and refuse anything that is not an XVF3800 array.""" + device = devices.resolve(selector, root) + if not device.is_array: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{device.stable_id} is not an XVF3800 microphone array", + remediation=( + "Only XVF3800 arrays expose DoA and AEC parameters. Run " + "`microphone list --json` and pick a device whose `is_array` is true." + ), + ) + return device + + +def _open_array( + device: devices.MicrophoneDevice, root: str = "/", timeout_ms: int | None = None +) -> Xvf3800: + """Open the USB node behind ``device`` and return an :class:`Xvf3800` for it.""" + matches = usbctl.find_devices( + root=root, + vendor=device.usb_ids.vendor, + product=device.usb_ids.product, + serial=device.serial, + ) + if not matches: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"no USB node found for {device.stable_id}", + remediation=( + "The ALSA card exists but its USB device node does not. Replug the array and " + "retry; `microphone list --json` shows what is attached right now." + ), + ) + fd = usbctl.open_device( + matches[0]["node"], vendor=matches[0].get("vendor"), product=matches[0].get("product") + ) + vendor = matches[0].get("vendor") or device.usb_ids.vendor + return Xvf3800(fd, timeout_ms=timeout_ms, vendor=vendor) + + +def _transport_error(exc: OSError, device: devices.MicrophoneDevice) -> CliError: + """Translate a raw transport ``OSError`` into the structured contract.""" + if exc.errno in (errno.ENODEV, errno.ENOENT, errno.ESHUTDOWN): + return CliError( + code=EXIT_ENV_ERROR, + message=f"device disappeared: {device.stable_id} ({exc})", + remediation=( + "The array was unplugged or reset mid-transfer. Replug it and retry; " + "`microphone list --json` shows what is attached right now." + ), + ) + return CliError( + code=EXIT_ENV_ERROR, + message=f"USB transfer failed for {device.stable_id}: {exc}", + remediation="Check that no other process holds the array, then retry.", + ) + + +# --------------------------------------------------------------------------- +# doa +# --------------------------------------------------------------------------- + + +def _read_doa(chip: Xvf3800, device: devices.MicrophoneDevice) -> dict[str, object]: + """One DoA sample. + + The azimuth is the firmware's own value: radians from ``DOA_VALUE_RADIANS`` + on Reachy firmware, integer degrees from ``DOA_VALUE`` on Seeed firmware. + Both are reported (``azimuth_rad`` / ``azimuth_deg``) as a unit conversion + only; no coordinate transform is applied. ``source`` names the command. + """ + if chip.vendor == SEEED_VENDOR: + values = chip.read(DOA_PARAM_DEGREES) + degrees = float(values[0]) + radians = math.radians(degrees) + speech = float(values[1]) if len(values) > 1 else 0.0 + source = DOA_PARAM_DEGREES + else: + values = chip.read(DOA_PARAM) + radians = float(values[0]) + degrees = math.degrees(radians) + speech = float(values[1]) if len(values) > 1 else 0.0 + source = DOA_PARAM + return { + "device": device.stable_id, + "azimuth_rad": radians, + "azimuth_deg": degrees, + "speech": speech >= _SPEECH_THRESHOLD, + "source": source, + "ts": _now(), + } + + +def _render_doa(payload: dict[str, object]) -> str: + return ( + f"device: {payload['device']} azimuth_rad: {payload['azimuth_rad']:.6f} " + f"azimuth_deg: {payload['azimuth_deg']:.1f} " + f"speech: {str(payload['speech']).lower()} source: {payload['source']}" + ) + + +def cmd_array_doa(args: argparse.Namespace) -> int: + json_mode = bool(getattr(args, "json", False)) + device = _resolve_array(args.device, args.root) + + if args.watch and args.interval <= 0: + raise CliError( + code=EXIT_USER_ERROR, + message=f"--interval must be greater than zero, got {args.interval}", + remediation="Pass a positive number of seconds, e.g. `--interval 0.5`.", + ) + if args.count is not None and args.count <= 0: + raise CliError( + code=EXIT_USER_ERROR, + message=f"--count must be greater than zero, got {args.count}", + remediation="Pass a positive poll count, e.g. `--count 10`.", + ) + + chip = _open_array(device, args.root) + try: + if not args.watch: + payload = _read_doa(chip, device) + emit_result( + payload if json_mode else _render_doa(payload), + json_mode=json_mode, + ) + return 0 + return _watch_doa(chip, device, interval=args.interval, count=args.count) + finally: + chip.close() + + +def _watch_doa( + chip: Xvf3800, + device: devices.MicrophoneDevice, + *, + interval: float, + count: int | None, +) -> int: + """Poll DoA, one JSON object per line, until ``count``, SIGINT, or a lost device. + + JSON Lines is the wire format whether or not ``--json`` was passed: a + stream of samples has no useful non-JSON rendering, and a consumer reading + the stream should not have to switch parsers on a flag. + """ + polls = 0 + try: + while count is None or polls < count: + try: + payload = _read_doa(chip, device) + except OSError as exc: + raise _transport_error(exc, device) from exc + emit_result(payload, json_mode=True) + polls += 1 + if count is not None and polls >= count: + break + _sleep(interval) + except KeyboardInterrupt: + # Ctrl-C is how a watch is meant to end: already-printed samples stand + # and the run is a success. + return 0 + return 0 + + +# --------------------------------------------------------------------------- +# aec +# --------------------------------------------------------------------------- + + +def _coerce(kind: str, raw: Any) -> object: + if kind == "bool": + return bool(int(raw[0])) + if kind == "int": + return int(raw[0]) + if kind == "float": + return float(raw[0]) + return [float(value) for value in raw] + + +def _read_aec(chip: Xvf3800, device: devices.MicrophoneDevice) -> dict[str, object]: + payload: dict[str, object] = {"device": device.stable_id} + for key, name, kind in _AEC_READS: + payload[key] = _coerce(kind, chip.read(name)) + payload["ts"] = _now() + return payload + + +def _render_aec(payload: dict[str, object]) -> str: + lines = [] + for key, value in payload.items(): + if isinstance(value, bool): + rendered = str(value).lower() + elif isinstance(value, list): + rendered = ", ".join(f"{item:.4f}" for item in value) + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + return "\n".join(lines) + + +def cmd_array_aec_get(args: argparse.Namespace) -> int: + json_mode = bool(getattr(args, "json", False)) + device = _resolve_array(args.device, args.root) + chip = _open_array(device, args.root) + try: + payload = _read_aec(chip, device) + except OSError as exc: + raise _transport_error(exc, device) from exc + finally: + chip.close() + emit_result(payload if json_mode else _render_aec(payload), json_mode=json_mode) + return 0 + + +def _plan(args: argparse.Namespace) -> list[dict[str, object]]: + planned: list[dict[str, object]] = [] + for flag, param in _AEC_WRITES: + setting = getattr(args, flag, None) + if setting is None: + continue + planned.append( + { + "setting": flag, + "value": setting, + "param": param, + "values": [1 if setting == "on" else 0], + } + ) + return planned + + +def cmd_array_aec_set(args: argparse.Namespace) -> int: + json_mode = bool(getattr(args, "json", False)) + planned = _plan(args) + if not planned: + raise CliError( + code=EXIT_USER_ERROR, + message="nothing to set: pass at least one of --echo, --bypass, --hpf", + remediation=( + "For example `microphone array aec set --echo off --apply`. " + "Run `microphone array aec get ` to see the current state." + ), + ) + + device = _resolve_array(args.device, args.root) + + if not args.apply: + # Dry run: the device is never opened, so nothing can touch hardware. + payload = { + "device": device.stable_id, + "mode": "dry-run", + "applied": False, + "hardware_touched": False, + "planned": planned, + } + emit_result( + payload if json_mode else _render_dry_run(device.stable_id, planned), + json_mode=json_mode, + ) + return 0 + + params = {str(item["setting"]): item["value"] for item in planned} + chip = _open_array(device, args.root) + try: + with activation_scope("array aec set", device.stable_id, params): + try: + for item in planned: + chip.write(str(item["param"]), list(item["values"])) + state = _read_aec(chip, device) + except OSError as exc: + raise _transport_error(exc, device) from exc + finally: + chip.close() + + payload = { + "device": device.stable_id, + "mode": "apply", + "applied": True, + "hardware_touched": True, + "planned": planned, + "state": state, + } + emit_result(payload if json_mode else _render_apply(payload), json_mode=json_mode) + return 0 + + +def _render_dry_run(stable_id: str, planned: list[dict[str, object]]) -> str: + lines = [f"device: {stable_id}", "mode: dry-run (nothing was written; pass --apply)"] + for item in planned: + lines.append( + f"would set {item['param']} = {item['values']} ({item['setting']}={item['value']})" + ) + return "\n".join(lines) + + +def _render_apply(payload: dict[str, object]) -> str: + planned = payload["planned"] + assert isinstance(planned, list) + lines = [f"device: {payload['device']}", "mode: apply"] + for item in planned: + lines.append( + f"wrote {item['param']} = {item['values']} ({item['setting']}={item['value']})" + ) + state = payload["state"] + assert isinstance(state, dict) + lines.append(_render_aec(state)) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# overviews +# --------------------------------------------------------------------------- + + +def _array_sections() -> list[dict[str, object]]: + return [ + { + "title": "Verbs", + "items": [ + "array overview — this description", + "array doa — read direction-of-arrival once", + "array doa --watch — stream DoA as JSON Lines " + "(--interval, --count; Ctrl-C exits 0)", + "array aec get — echo-canceller state", + "array aec set — flip --echo/--bypass/--hpf (dry run without --apply)", + ], + }, + { + "title": "Frame", + "items": [ + f"azimuth comes from the firmware parameter {DOA_PARAM}", + "raw firmware radians — no coordinate transform is applied here", + "`speech` is the firmware's second DoA value, thresholded at 0.5", + ], + }, + { + "title": "Hardware", + "items": [ + "reads open the USB node; only `aec set --apply` writes", + "every --apply run appends one line to the activation log " + "($MICROPHONE_ACTIVATION_LOG)", + "exit codes: 0 success, 1 user error, 2 device/environment error", + ], + }, + ] + + +def _aec_sections() -> list[dict[str, object]]: + return [ + { + "title": "get", + "items": [f"{key} <- {name}" for key, name, _kind in _AEC_READS], + }, + { + "title": "set", + "items": [f"--{flag} on|off -> {name}" for flag, name in _AEC_WRITES] + + ["--apply is required to touch hardware; without it the plan is printed"], + }, + ] + + +def cmd_array_overview(args: argparse.Namespace) -> int: + emit_overview( + "microphone array", + _array_sections(), + json_mode=bool(getattr(args, "json", False)), + ) + return 0 + + +def cmd_array_aec_overview(args: argparse.Namespace) -> int: + emit_overview( + "microphone array aec", + _aec_sections(), + json_mode=bool(getattr(args, "json", False)), + ) + return 0 + + +# --------------------------------------------------------------------------- +# registration +# --------------------------------------------------------------------------- + + +def _add_common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("device", help="Device selector (stable id, card id, serial, substring).") + parser.add_argument( + "--root", + default="/", + help="Filesystem root to read device state from (tests point this at a fixture tree).", + ) + parser.add_argument("--json", action="store_true", help=_JSON_HELP) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "array", + help="Microphone-array firmware: direction-of-arrival and echo-canceller state.", + description="XVF3800 array verbs. Reads are safe; `aec set` needs --apply.", + ) + p.add_argument("--json", action="store_true", help=_JSON_HELP) + p.set_defaults(func=cmd_array_overview, json=False) + + # parser_class must propagate, or this noun's parse errors bypass the + # structured error contract and exit 2 instead of 1. + noun_sub = p.add_subparsers(dest="array_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the array verb group.") + ov.add_argument("--json", action="store_true", help=_JSON_HELP) + ov.set_defaults(func=cmd_array_overview) + + doa = noun_sub.add_parser( + "doa", + help="Read direction-of-arrival (raw firmware radians).", + description=( + "Read DOA_VALUE_RADIANS from the array firmware. The azimuth is reported in the " + "firmware's own frame, in radians, with no coordinate transform applied." + ), + ) + _add_common(doa) + doa.add_argument( + "--watch", + action="store_true", + help="Poll continuously, printing one JSON object per line until Ctrl-C or --count.", + ) + doa.add_argument( + "--interval", + type=float, + default=0.5, + metavar="SECONDS", + help="Seconds between polls while watching (default 0.5).", + ) + doa.add_argument( + "--count", + type=int, + default=None, + metavar="N", + help="Stop after N samples while watching (default: run until interrupted).", + ) + doa.set_defaults(func=cmd_array_doa) + + aec = noun_sub.add_parser( + "aec", + help="Echo-canceller state (see 'microphone array aec overview').", + ) + aec.add_argument("--json", action="store_true", help=_JSON_HELP) + aec.set_defaults(func=cmd_array_aec_overview, json=False) + # Nested one level deeper: propagate parser_class again. + aec_sub = aec.add_subparsers(dest="aec_command", parser_class=type(p)) + + aec_ov = aec_sub.add_parser("overview", help="Describe the aec verb group.") + aec_ov.add_argument("--json", action="store_true", help=_JSON_HELP) + aec_ov.set_defaults(func=cmd_array_aec_overview) + + aec_get = aec_sub.add_parser("get", help="Read echo-canceller state (read-only).") + _add_common(aec_get) + aec_get.set_defaults(func=cmd_array_aec_get) + + aec_set = aec_sub.add_parser( + "set", + help="Flip echo-canceller switches (dry run unless --apply).", + ) + _add_common(aec_set) + for flag, param in _AEC_WRITES: + aec_set.add_argument( + f"--{flag}", + choices=("on", "off"), + default=None, + help=f"Set {param} on or off.", + ) + aec_set.add_argument( + "--apply", + action="store_true", + help="Actually write to the device (and append one activation-log line).", + ) + aec_set.set_defaults(func=cmd_array_aec_set) diff --git a/microphone_cli/cli/_commands/cli.py b/microphone_cli/cli/_commands/cli.py index 74fa190..ca486d7 100644 --- a/microphone_cli/cli/_commands/cli.py +++ b/microphone_cli/cli/_commands/cli.py @@ -1,4 +1,4 @@ -"""``microphone-cli cli`` — noun grouping CLI-surface introspection. +"""``microphone cli`` — noun grouping CLI-surface introspection. Exists to satisfy the agent-first rubric's ``overview_cli_noun_exists`` check: any noun with action-verbs must also expose ``overview``. There are no @@ -15,7 +15,7 @@ def cmd_cli_overview(args: argparse.Namespace) -> int: emit_overview( - "microphone-cli cli", + "microphone cli", cli_sections(), json_mode=bool(getattr(args, "json", False)), ) @@ -23,14 +23,14 @@ def cmd_cli_overview(args: argparse.Namespace) -> int: def _no_verb(args: argparse.Namespace) -> int: - # `microphone-cli cli` with no sub-verb prints the noun's overview. + # `microphone cli` with no sub-verb prints the noun's overview. return cmd_cli_overview(args) def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser( "cli", - help="CLI-surface introspection (see 'microphone-cli cli overview').", + help="CLI-surface introspection (see 'microphone cli overview').", ) p.add_argument("--json", action="store_true", help="Emit structured JSON.") p.set_defaults(func=_no_verb, json=False) @@ -38,6 +38,6 @@ def register(sub: argparse._SubParsersAction) -> None: # parser_class); propagate it so `cli overview` parse errors route through # the structured error contract instead of argparse's default stderr/exit 2. noun_sub = p.add_subparsers(dest="cli_command", parser_class=type(p)) - ov = noun_sub.add_parser("overview", help="Describe the microphone-cli CLI surface.") + ov = noun_sub.add_parser("overview", help="Describe this CLI surface.") ov.add_argument("--json", action="store_true", help="Emit structured JSON.") ov.set_defaults(func=cmd_cli_overview) diff --git a/microphone_cli/cli/_commands/doctor.py b/microphone_cli/cli/_commands/doctor.py index c05146b..6fe44c9 100644 --- a/microphone_cli/cli/_commands/doctor.py +++ b/microphone_cli/cli/_commands/doctor.py @@ -1,6 +1,9 @@ -"""``microphone-cli doctor`` — check the agent-identity invariants. +"""``microphone doctor`` — check the agent-identity invariants. -Mirrors the two invariants ``steward doctor`` verifies for a mesh agent: +An *identity* check, not a capture-readiness check: it says nothing about whether +a microphone is attached or openable (use ``microphone list`` for that). + +Mirrors the two invariants ``steward doctor`` verifies: * **prompt-file-present** — the repo declares an agent in ``culture.yaml`` and has the matching prompt file on disk; @@ -105,7 +108,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: emit_result(report, json_mode=True) else: status = "healthy" if report["healthy"] else "unhealthy" - lines = [f"microphone-cli doctor: {status}", ""] + lines = [f"microphone doctor: {status}", ""] for check in report["checks"]: mark = "ok" if check["passed"] else "FAIL" lines.append(f"[{mark}] {check['id']}: {check['message']}") diff --git a/microphone_cli/cli/_commands/explain.py b/microphone_cli/cli/_commands/explain.py index b4557e9..c13eeba 100644 --- a/microphone_cli/cli/_commands/explain.py +++ b/microphone_cli/cli/_commands/explain.py @@ -1,4 +1,4 @@ -"""``microphone-cli explain ...`` — global markdown catalog lookup (stable-contract). +"""``microphone explain ...`` — global markdown catalog lookup (stable-contract). ``explain`` is global (not nested under a noun). It takes zero or more path tokens and resolves them via the catalog in :mod:`microphone_cli.explain`. @@ -32,7 +32,7 @@ def register(sub: argparse._SubParsersAction) -> None: p.add_argument( "path", nargs="*", - help="Command path tokens; empty = root (same as 'microphone-cli').", + help="Command path tokens; empty = root (same as the bare tool name).", ) p.add_argument("--json", action="store_true", help="Emit structured JSON.") p.set_defaults(func=cmd_explain) diff --git a/microphone_cli/cli/_commands/gain.py b/microphone_cli/cli/_commands/gain.py new file mode 100644 index 0000000..e4b8047 --- /dev/null +++ b/microphone_cli/cli/_commands/gain.py @@ -0,0 +1,348 @@ +"""``microphone gain`` — read/set capture gain over ALSA and, on arrays, firmware. + +Two independent gain knobs exist on an XVF3800 array and only one on a plain +USB microphone: + +* **ALSA** — the kernel-level capture-volume mixer control, read and written + through ``amixer`` (:mod:`microphone_cli.mixer`). Every USB capture device + has one (or ``gain get``/``gain set`` fails with a user error naming the + card). +* **Firmware** — ``AUDIO_MGR_MIC_GAIN`` on the XVF3800 itself + (:mod:`microphone_cli.xvf3800`), read/written over a USB vendor control + transfer. Only present on :attr:`~microphone_cli.devices.MicrophoneDevice.is_array` + devices. + +``gain get`` reports both when available. ``gain set`` defaults to +``--target both`` (silently ALSA-only on a non-array device) and is a dry run +unless ``--apply`` is passed: without ``--apply`` it computes and prints the +plan without issuing the ALSA ``cset`` or the firmware write, and without +opening the USB device at all; with ``--apply`` both actions are issued and +wrapped in exactly one :func:`~microphone_cli.activation.activation_scope` +line. + +Value mapping: the CLI value is a float. For ALSA it is linearly mapped from +``0.0..1.0`` onto the control's reported ``[min, max]`` and rounded to the +nearest integer with Python's ``round()`` (banker's rounding: ties round to +the nearest even integer). For firmware, the value is written verbatim as the +raw ``AUDIO_MGR_MIC_GAIN`` float — there is no range to map onto. +""" + +from __future__ import annotations + +import argparse +import math +import subprocess # nosec B404 - fixed argv, no shell; passed through to mixer.py +from typing import Any + +from microphone_cli import mixer +from microphone_cli.activation import activation_scope +from microphone_cli.cli._commands import JSON_FLAG_HELP +from microphone_cli.cli._commands.overview import emit_overview +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from microphone_cli.cli._output import emit_result +from microphone_cli.devices import MicrophoneDevice, resolve +from microphone_cli.usbctl import find_devices, open_device +from microphone_cli.xvf3800 import Xvf3800 + +_FIRMWARE_PARAM = "AUDIO_MGR_MIC_GAIN" +_TARGETS = ("alsa", "firmware", "both") + +_VERBS = [ + "gain overview — this description", + "gain get — read the current ALSA and (on arrays) firmware gain", + "gain set [--apply] — plan or apply a new gain (0.0..1.0)", +] + + +def _sections() -> list[dict[str, object]]: + return [ + {"title": "Verbs", "items": list(_VERBS)}, + { + "title": "Notes", + "items": [ + "value is a float; ALSA maps 0.0..1.0 onto the control's min..max", + "firmware gain (AUDIO_MGR_MIC_GAIN) is written verbatim, array devices only", + "gain set is a dry run unless --apply is passed", + "--apply writes exactly one line to the activation log", + ], + }, + ] + + +def cmd_gain_overview(args: argparse.Namespace) -> None: + emit_overview("microphone gain", _sections(), json_mode=bool(getattr(args, "json", False))) + + +def _no_verb(args: argparse.Namespace) -> None: + cmd_gain_overview(args) + + +# --------------------------------------------------------------------------- +# firmware access seam +# --------------------------------------------------------------------------- + + +def _open_firmware(device: MicrophoneDevice, root: str) -> Xvf3800: + """Open the array's XVF3800 over USB. The seam tests monkeypatch.""" + matches = find_devices( + root=root, + vendor=device.usb_ids.vendor, + product=device.usb_ids.product, + serial=device.serial, + ) + if not matches: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"no USB node found for {device.stable_id}", + remediation=( + "reattach the device and retry; `microphone device list` shows what's attached" + ), + ) + fd = open_device( + matches[0]["node"], vendor=matches[0].get("vendor"), product=matches[0].get("product") + ) + return Xvf3800(fd, vendor=matches[0].get("vendor") or device.usb_ids.vendor) + + +def _read_firmware_gain(device: MicrophoneDevice, root: str) -> dict[str, object]: + """``{"mic_gain": f}`` on success, ``{"error": message}`` on any failure.""" + try: + xvf = _open_firmware(device, root) + except CliError as exc: + return {"error": exc.message} + try: + values = xvf.read(_FIRMWARE_PARAM) + except CliError as exc: + return {"error": exc.message} + finally: + xvf.close() + return {"mic_gain": values[0]} + + +# --------------------------------------------------------------------------- +# gain get +# --------------------------------------------------------------------------- + + +def _alsa_payload(control: mixer.MixerControl) -> dict[str, object]: + return { + "control": control.name, + "numid": control.numid, + "value": control.value, + "min": control.min, + "max": control.max, + } + + +def cmd_gain_get(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + root = getattr(args, "root", "/") or "/" + device = resolve(args.device, root=root) + + control = mixer.get_gain(device.card_index, run=subprocess.run) + alsa = _alsa_payload(control) + + firmware: dict[str, object] | None = None + if device.is_array: + firmware = _read_firmware_gain(device, root) + + payload = {"device": device.stable_id, "alsa": alsa, "firmware": firmware} + if json_mode: + emit_result(payload, json_mode=True) + else: + lines = [ + f"device: {device.stable_id}", + f"alsa: {alsa['control']} (numid={alsa['numid']}) " + f"= {alsa['value']} [{alsa['min']}..{alsa['max']}]", + ] + if firmware is None: + lines.append("firmware: n/a (not an array)") + elif "error" in firmware: + lines.append(f"firmware: error: {firmware['error']}") + else: + lines.append(f"firmware: mic_gain = {firmware['mic_gain']}") + emit_result("\n".join(lines), json_mode=False) + + +# --------------------------------------------------------------------------- +# gain set +# --------------------------------------------------------------------------- + + +def _clamp01(value: float) -> float: + # Belt-and-braces only: by the time this runs, _validate_gain_value has + # already refused anything outside 0.0..1.0 (and non-finite values), so + # this never actually clamps a real value. + return max(0.0, min(1.0, value)) + + +def _validate_gain_value(value: float) -> None: + """Refuse a gain value before any planning or writing happens. + + Applies to every ``--target`` (including the default ``both``): a bad + value must never reach ``_map_to_alsa``'s silent clamp or the firmware + write, for either knob. + """ + if not math.isfinite(value) or not (0.0 <= value <= 1.0): + raise CliError( + code=EXIT_USER_ERROR, + message=f"gain value {value} is out of range", + remediation="pass a finite value in 0.0..1.0", + ) + + +def _map_to_alsa(value: float, control: mixer.MixerControl) -> int: + lo = control.min if control.min is not None else 0 + hi = control.max if control.max is not None else 100 + return round(lo + _clamp01(value) * (hi - lo)) + + +def cmd_gain_set(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + root = getattr(args, "root", "/") or "/" + target = getattr(args, "target", "both") or "both" + value = float(args.value) + apply = bool(getattr(args, "apply", False)) + _validate_gain_value(value) + + device = resolve(args.device, root=root) + + do_alsa = target in ("alsa", "both") + do_firmware = target in ("firmware", "both") + if target == "firmware" and not device.is_array: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{device.stable_id} is not a microphone array; it has no firmware gain", + remediation="use --target alsa (or drop --target, the default is 'both')", + ) + # "both" silently drops firmware on a non-array device: nothing to target. + if target == "both" and not device.is_array: + do_firmware = False + + if not apply: + payload = _plan(device, value, do_alsa, do_firmware) + else: + payload = _apply(device, value, do_alsa, do_firmware, root, target) + + if json_mode: + emit_result(payload, json_mode=True) + else: + emit_result(_render_set_text(payload), json_mode=False) + + +def _plan( + device: MicrophoneDevice, value: float, do_alsa: bool, do_firmware: bool +) -> dict[str, Any]: + planned: dict[str, object] = {} + if do_alsa: + control = mixer.get_gain(device.card_index, run=subprocess.run) + raw = _map_to_alsa(value, control) + planned["alsa"] = {"argv": mixer.set_gain_argv(device.card_index, control.numid, raw)} + if do_firmware: + planned["firmware"] = {"param": _FIRMWARE_PARAM, "values": [value]} + return { + "mode": "dry-run", + "applied": False, + "hardware_touched": False, + "device": device.stable_id, + "planned": planned, + } + + +def _apply( + device: MicrophoneDevice, + value: float, + do_alsa: bool, + do_firmware: bool, + root: str, + target: str, +) -> dict[str, Any]: + params: dict[str, object] = {"value": value, "target": target} + result: dict[str, Any] = { + "mode": "apply", + "applied": True, + "hardware_touched": True, + "device": device.stable_id, + } + with activation_scope("gain set", device.stable_id, params): + if do_alsa: + control = mixer.get_gain(device.card_index, run=subprocess.run) + raw = _map_to_alsa(value, control) + updated = mixer.set_gain(device.card_index, control, raw, run=subprocess.run) + result["alsa"] = _alsa_payload(updated) + params["alsa_raw"] = raw + if do_firmware: + xvf = _open_firmware(device, root) + try: + xvf.write(_FIRMWARE_PARAM, [value]) + after = xvf.read(_FIRMWARE_PARAM) + finally: + xvf.close() + result["firmware"] = {"mic_gain": after[0]} + params["firmware_value"] = value + return result + + +def _render_set_text(payload: dict[str, Any]) -> str: + lines = [f"device: {payload['device']}", f"mode: {payload['mode']}"] + if payload["mode"] == "dry-run": + planned = payload["planned"] + if "alsa" in planned: + lines.append(f"planned alsa argv: {' '.join(planned['alsa']['argv'])}") + if "firmware" in planned: + fw = planned["firmware"] + lines.append(f"planned firmware: {fw['param']} = {fw['values']}") + if not planned: + lines.append("planned: nothing to do") + else: + if "alsa" in payload: + alsa = payload["alsa"] + lines.append(f"alsa: {alsa['control']} (numid={alsa['numid']}) = {alsa['value']}") + if "firmware" in payload: + lines.append(f"firmware: mic_gain = {payload['firmware']['mic_gain']}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# registration +# --------------------------------------------------------------------------- + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "gain", + help="Read/set microphone capture gain (see 'microphone gain overview').", + ) + p.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + p.set_defaults(func=_no_verb, json=False) + # Propagate parser_class so nested verbs route parse-time errors through the + # structured CliError contract instead of argparse's default exit(2). + noun_sub = p.add_subparsers(dest="gain_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the gain verb group.") + ov.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + ov.set_defaults(func=cmd_gain_overview) + + get = noun_sub.add_parser("get", help="Read the current ALSA and firmware gain.") + get.add_argument("device", help="A microphone selector (stable id, serial, card id, ...).") + get.add_argument("--root", default="/", help="Root to resolve devices under (testing).") + get.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + get.set_defaults(func=cmd_gain_get) + + set_ = noun_sub.add_parser("set", help="Plan or apply a new capture gain.") + set_.add_argument("device", help="A microphone selector (stable id, serial, card id, ...).") + set_.add_argument("value", type=float, help="Gain, 0.0..1.0.") + set_.add_argument( + "--apply", + action="store_true", + help="Actually issue the ALSA/firmware writes (default: dry run).", + ) + set_.add_argument( + "--target", + choices=_TARGETS, + default="both", + help="Which gain(s) to set (default: both, where available).", + ) + set_.add_argument("--root", default="/", help="Root to resolve devices under (testing).") + set_.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + set_.set_defaults(func=cmd_gain_set) diff --git a/microphone_cli/cli/_commands/inspect.py b/microphone_cli/cli/_commands/inspect.py new file mode 100644 index 0000000..a1d90ef --- /dev/null +++ b/microphone_cli/cli/_commands/inspect.py @@ -0,0 +1,245 @@ +"""``microphone inspect `` — capture formats, rates, and firmware. + +Resolves a selector through :func:`microphone_cli.devices.resolve` (identity; +never a raw ALSA card number — see that module's docstring), then reads the +USB-audio driver's own report of what the capture interface offers from +``/proc/asound/card/stream0``: ALSA format names, sample rates +and channel count. All reads are taken relative to the device's ``root`` so +tests stay hardware-free — no ALSA library is loaded and no ``/proc`` node +outside a fixture tree is ever touched. + +Cited from ``../webcam-cli/webcam_cli/cli/_commands/list_devices.py`` for the +"compose, never hard-fail" shape: a descriptive verb accepts a bad or partial +target and still returns something rather than raising (see +``CLAUDE.md``, "Adding a verb or noun", point 6). + +On an XVF3800-based array (``device.is_array``) this also looks the device up +on the USB bus with :func:`microphone_cli.usbctl.find_devices` and reads its +firmware identity with :class:`microphone_cli.xvf3800.Xvf3800`. That control +path needs a device node most agents cannot open without a udev rule (see +``microphone_cli.access``), so a permission or lookup failure there is +reported as ``firmware: {"error": ...}`` rather than raised — ``inspect`` is +descriptive and must not hard-fail on a permissions problem non-array +microphones don't even have. Non-array microphones report ``firmware: null``; +there is no XVF3800 control protocol to speak to them with. +""" + +from __future__ import annotations + +import argparse +import os +import re + +from microphone_cli import usbctl +from microphone_cli.cli._errors import CliError +from microphone_cli.cli._output import emit_result +from microphone_cli.devices import MicrophoneDevice, resolve +from microphone_cli.xvf3800 import Xvf3800 + +# "Capture:" / "Playback:" section header in /proc/asound/cardN/stream0. +_SECTION_RE = re.compile(r"^(?P
Playback|Capture):\s*$") +# " Format: S32_LE" +_FORMAT_RE = re.compile(r"^\s*Format:\s*(?P\S+)\s*$") +# " Channels: 6" +_CHANNELS_RE = re.compile(r"^\s*Channels:\s*(?P\d+)\s*$") +# " Rates: 16000, 48000" +_RATES_RE = re.compile(r"^\s*Rates:\s*(?P\S.*)$") + + +def _stream0_path(root: str, device: MicrophoneDevice) -> str: + return os.path.join(root or "/", "proc", "asound", f"card{device.card_index}", "stream0") + + +def _read_text(path: str) -> str | None: + try: + with open(path, encoding="utf-8", errors="replace") as handle: + return handle.read() + except OSError: + return None + + +def _section_header(raw_line: str) -> str | None: + """The section name (``Playback``/``Capture``) if ``raw_line`` opens one.""" + matched = _SECTION_RE.match(raw_line.strip()) + return matched.group("section") if matched is not None else None + + +def _append_format(formats: list[str], raw_line: str) -> bool: + matched = _FORMAT_RE.match(raw_line) + if matched is None: + return False + fmt = matched.group("fmt") + if fmt not in formats: + formats.append(fmt) + return True + + +def _append_channels(channels: list[int], raw_line: str) -> bool: + matched = _CHANNELS_RE.match(raw_line) + if matched is None: + return False + channels.append(int(matched.group("n"))) + return True + + +def _append_rates(rates: list[int], raw_line: str) -> bool: + matched = _RATES_RE.match(raw_line) + if matched is None: + return False + for token in matched.group("rates").split(","): + token = token.strip() + if token.isdigit(): + value = int(token) + if value not in rates: + rates.append(value) + return True + + +def _parse_capture_line( + raw_line: str, + formats: list[str], + rates: list[int], + channels: list[int], +) -> None: + """Try each capture-field pattern against one line of a ``Capture:`` block.""" + if _append_format(formats, raw_line): + return + if _append_channels(channels, raw_line): + return + _append_rates(rates, raw_line) + + +def _parse_capture_block(text: str) -> tuple[list[str], list[int], int | None]: + """Formats, rates and the widest channel count of the ``Capture:`` block. + + A multi-altset device lists one block per altset; every altset's format + and rates are collected (order-preserving, de-duplicated), and the widest + ``Channels:`` value is reported — the same "widest wins" rule + :mod:`microphone_cli.devices` uses for the same reason: the widest altset + is the one that carries every microphone of an array. + """ + formats: list[str] = [] + rates: list[int] = [] + channels: list[int] = [] + section: str | None = None + + for raw_line in text.splitlines(): + header = _section_header(raw_line) + if header is not None: + section = header + continue + if section != "Capture": + continue + _parse_capture_line(raw_line, formats, rates, channels) + + return formats, sorted(rates), (max(channels) if channels else None) + + +def _formats_rates_channels( + root: str, device: MicrophoneDevice +) -> tuple[list[str], list[int], int | None]: + text = _read_text(_stream0_path(root, device)) + if text is None: + # No stream0 in this tree: fall back to the device's own channel + # count (already derived from stream0 or hw_params by devices.py) + # and report no known formats/rates rather than guessing at them. + return [], [], device.channels + formats, rates, channels = _parse_capture_block(text) + if not formats and not rates and channels is None: + return [], [], device.channels + return formats, rates, channels if channels is not None else device.channels + + +def _firmware_payload(root: str, device: MicrophoneDevice) -> dict[str, object] | None: + if not device.is_array: + return None + + matches = usbctl.find_devices( + root=root, + vendor=device.usb_ids.vendor, + product=device.usb_ids.product, + serial=device.serial, + ) + if not matches: + return { + "error": ( + f"no USB device node found under {root!r} for {device.stable_id} " + f"({device.usb_ids})" + ) + } + + node = matches[0]["node"] + try: + fd = usbctl.open_device( + node, vendor=matches[0].get("vendor"), product=matches[0].get("product") + ) + except CliError as exc: + return {"error": exc.message} + + try: + with Xvf3800(fd, vendor=matches[0].get("vendor") or device.usb_ids.vendor) as dev: + return dev.firmware_info() + except CliError as exc: + return {"error": exc.message} + + +def build_report(selector: str, root: str) -> dict[str, object]: + """Resolve ``selector`` and report its capture formats, rates, and firmware.""" + device = resolve(selector, root=root) + formats, rates, channels = _formats_rates_channels(root, device) + return { + "device": device.as_dict(), + "formats": formats, + "rates": rates, + "channels": channels, + "firmware": _firmware_payload(root, device), + } + + +def _render_text(report: dict[str, object]) -> str: + device = report["device"] + lines = [f"{device['stable_id']} ({device['label']}) {device['alsa_address']}"] + lines.append(f" channels: {report['channels']}") + lines.append(f" formats: {', '.join(report['formats']) or 'unknown'}") + lines.append(f" rates: {', '.join(str(r) for r in report['rates']) or 'unknown'}") + firmware = report["firmware"] + if firmware is None: + lines.append(" firmware: n/a (not an XVF3800 array)") + elif "error" in firmware: + lines.append(f" firmware: unavailable ({firmware['error']})") + else: + lines.append( + f" firmware: version={firmware['version']} build={firmware['build']} " + f"host={firmware['host']} repo_hash={firmware['repo_hash']}" + ) + return "\n".join(lines) + + +def cmd_inspect(args: argparse.Namespace) -> int: + root = getattr(args, "root", None) or "/" + report = build_report(args.device, root) + json_mode = bool(getattr(args, "json", False)) + if json_mode: + emit_result(report, json_mode=True) + else: + emit_result(_render_text(report), json_mode=False) + return 0 + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "inspect", + help="Inspect one microphone's capture formats, rates, channels, and firmware.", + ) + p.add_argument("device", metavar="", help="Selector: stable id, serial, or label.") + p.add_argument("--json", action="store_true", help="Emit structured JSON.") + p.add_argument( + "--root", + default="/", + metavar="PATH", + help=( + "Filesystem root to resolve the device under (default: /); " + "mainly for pointing at a synthetic device tree in tests." + ), + ) + p.set_defaults(func=cmd_inspect) diff --git a/microphone_cli/cli/_commands/learn.py b/microphone_cli/cli/_commands/learn.py index de32ca2..aa531e0 100644 --- a/microphone_cli/cli/_commands/learn.py +++ b/microphone_cli/cli/_commands/learn.py @@ -1,34 +1,140 @@ -"""``microphone-cli learn`` — the learnability affordance. +"""``microphone learn`` — the learnability affordance. Prints a structured self-teaching prompt. Must satisfy the agent-first rubric: >=200 chars and mention purpose, command map, exit codes, --json, and explain. + +Beyond the rubric, this text leads with the one fact an agent needs *before* it +invokes anything here: which invocations open a microphone or write to array +firmware. A tool that can open a microphone is a surveillance surface and a tool +that can write firmware parameters is a bricking surface, so "did that touch the +hardware?" must be answerable from the surface alone — and the answer is stated +first, not buried in a verb's ``--help``. + +Ported from ``../webcam-cli/webcam_cli/cli/_commands/learn.py`` (the +purpose/hardware-split/command-map/exit-code section order and the +``_as_json_payload`` key set), with the video half dropped and the +array-firmware half — ``array``, ``param``, the persistent-write tier — added. """ from __future__ import annotations import argparse -from microphone_cli import __version__ +from microphone_cli import __version__, activation from microphone_cli.cli._output import emit_result +_PURPOSE = ( + "Own the USB microphones and microphone arrays attached to this host: enumerate " + "what is attached and give each a stable name, report honestly what can be opened " + "right now, expose the array firmware an ordinary audio API cannot reach " + "(direction-of-arrival, echo-canceller state, raw XVF3800 parameters), and hand a " + "consumer either a live stream or a bounded recorded file. Interpreting what is " + "*in* the audio is a speech model's job, not this tool's." +) + +_AUDIENCE = [ + "media-cli — the composing consumer: it orchestrates capture across tools and " + "calls this CLI for everything microphone-shaped rather than re-deriving it", + "agents — anything that must decide, from the surface alone, whether an " + "invocation opens a device", + "operators — a human at a shell debugging an array that is not behaving", +] + _TEXT = """\ -microphone-cli — a clonable template for AgentCulture mesh agents. +microphone — enumerate, inspect, tune, stream, and record the local USB +microphones and microphone arrays. + +The installed command is `microphone`. (The project and PyPI distribution are +named microphone-cli and the import package is microphone_cli; neither is ever +typed.) Purpose ------- -Scaffold for a new Culture mesh agent: an agent-first CLI (cited from the teken -`python-cli` reference), an identity (culture.yaml + CLAUDE.md), the canonical -guildmaster skill kit under .claude/skills/, and a deploy/CI baseline. Clone it, -rename the package, and edit culture.yaml to mint a new agent. +Own the USB microphones and microphone arrays attached to this host: enumerate +what is attached and give each a stable name, report honestly what can be opened +right now, expose the array firmware an ordinary audio API cannot reach +(direction-of-arrival, echo-canceller state, raw XVF3800 parameters), and hand a +consumer either a live stream or a bounded recorded file. Interpreting what is +*in* the audio is a speech model's job, not this tool's. + +What touches the hardware — read this before invoking anything +-------------------------------------------------------------- +Every verb sits on one of three levels, and which level you are on is readable +from the flags alone: + + (no flag) Dry run. Resolves the device from filesystem reads, validates the + request, prints the plan it would run. Opens no device, issues no + USB transfer, spawns nothing, logs nothing. + --probe Checks the capture engine and the capture node's real access state + (`stream audio`, `record`). Still spawns nothing. + --apply Commits: opens the device and streams/records, or issues the ALSA + and firmware writes. Written to the activation log. + +Read-only vendor control transfers are the middle case worth knowing about: +`inspect` (firmware identity, on arrays only), `array doa`, `array aec get`, +`param get` and `gain get` open the USB device and *read* from it with no +--apply. They change nothing, but they are not free: they need permission on the +device node, and a headless agent without the udev rule gets a typed environment +error rather than silence. Writes always need --apply. + +`list` opens nothing beyond one non-blocking permission probe per capture node. Commands -------- - microphone-cli whoami Identity from culture.yaml. - microphone-cli learn This self-teaching prompt. - microphone-cli explain ... Markdown docs for any noun/verb path. - microphone-cli overview Descriptive snapshot of the agent. - microphone-cli doctor Check the agent-identity invariants. - microphone-cli cli overview Describe the CLI surface itself. + microphone list Attached microphones: id, ALSA address, access. + microphone inspect Formats, rates, channels, array firmware identity. + microphone gain overview Describe the gain verb group. + microphone gain get Read the ALSA (and firmware) capture gain. + microphone gain set Plan or --apply a new gain, 0.0..1.0. + microphone array overview Describe the array verb group. + microphone array doa Direction of arrival, raw firmware radians. + microphone array aec overview Describe the echo-canceller verb group. + microphone array aec get Echo-canceller state (read-only). + microphone array aec set Flip AEC switches (dry run unless --apply). + microphone param overview Describe the param verb group. + microphone param list Every XVF3800 parameter row. + microphone param get Read one raw firmware parameter. + microphone param set ... Write one raw firmware parameter. + microphone stream overview Describe the stream verb group. + microphone stream audio Live microphone attachment point (unbounded). + microphone record Bounded clip to one file. + microphone whoami Identity from culture.yaml. + microphone learn This self-teaching prompt. + microphone explain ... Markdown docs for any noun/verb path. + microphone overview Descriptive snapshot of the agent. + microphone doctor Check the agent-identity invariants. + microphone cli overview Describe the CLI surface itself. + +Naming a device +--------------- +Pass the stable id printed by `microphone list` (or a unique substring of it), +never a bare `hw:N` or a card index: ALSA card numbering is plug order and +re-enumeration order, not identity, so an index is not a reproducible +instruction. `microphone list --json` prints the id to use. Access failures +distinguish absent from present-but-forbidden from busy, and each names its own +fix. + +The persistent tier +------------------- +`param set` refuses a persistent or destructive parameter unless +--allow-persistent is passed alongside --apply. An ordinary `rw` write is +volatile and reverts on the next power-cycle; a persistent one survives it, +reboots the device, or is otherwise irreversible. That gate runs before the +device is opened and before any transfer is issued. + +Raw firmware values +------------------- +`array doa` reports the azimuth exactly as the firmware reports it: radians, in +the array's own frame. No degree conversion, no coordinate transform, no +re-basing onto a robot frame happens here — a consumer that needs another frame +owns that conversion and can only do it correctly from the untouched value. + +Bounds +------ +`record` is bounded by construction: a duration cap and a size cap always apply +and no flag combination expresses "forever". `stream audio` is unbounded by +construction: there is no --duration; stop it with SIGINT/SIGTERM or by killing +the returned pid. Machine-readable output ----------------------- @@ -38,36 +144,149 @@ Exit-code policy ---------------- 0 success - 1 user-input error (bad flag, bad path, missing arg) - 2 environment / setup error - 3+ reserved + 1 user-input error (bad flag, unknown device, unknown parameter, bad value) + 2 environment error (no capture engine, forbidden device node) — not + retryable without a config/environment fix + 3 device busy (EBUSY) — retryable; another process holds the device + 4+ reserved + +Who this is for +--------------- + media-cli the composing consumer: it orchestrates capture across tools and + calls this CLI for everything microphone-shaped + agents anything that must decide, from the surface alone, whether an + invocation opens a device + operators a human at a shell debugging an array that is misbehaving + +Consent +------- +Every --apply is appended to the activation log (default +~/.local/state/microphone-cli/activation.jsonl; override +$MICROPHONE_ACTIVATION_LOG), and a recording writes only to the path you name — +no hidden buffer, never to stdout. A hardware activity light CANNOT be promised: +that is device firmware, outside this tool's control. This tool records +activations; it does not prevent covert use. More detail ----------- - microphone-cli explain microphone-cli + microphone explain microphone + microphone explain list + microphone explain array + microphone explain param + microphone explain stream + microphone explain record """ +# The registered surface, as an agent should read it. Kept in the order +# `_build_parser` registers it; ``tests/test_cli.py`` walks the live parser tree +# and fails when this list and that tree disagree. +_COMMANDS: list[dict[str, object]] = [ + {"path": ["list"], "summary": "Attached microphones: stable id, ALSA address, access state."}, + { + "path": ["inspect"], + "summary": "One device's capture formats, rates, channels, and array firmware identity.", + }, + {"path": ["gain"], "summary": "Capture-gain noun group."}, + {"path": ["gain", "overview"], "summary": "Describe the gain verb group."}, + {"path": ["gain", "get"], "summary": "Read the ALSA (and, on arrays, firmware) capture gain."}, + {"path": ["gain", "set"], "summary": "Plan or --apply a new capture gain, 0.0..1.0."}, + {"path": ["array"], "summary": "Microphone-array firmware noun group."}, + {"path": ["array", "overview"], "summary": "Describe the array verb group."}, + { + "path": ["array", "doa"], + "summary": "Read direction-of-arrival as raw firmware radians (--watch for JSON Lines).", + }, + {"path": ["array", "aec"], "summary": "Echo-canceller noun group."}, + {"path": ["array", "aec", "overview"], "summary": "Describe the echo-canceller verb group."}, + {"path": ["array", "aec", "get"], "summary": "Read echo-canceller state (read-only)."}, + { + "path": ["array", "aec", "set"], + "summary": "Flip echo-canceller switches (dry run unless --apply).", + }, + {"path": ["param"], "summary": "Raw XVF3800 firmware-parameter noun group."}, + {"path": ["param", "overview"], "summary": "Describe the param verb group and its gates."}, + {"path": ["param", "list"], "summary": "Every XVF3800 parameter row, with access and tier."}, + {"path": ["param", "get"], "summary": "Read one raw firmware parameter."}, + { + "path": ["param", "set"], + "summary": "Write one raw firmware parameter (--apply, plus --allow-persistent " + "for the persistent tier).", + }, + {"path": ["stream"], "summary": "Live attachment-point noun group."}, + {"path": ["stream", "overview"], "summary": "Describe the stream verb group."}, + {"path": ["stream", "audio"], "summary": "Serve a live microphone stream over RTP/UDP."}, + {"path": ["record"], "summary": "Record a bounded clip to one file."}, + {"path": ["whoami"], "summary": "Identity probe from culture.yaml."}, + {"path": ["learn"], "summary": "Self-teaching prompt."}, + {"path": ["explain"], "summary": "Markdown docs by path."}, + {"path": ["overview"], "summary": "Descriptive snapshot of the agent."}, + {"path": ["doctor"], "summary": "Check the agent-identity invariants."}, + {"path": ["cli"], "summary": "CLI-surface introspection (noun group)."}, + {"path": ["cli", "overview"], "summary": "Describe the CLI surface."}, +] + def _as_json_payload() -> dict[str, object]: return { "tool": "microphone-cli", + "command": "microphone", + "import_package": "microphone_cli", "version": __version__, - "purpose": "Clonable scaffold for a new AgentCulture mesh agent.", - "commands": [ - {"path": ["whoami"], "summary": "Identity probe from culture.yaml."}, - {"path": ["learn"], "summary": "Self-teaching prompt."}, - {"path": ["explain"], "summary": "Markdown docs by path."}, - {"path": ["overview"], "summary": "Descriptive snapshot of the agent."}, - {"path": ["doctor"], "summary": "Check the agent-identity invariants."}, - {"path": ["cli", "overview"], "summary": "Describe the CLI surface."}, - ], + "purpose": _PURPOSE, + "audience": list(_AUDIENCE), + "commands": [dict(entry) for entry in _COMMANDS], + "hardware_activation": { + "default": "dry run — resolves and validates from filesystem reads, opens no " + "device, issues no transfer, spawns nothing, logs nothing", + "--probe": "checks the capture engine and the capture node's real access state " + "(stream audio, record); still spawns nothing", + "--apply": "commits — opens the device and streams/records, or issues the ALSA " + "and firmware writes; written to the activation log", + "read_only_control_transfers": ( + "inspect (firmware identity), array doa, array aec get, param get and gain " + "get open the USB device and READ from it with no --apply. They change " + "nothing, but they need permission on the device node" + ), + "list": "opens nothing beyond one non-blocking permission probe per capture node", + }, + "device_selector": ( + "the stable id printed by `microphone list` (or a unique substring); a bare " + "hw:N or card index is refused because ALSA card numbering is plug order, " + "not identity" + ), + "bounds": { + "record": "bounded by construction — a duration cap and a size cap always " + "apply; no flag means 'forever'", + "stream audio": "unbounded by construction — there is no --duration; stop with " + "SIGINT/SIGTERM or by killing the returned pid", + }, + "persistent_tier": ( + "`param set` refuses a persistent or destructive parameter unless " + "--allow-persistent accompanies --apply; the gate runs before the device is " + "opened and before any transfer is issued" + ), + "raw_firmware_values": ( + "`array doa` reports the azimuth exactly as the firmware reports it — radians " + "in the array's own frame, with no conversion or coordinate transform" + ), "exit_codes": { "0": "success", "1": "user-input error", - "2": "environment/setup error", + "2": "environment error — not retryable without a config fix", + "3": "device busy (EBUSY) — retryable", + }, + "consent": { + "activation_log": str(activation.log_path()), + "activation_log_env": activation.ENV_LOG_PATH, + "bytes_written": "only to the path named on the command line — no hidden buffer", + "activity_light": ( + "cannot be promised — a hardware activity LED is device firmware, outside " + "this tool's control; this tool records activations, it does not prevent " + "covert use" + ), }, "json_support": True, - "explain_pointer": "microphone-cli explain ", + "explain_pointer": "microphone explain ", } diff --git a/microphone_cli/cli/_commands/list_devices.py b/microphone_cli/cli/_commands/list_devices.py new file mode 100644 index 0000000..1cd96aa --- /dev/null +++ b/microphone_cli/cli/_commands/list_devices.py @@ -0,0 +1,144 @@ +"""``microphone list`` — attached microphones, identity, and access status. + +Composes the two wave-1 modules that already own this domain rather than +re-deriving anything: :mod:`microphone_cli.devices` for *what is attached and +how to name it* (identity is keyed on the USB descriptors, not the plug-order +ALSA card index — see that module's docstring for why), and +:mod:`microphone_cli.access` for *can the capture node be opened right now*. +This module adds nothing to either: it enumerates, probes access on the one +ALSA capture node each device exposes, and renders. + +Cited from ``../webcam-cli/webcam_cli/cli/_commands/list_devices.py`` (the +pattern of composing ``enumerate_devices`` with a per-node ``check_access`` +probe, and never hard-failing on a bad device) with the video/audio pairing +dropped — this project has exactly one node per device, the ALSA capture PCM. + +``list`` never fails because one device is unhappy. :func:`~microphone_cli. +access.check_access` *reports* rather than raises, and this module never calls +:func:`microphone_cli.access.access_error` — a forbidden, absent, or busy +device is shown as such, with its remediation carried through, while ``list`` +itself still exits 0. + +No hardware is activated beyond the single non-blocking permission probe +:func:`~microphone_cli.access.check_access` already performs per node (an +``O_NONBLOCK`` ``open()``/``close()`` pair, not a capture). +""" + +from __future__ import annotations + +import argparse +import os +import re + +from microphone_cli.access import AccessReport, check_access +from microphone_cli.cli._output import emit_result +from microphone_cli.devices import MicrophoneDevice, enumerate_devices + +# A capture PCM directory under /proc/asound/cardN: "pcm0c" (playback is "pcm0p"). +_CAPTURE_PCM_RE = re.compile(r"^pcm(?P\d+)c$") + + +def _capture_pcm_device(root: str, index: int) -> int: + """Lowest capture PCM device number of an ALSA card; ``0`` if none is listed. + + Every device :func:`~microphone_cli.devices.enumerate_devices` returns has + at least one capture PCM (that is how it qualified as a microphone in the + first place); the ``0`` fallback only matters for a fixture tree that + doesn't bother recreating the ``pcm0c`` directory. + """ + card_dir = os.path.join(root or "/", "proc", "asound", f"card{index}") + try: + entries = os.listdir(card_dir) + except OSError: + return 0 + numbers = [ + int(matched.group("device")) + for matched in (_CAPTURE_PCM_RE.match(entry) for entry in entries) + if matched is not None + ] + return min(numbers) if numbers else 0 + + +def _audio_node_path(root: str, device: MicrophoneDevice) -> str: + """The ALSA capture PCM device node for ``device``, e.g. ``/dev/snd/pcmC1D0c``.""" + pcm_device = _capture_pcm_device(root, device.card_index) + # Root-joined so a fixture tree never probes the host's real /dev/snd node + # (found on hardware: fixture card 1 collided with a real card 1). + return os.path.join(root, f"dev/snd/pcmC{device.card_index}D{pcm_device}c") + + +def _access_payload(report: AccessReport) -> dict[str, object]: + return { + "state": report.state.value, + "path": report.path, + "remediation": report.remediation, + } + + +def _device_payload(root: str, device: MicrophoneDevice) -> dict[str, object]: + """``device.as_dict()`` plus one access probe on its ALSA capture node.""" + payload = device.as_dict() + payload["audio_access"] = _access_payload(check_access(_audio_node_path(root, device), "audio")) + return payload + + +def build_report(root: str) -> dict[str, object]: + """Enumerate every microphone under ``root`` with its access status. + + Pure composition of :func:`microphone_cli.devices.enumerate_devices` + (identity) and :func:`microphone_cli.access.check_access` (openability). + Never raises: a device whose node is absent, forbidden, or busy is still + listed, with that state and its remediation attached. + """ + devices = enumerate_devices(root=root) + return { + "devices": [_device_payload(root, device) for device in devices], + "count": len(devices), + } + + +def _render_text(report: dict[str, object]) -> str: + devices = report["devices"] + if not devices: + return "no microphones found" + + lines: list[str] = [f"{report['count']} microphone(s)"] + for payload in devices: + marker = " [array]" if payload["is_array"] else "" + access = payload["audio_access"] + lines.append( + f"{payload['stable_id']} ({payload['label']}) {payload['alsa_address']}{marker}" + ) + lines.append(f" audio access: {access['state']}") + if access["remediation"]: + lines.append(f" hint: {access['remediation']}") + return "\n".join(lines) + + +def cmd_list(args: argparse.Namespace) -> int: + root = getattr(args, "root", None) or "/" + report = build_report(root) + json_mode = bool(getattr(args, "json", False)) + if json_mode: + emit_result(report, json_mode=True) + else: + emit_result(_render_text(report), json_mode=False) + return 0 + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "list", + help="List attached microphones: stable id, ALSA address, and access status.", + ) + p.add_argument("--json", action="store_true", help="Emit structured JSON.") + p.add_argument( + "--root", + default="/", + metavar="PATH", + help=( + "Filesystem root to enumerate devices under (default: /); " + "mainly for pointing at a synthetic device tree in tests." + ), + ) + p.set_defaults(func=cmd_list) diff --git a/microphone_cli/cli/_commands/overview.py b/microphone_cli/cli/_commands/overview.py index 8a04cb3..2c610d3 100644 --- a/microphone_cli/cli/_commands/overview.py +++ b/microphone_cli/cli/_commands/overview.py @@ -1,9 +1,11 @@ -"""``microphone-cli overview`` — read-only descriptive snapshot of the agent. +"""``microphone overview`` — read-only descriptive snapshot of the agent. Describes the agent to an agent reader: identity (from culture.yaml), the verb -surface, and the sibling-pattern artifacts this template carries. The shared -section/render helpers here are reused by the ``cli`` noun's ``overview`` (see -:mod:`microphone_cli.cli._commands.cli`). +surface, which invocations energize hardware, the contracts the microphone verbs +obey, and the consent posture — stated with its limits, never overstated. The +shared section/render helpers here are reused by every noun group's own +``overview`` (see :mod:`microphone_cli.cli._commands.cli`, ``.gain``, ``.array``, +``.param`` and ``.stream``). Descriptive verbs never hard-fail on a missing target path — an optional positional ``target`` is accepted and ignored (overview describes this agent, @@ -14,25 +16,78 @@ import argparse +from microphone_cli import activation from microphone_cli.cli._commands.whoami import report from microphone_cli.cli._output import emit_result -_ARTIFACTS = [ - "culture.yaml + AGENTS.colleague.md — mesh identity (suffix + backend)", - ".claude/skills/ — the canonical guildmaster skill kit (cite-don't-import)", - "docs/skill-sources.md — skill provenance ledger", - "pyproject.toml + .github/workflows/ — buildable, deployable package baseline", -] - +#: The registered surface, one line per verb family. Hand-maintained, and +#: ``tests/test_cli.py`` walks the live parser tree to prove every registered +#: path is represented here (a shorthand line may cover several leaf verbs). _VERBS = [ + "list — attached microphones: stable id, ALSA address, access state", + "inspect — capture formats, rates, channels, and array firmware identity", + "gain get|set — read or change capture gain (ALSA, plus firmware on arrays)", + "gain overview — describe the gain verb group (bare 'gain' does the same)", + "array doa — direction of arrival, reported as raw firmware radians", + "array aec get|set — echo-canceller state and the switches safe to flip", + "array aec overview — describe the aec verb group (bare 'array aec' does the same)", + "array overview — describe the array verb group (bare 'array' does the same)", + "param list — every XVF3800 firmware parameter row, with access and tier", + "param get|set [values] — read or write one raw firmware parameter", + "param overview — describe the param verb group (bare 'param' does the same)", + "stream audio — serve a live microphone attachment point (unbounded)", + "stream overview — describe the stream verb group (bare 'stream' does the same)", + "record — record a bounded clip to one file (caps always apply)", "whoami — identity probe (nick, version, backend, model)", "learn — structured self-teaching prompt", "explain — markdown docs for a topic", "overview — this descriptive snapshot", "doctor — check the agent-identity invariants", + "cli overview — describe the CLI surface itself (bare 'cli' does the same)", +] + +#: The single most important thing an agent needs before invoking anything here: +#: whether the invocation will open a microphone or write firmware. Readable from +#: the flags alone. +_HARDWARE = [ + "default (no flag) — dry run: resolves the device from filesystem reads, validates " + "the request, prints the plan. Opens nothing, issues no transfer, logs nothing.", + "--probe — checks the capture engine and the capture node's real access state " + "(stream audio, record). Still spawns nothing.", + "--apply — commits: opens the device and streams/records, or issues the ALSA and " + "firmware writes; written to the activation log.", + "read-only control transfers — inspect (firmware identity), array doa, array aec get, " + "param get and gain get open the USB device and READ from it with no --apply; they " + "change nothing but still need permission on the device node.", + "list — opens nothing beyond one non-blocking permission probe per capture node.", +] + +_CONTRACTS = [ + "identity is the stable id printed by `microphone list`, never a bare hw:N or card " + "index: ALSA card numbering is plug order and re-enumeration order, not identity", + "access is reported as distinct states — absent, forbidden and busy each name their " + "own fix — and map onto distinct exit codes (1 user, 2 environment, 3 busy/retryable)", + "every verb is a dry run by default; nothing opens a device or writes firmware " + "without --probe or --apply, and every --apply leaves one activation-log line", + "the persistent tier is gated twice: `param set` needs --allow-persistent as well as " + "--apply for a parameter that survives a power-cycle, reboots the device, or is " + "otherwise destructive, and the gate runs before the device is opened", + "firmware values are reported raw: `array doa` gives azimuth in the firmware's own " + "frame, in radians, with no degree conversion and no coordinate transform", + "zero runtime dependencies — the standard library only, so nothing here can drag a " + "native audio stack into a consumer's environment", ] +def _consent_items() -> list[str]: + return [ + f"activation log: {activation.log_path()} (override ${activation.ENV_LOG_PATH})", + "a recording writes only to the path you name — no hidden buffer, never to stdout", + "a hardware activity light CANNOT be promised: that is device firmware, outside " + "this tool's control. This tool records activations; it does not prevent covert use.", + ] + + def agent_sections() -> list[dict[str, object]]: """Sections describing the agent (used by the global verb).""" ident = report() @@ -47,23 +102,34 @@ def agent_sections() -> list[dict[str, object]]: ], }, {"title": "Verbs", "items": list(_VERBS)}, - {"title": "Sibling-pattern artifacts", "items": list(_ARTIFACTS)}, + {"title": "What touches the hardware", "items": list(_HARDWARE)}, + {"title": "Contracts", "items": list(_CONTRACTS)}, + {"title": "Consent", "items": _consent_items()}, ] def cli_sections() -> list[dict[str, object]]: - """Sections describing the CLI surface itself (used by `cli overview`).""" + """Sections describing the CLI surface itself (used by `cli overview`). + + ``_VERBS`` is the single source of truth for the registered surface (see + ``test_every_registered_path_appears_in_overview_verbs`` in + ``tests/test_cli.py``), so this reuses it verbatim rather than + re-declaring ``cli overview`` here too. + """ return [ { "title": "Verbs", - "items": list(_VERBS) + ["cli overview — describe the CLI surface (this command)"], + "items": list(_VERBS), }, { "title": "Conventions", "items": [ "every command supports --json", "results to stdout, errors/diagnostics to stderr (never mixed)", - "exit codes: 0 success, 1 user error, 2 environment error, 3+ reserved", + "exit codes: 0 success, 1 user error, 2 environment error, " + "3 device busy (retryable), 4+ reserved", + "writes and hardware activation are opt-in: --apply commits, --probe " + "checks the engine and access state, and the default is a dry run", ], }, ] @@ -90,7 +156,7 @@ def cmd_overview(args: argparse.Namespace) -> int: # `target` is accepted for rubric compatibility (descriptive verbs must not # hard-fail on a missing path) but overview describes this agent itself. emit_overview( - "microphone-cli", + "microphone", agent_sections(), json_mode=bool(getattr(args, "json", False)), ) @@ -100,7 +166,7 @@ def cmd_overview(args: argparse.Namespace) -> int: def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser( "overview", - help="Read-only descriptive snapshot of the agent (identity, verbs, artifacts).", + help="Read-only descriptive snapshot of the agent (identity, verbs, contracts, consent).", ) p.add_argument( "target", diff --git a/microphone_cli/cli/_commands/param.py b/microphone_cli/cli/_commands/param.py new file mode 100644 index 0000000..8f825d8 --- /dev/null +++ b/microphone_cli/cli/_commands/param.py @@ -0,0 +1,367 @@ +"""``microphone param`` — read/write raw XVF3800 firmware parameters. + +This is the low-level noun: every verb operates directly on one row of +:data:`microphone_cli.xvf3800.PARAMETERS`, addressed by name +(case-insensitive, always echoed upper-case). Validation — unknown name, +wrong access direction, wrong value count/type — happens *before* any USB +transfer is issued, both in dry-run (`param set` without ``--apply``) and in +apply mode, so a rejected command never touches hardware. + +A second gate sits in front of ``--apply``: names in +:data:`microphone_cli.xvf3800.PERSISTENT` either survive a power-cycle (after +``SAVE_CONFIGURATION``), trigger a reboot, or are otherwise destructive +(``TEST_CORE_BURN``, the ``SPECIAL_CMD_*`` firmware-update commands, ...). +Writing one of those requires ``--allow-persistent`` in addition to +``--apply`` — an ordinary ``rw`` write is volatile and reverts on the next +power-cycle, so that extra flag is the one place this noun asks for explicit +confirmation. The persistent check runs *before* the device is opened or any +transfer is issued. + +:func:`_open_array` is the hardware seam: it resolves a +:class:`~microphone_cli.devices.MicrophoneDevice` to a +:class:`~microphone_cli.xvf3800.Xvf3800` over a real USB node. Tests +monkeypatch it to return ``Xvf3800(fake_transfer)`` instead, so no test here +ever opens ``/dev``. +""" + +from __future__ import annotations + +import argparse +from typing import Any + +from microphone_cli.activation import activation_scope +from microphone_cli.cli._commands import JSON_FLAG_HELP +from microphone_cli.cli._commands.overview import emit_overview +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from microphone_cli.cli._output import emit_result +from microphone_cli.devices import MicrophoneDevice, resolve +from microphone_cli.usbctl import find_devices, open_device +from microphone_cli.xvf3800 import ParamInfo, Xvf3800, param_info, parameters_for + +_NUMERIC_FLOAT_TYPES = ("float", "radians") + +_NOT_ARRAY_HINT = ( + "select a microphone array (an XVF3800 device — `microphone list --json` and check " + "is_array) instead" +) + + +# --------------------------------------------------------------------------- +# overview +# --------------------------------------------------------------------------- + + +def _sections() -> list[dict[str, object]]: + return [ + { + "title": "Verbs", + "items": [ + "param list — every row of the XVF3800 parameter table", + "param get — read one parameter", + "param set [--apply] [--allow-persistent] — " + "write one parameter", + "param overview — this description", + ], + }, + { + "title": "Persistent tier (requires --allow-persistent on --apply)", + "items": [ + "SAVE_CONFIGURATION, CLEAR_CONFIGURATION, REBOOT, TEST_CORE_BURN, " + "TEST_AEC_DISABLE_CONTROL, USB_BIT_DEPTH and every SPECIAL_CMD_* name persist " + "across reboot, trigger a reboot, or are otherwise destructive — unlike an " + "ordinary rw write, which is volatile and reverts on power-cycle.", + ], + }, + ] + + +def cmd_param_overview(args: argparse.Namespace) -> None: + emit_overview( + "microphone param", + _sections(), + json_mode=bool(getattr(args, "json", False)), + ) + + +def _no_verb(args: argparse.Namespace) -> None: + # `microphone param` with no sub-verb prints the noun's overview. + cmd_param_overview(args) + + +# --------------------------------------------------------------------------- +# hardware seam +# --------------------------------------------------------------------------- + + +def _resolve_device(selector: str, root: str) -> MicrophoneDevice: + device = resolve(selector, root=root) + if not device.is_array: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{device.stable_id} is not an XVF3800 microphone array", + remediation=_NOT_ARRAY_HINT, + ) + return device + + +def _open_array(device: MicrophoneDevice, root: str) -> Xvf3800: + """Resolve ``device`` to an open :class:`Xvf3800`. Tests monkeypatch this.""" + matches = find_devices( + root=root, + vendor=device.usb_ids.vendor, + product=device.usb_ids.product, + serial=device.serial or None, + ) + if not matches: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"cannot locate a USB node for {device.stable_id}", + remediation="Replug the device and retry; check `microphone list --json`.", + ) + fd = open_device( + matches[0]["node"], vendor=matches[0].get("vendor"), product=matches[0].get("product") + ) + return Xvf3800(fd, vendor=matches[0].get("vendor") or device.usb_ids.vendor) + + +# --------------------------------------------------------------------------- +# value parsing +# --------------------------------------------------------------------------- + + +def _parse_values(info: ParamInfo, tokens: list[str]) -> Any: + """Convert CLI string tokens to typed values for ``info``, or raise. + + ``char`` parameters take exactly one string token, used verbatim. + Every other type takes exactly ``info.count`` tokens, converted to + ``float`` (``float``/``radians``) or ``int`` (``uint8``/``int32``/ + ``uint32``). Raises :class:`CliError` (exit 1) on a wrong token count or + a token that does not convert — always before any USB transfer. + """ + if info.type == "char": + if len(tokens) != 1: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} takes a single string value, got {len(tokens)}", + remediation=f"Pass exactly one string value (up to {info.count} characters).", + ) + return tokens[0] + + if len(tokens) != info.count: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} takes {info.count} value(s), got {len(tokens)}", + remediation=f"Pass exactly {info.count} value(s) of type {info.type}.", + ) + + converter = float if info.type in _NUMERIC_FLOAT_TYPES else int + try: + return [converter(token) for token in tokens] + except ValueError as exc: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} value(s) not valid for type {info.type}: {exc}", + remediation=f"Pass {info.count} value(s) that fit type {info.type}.", + ) from exc + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +def cmd_param_list(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + vendor = getattr(args, "vendor", None) + table = parameters_for(vendor) + infos = sorted((param_info(name, vendor) for name in table), key=lambda info: info.name) + payload = { + "params": [info.to_dict() for info in infos], + "count": len(infos), + "vendor": (vendor or "").lower() or None, + } + if json_mode: + emit_result(payload, json_mode=True) + return + lines = [ + f"{info.name} resid={info.resid} cmdid={info.cmdid} count={info.count} " + f"access={info.access} type={info.type} persistent={info.persistent}" + for info in infos + ] + lines.append(f"({len(infos)} parameters)") + emit_result("\n".join(lines), json_mode=False) + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + + +def cmd_param_get(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + root = getattr(args, "root", "/") or "/" + + # Resolve the device first: the parameter table depends on its firmware. + device = _resolve_device(args.device, root) + info = param_info(args.name, device.usb_ids.vendor) + if info.access == "wo": + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} is write-only and cannot be read", + remediation="Use `microphone param set` for write-only parameters.", + ) + + with _open_array(device, root) as xvf: + values = xvf.read(info.name) + + payload = {"device": device.stable_id, "param": info.to_dict(), "values": values} + if json_mode: + emit_result(payload, json_mode=True) + return + emit_result(f"{info.name} = {values}", json_mode=False) + + +# --------------------------------------------------------------------------- +# set +# --------------------------------------------------------------------------- + + +def cmd_param_set(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + root = getattr(args, "root", "/") or "/" + apply = bool(getattr(args, "apply", False)) + allow_persistent = bool(getattr(args, "allow_persistent", False)) + + # Resolve the device first: the parameter table depends on its firmware. + device = _resolve_device(args.device, root) + info = param_info(args.name, device.usb_ids.vendor) + if info.access == "ro": + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} is read-only and cannot be written", + remediation="Use `microphone param get` to read it.", + ) + values = _parse_values(info, list(args.values)) + + if not apply: + payload = { + "mode": "dry-run", + "applied": False, + "hardware_touched": False, + "device": device.stable_id, + "param": info.to_dict(), + "values": values, + } + if json_mode: + emit_result(payload, json_mode=True) + else: + emit_result( + f"dry-run: would write {info.name} = {values} on {device.stable_id} " + "(pass --apply to send it)", + json_mode=False, + ) + return + + if info.persistent and not allow_persistent: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} is a persistent/destructive parameter", + remediation=( + "Pass --allow-persistent to confirm. Most rw parameter writes are volatile " + "and revert on the next power-cycle; this one either survives a power-cycle " + "(e.g. after SAVE_CONFIGURATION), triggers a reboot, or is otherwise " + "destructive, so it needs an explicit opt-in." + ), + ) + + with _open_array(device, root) as xvf: + with activation_scope( + "param set", + device.stable_id, + {"param": info.name, "values": values, "persistent": info.persistent}, + ): + xvf.write(info.name, values) + payload = { + "mode": "apply", + "applied": True, + "hardware_touched": True, + "device": device.stable_id, + "param": info.to_dict(), + "values": values, + } + if info.access == "rw": + payload["readback"] = xvf.read(info.name) + + if json_mode: + emit_result(payload, json_mode=True) + else: + suffix = f" readback={payload['readback']}" if "readback" in payload else "" + emit_result( + f"applied: {info.name} = {values} on {device.stable_id}{suffix}", + json_mode=False, + ) + + +# --------------------------------------------------------------------------- +# registration +# --------------------------------------------------------------------------- + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "param", + help="Read/write raw XVF3800 firmware parameters (see 'microphone param overview').", + ) + p.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + p.set_defaults(func=_no_verb, json=False) + noun_sub = p.add_subparsers(dest="param_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the param noun (verbs, persistent tier).") + ov.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + ov.set_defaults(func=cmd_param_overview) + + lst = noun_sub.add_parser("list", help="List every XVF3800 parameter table row.") + lst.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + lst.add_argument( + "--vendor", + default=None, + help=( + "USB vendor id selecting the firmware table: 38fb (Reachy Mini Audio, the " + "default table) or 2886 (Seeed USB firmware)." + ), + ) + lst.set_defaults(func=cmd_param_list) + + get = noun_sub.add_parser("get", help="Read one parameter from a microphone array.") + get.add_argument("device", help="Stable id (or other selector) of the microphone array.") + get.add_argument("name", help="Parameter name (case-insensitive).") + get.add_argument( + "--root", + default="/", + help="Root filesystem to resolve the device under (tests point this at a fixture tree).", + ) + get.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + get.set_defaults(func=cmd_param_get) + + st = noun_sub.add_parser("set", help="Write one parameter on a microphone array.") + st.add_argument("device", help="Stable id (or other selector) of the microphone array.") + st.add_argument("name", help="Parameter name (case-insensitive).") + st.add_argument("values", nargs="+", help="Value(s) to write, parsed per the parameter type.") + st.add_argument( + "--apply", + action="store_true", + help="Actually send the write (default is a dry-run: validate only, no transfer).", + ) + st.add_argument( + "--allow-persistent", + action="store_true", + dest="allow_persistent", + help="Confirm a write to a persistent/destructive parameter (see 'param overview').", + ) + st.add_argument( + "--root", + default="/", + help="Root filesystem to resolve the device under (tests point this at a fixture tree).", + ) + st.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + st.set_defaults(func=cmd_param_set) diff --git a/microphone_cli/cli/_commands/record.py b/microphone_cli/cli/_commands/record.py new file mode 100644 index 0000000..54cc4e5 --- /dev/null +++ b/microphone_cli/cli/_commands/record.py @@ -0,0 +1,743 @@ +"""``microphone record`` — capture a bounded audio clip to a file. + +Cited (audio half only) from ``webcam-cli/webcam_cli/cli/_commands/record.py``: +the flag set (lines 1408-1529), the dry-run payload keys (1051-1090) — ``mode``, +``apply``, ``device``, ``kind``, ``capture_node``, ``audio_address``, +``audio_format``, ``pipeline_preview``, ``bound``, ``warmup_s``, +``warmup_basis``, ``output_path``, ``would_write``, ``access``, ``engine``, +``timestamps`` — and the apply payload's additions (1281-1306): +``bytes_written``, ``stopped_reason``, ``pipeline``. The video-only keys +(``video_format``, ``warmup_frames``) are dropped rather than emitted as +``null``, and ``kind`` is always ``"audio"`` here. + +**There is no flag that means "forever."** ``--duration`` defaults to +:data:`DEFAULT_DURATION_S` and is capped at :data:`MAX_DURATION_S`; +``--max-bytes`` defaults to :data:`DEFAULT_MAX_BYTES` and is capped at +:data:`MAX_BYTES_CEILING`. Both bounds are enforced twice over: the built argv +is self-limiting (``alsasrc num-buffers``, see +:func:`microphone_cli.engine.build_audio_record_argv`) *and* this module polls +the growing artifact and stops the child when either bound is reached. The +JSON says which one won, in ``stopped_reason``. Stopping the child means +stopping it — SIGTERM, then SIGKILL, waiting for each — and if the finished +artifact is nonetheless larger than ``--max-bytes`` (the cap is polled, so a +pipeline can blow it and exit inside one interval) that is a typed exit-2 +error naming the size and the cap, not a successful bounded recording. + +Hardware contact is the same three-level split as ``stream audio`` — nothing +(default), engine + access check (``--probe``), spawn (``--apply``) — and the +capture-node helper is shared with that module rather than re-derived. +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess # nosec B404 - the spawn seam; fixed argv, never a shell +import time +from dataclasses import dataclass +from datetime import datetime, timezone + +from microphone_cli import access, activation, devices, engine +from microphone_cli.cli._commands import JSON_FLAG_HELP +from microphone_cli.cli._commands.stream import ( + DEFAULT_CHANNELS, + DEFAULT_RATE, + DEFAULT_SAMPLE_FORMAT, + advertised_format, + capture_node_path, +) +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from microphone_cli.cli._output import emit_diagnostic, emit_result + +DEFAULT_DURATION_S = 30.0 +MAX_DURATION_S = 3600.0 +DEFAULT_MAX_BYTES = 256 * 1024 * 1024 +MAX_BYTES_CEILING = 4 * 1024 * 1024 * 1024 # 4 GiB + +#: How often the apply loop checks the child and the artifact's size. Small +#: enough that a max-bytes overshoot stays bounded, large enough not to spin. +POLL_INTERVAL_S = 0.25 +#: Slack added to --duration before this module stops the child itself. The +#: argv is already self-limiting, so the wall-clock timer is a backstop for a +#: pipeline that ignores its own bound, not the primary mechanism. +STOP_GRACE_S = 2.0 +#: How long to wait for a SIGTERM'd child to actually exit before escalating +#: to SIGKILL. A recording that outlives this verb keeps the capture PCM open, +#: so "warn and return" is not an option — the caller would be told the bound +#: stopped a recording that is in fact still running. +TERMINATE_TIMEOUT_S = 5.0 +#: How long to wait for a SIGKILL'd child. Still alive after this and the +#: recording is not stoppable from here: a typed environment error, never a +#: successful bounded recording. +KILL_TIMEOUT_S = 5.0 + + +#: Container per output extension. An unknown extension is a typed user error: +#: guessing a container for a caller would silently produce a file that is not +#: what its name claims. +_CONTAINERS = {".mka": "mka", ".wav": "wav"} + +_CONTAINER_ELEMENTS = { + "mka": ["audioconvert", "audioresample", "opusenc", "matroskamux"], + "wav": ["wavenc"], +} + +_WARMUP_BASIS = ( + "none — an ALSA capture device has no sensor that has to settle (unlike a UVC " + "camera's auto-exposure), so no lead-in audio is discarded and the whole recorded " + "window is kept" +) + +#: Keys every payload carries (dry-run and probe). ``--apply`` adds +#: :data:`APPLY_PAYLOAD_KEYS` on top. +PAYLOAD_KEYS = ( + "mode", + "apply", + "hardware_touched", + "engine_checked", + "device", + "kind", + "container", + "capture_node", + "audio_address", + "audio_format", + "pipeline_preview", + "pipeline_preview_str", + "bound", + "warmup_s", + "warmup_basis", + "output_path", + "would_write", + "access", + "engine", + "timestamps", +) + +APPLY_PAYLOAD_KEYS = ("bytes_written", "stopped_reason", "pipeline") + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _monotonic() -> float: + """Clock seam — patched in tests so the duration bound is deterministic.""" + return time.monotonic() + + +def _sleep(seconds: float) -> None: + """Sleep seam — patched in tests so the poll loop costs no wall-clock time.""" + time.sleep(seconds) + + +def _spawn(argv: list[str]) -> subprocess.Popen: + """Spawn ``argv`` — the single seam the ``--apply`` path goes through.""" + return subprocess.Popen( # nosec B603 - fixed argv built by engine.py, shell=False + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +# --------------------------------------------------------------------------- +# validation +# --------------------------------------------------------------------------- + + +def _user_error(message: str, remediation: str) -> CliError: + return CliError(code=EXIT_USER_ERROR, message=message, remediation=remediation) + + +def _validate_duration(duration: float) -> float: + if not 0 < duration <= MAX_DURATION_S: + raise _user_error( + f"invalid --duration {duration:g}: must satisfy 0 < duration <= {MAX_DURATION_S:g}", + "pass a duration inside the range; there is deliberately no value meaning " + "'record forever'", + ) + return float(duration) + + +def _validate_max_bytes(max_bytes: int) -> int: + if not 0 < max_bytes <= MAX_BYTES_CEILING: + raise _user_error( + f"invalid --max-bytes {max_bytes}: must satisfy 0 < max-bytes <= " + f"{MAX_BYTES_CEILING}", + "pass a size cap inside the range; the ceiling is 4 GiB", + ) + return int(max_bytes) + + +def _resolve_output(path: str, *, overwrite: bool) -> tuple[str, str]: + """Validate the output path and derive its container; returns ``(path, container)``. + + Three refusals, all exit 1 and all *before* any hardware decision: + an unknown extension (the container would have to be guessed), a missing + parent directory (this verb creates the file, never the directory tree), + and an existing file without ``--overwrite`` (a recording must not silently + destroy an earlier one). + """ + output_path = os.path.abspath(path) + extension = os.path.splitext(output_path)[1].lower() + container = _CONTAINERS.get(extension) + if container is None: + known = ", ".join(sorted(_CONTAINERS)) + raise _user_error( + f"cannot infer a container from output extension {extension or '(none)'!r}", + f"use one of the known extensions ({known}); the container is never guessed", + ) + + parent = os.path.dirname(output_path) or "." + if not os.path.isdir(parent): + raise _user_error( + f"output directory {parent} does not exist", + "create the directory first — record writes the file, not the tree above it", + ) + + if os.path.exists(output_path) and not overwrite: + raise _user_error( + f"output path {output_path} already exists", + "pass --overwrite to replace it, or choose another path", + ) + + return output_path, container + + +# --------------------------------------------------------------------------- +# payload +# --------------------------------------------------------------------------- + + +def _paper_access(node: str) -> dict[str, object]: + """Stat, never open — see ``stream``'s helper for why this is not a check.""" + return { + "path": node, + "kind": "audio", + "checked": False, + "state": "absent" if not os.path.exists(node) else "unchecked", + "holder": None, + "remediation": "", + "note": "dry run — the node was stat'ed, never opened; pass --probe to really check", + } + + +def _checked_access(node: str) -> dict[str, object]: + report = access.check_access(node, "audio") + holder = ( + {"pid": report.holder.pid, "command": report.holder.command} + if report.holder is not None + else None + ) + return { + "path": report.path, + "kind": report.kind, + "checked": True, + "state": report.state.value, + "holder": holder, + "remediation": report.remediation, + "note": "one non-blocking open(2), closed immediately; no audio was read", + } + + +def _engine_state(cap: engine.Capability | None) -> dict[str, object]: + if cap is None: + return { + "checked": False, + "available": None, + "gst_launch_present": None, + "note": "dry run — the engine was not detected; pass --probe or --apply to check", + } + return { + "checked": True, + "available": cap.available, + "gst_launch_present": cap.gst_launch is not None, + "note": "detected via gst-inspect-1.0, which opens no device", + } + + +@dataclass(frozen=True) +class _Plan: + """The validated request: what to write, in what shape, under what bounds. + + Built once in :func:`cmd_record` and passed whole, so the payload, the + activation params and the apply loop all read the same numbers instead of + threading six arguments each. + """ + + container: str + fmt: engine.AudioFormat + fmt_source: dict[str, str] + duration_s: float + max_bytes: int + output_path: str + + +def _payload( + *, + device: devices.MicrophoneDevice, + selector: str, + node: str, + plan: _Plan, + argv: list[str], + mode: str, + cap: engine.Capability | None, + access_state: dict[str, object], + resolved_at: str, +) -> dict[str, object]: + applied = mode == "apply" + fmt = plan.fmt + device_dict = device.as_dict() + device_dict["selector"] = selector + return { + "mode": mode, + "apply": applied, + "hardware_touched": applied, + "engine_checked": cap is not None, + "device": device_dict, + "kind": "audio", + "container": plan.container, + "capture_node": node, + "audio_address": device.alsa_address, + "audio_format": { + "requested": { + "rate": fmt.rate, + "channels": fmt.channels, + "sample_format": fmt.sample_format, + "source": plan.fmt_source or {}, + }, + "planned": { + "rate": fmt.rate, + "channels": fmt.channels, + "sample_format": fmt.sample_format, + }, + "probed": False, + "note": ( + "applied as an exact caps filter — an unsupported combination fails at " + "pipeline start rather than being silently substituted" + ), + }, + "pipeline_preview": list(argv), + "pipeline_preview_str": " ".join(shlex.quote(token) for token in argv), + "bound": { + "duration_s": plan.duration_s, + "max_bytes": plan.max_bytes, + "unbounded_is_impossible": True, + }, + "warmup_s": 0.0, + "warmup_basis": _WARMUP_BASIS, + "output_path": plan.output_path, + "would_write": [plan.output_path], + "access": access_state, + "engine": _engine_state(cap), + "timestamps": {"resolved_at": resolved_at}, + } + + +def _render_text(data: dict[str, object]) -> str: + fmt = data["audio_format"]["planned"] # type: ignore[index] + bound = data["bound"] + lines = [ + f"verb: record ({data['mode']})", + f"device: {data['device']['stable_id']}", # type: ignore[index] + f"source: {data['audio_address']} ({data['capture_node']})", + f"format: {fmt['sample_format']} {fmt['rate']} Hz {fmt['channels']} ch " + f"-> {data['container']}", + f"bound: {bound['duration_s']:g}s / {bound['max_bytes']} bytes", # type: ignore + f"output: {data['output_path']}", + f"access: {data['access']['state']}", # type: ignore[index] + f"pipeline: {data['pipeline_preview_str']}", + ] + if data["apply"]: + lines.append(f"written: {data['bytes_written']} bytes ({data['stopped_reason']})") + else: + lines.append("hardware: untouched — pass --apply to actually record") + return "\n".join(lines) + + +def _emit(data: dict[str, object], *, json_mode: bool) -> None: + emit_result(data if json_mode else _render_text(data), json_mode=json_mode) + + +# --------------------------------------------------------------------------- +# apply +# --------------------------------------------------------------------------- + + +def _artifact_size(path: str) -> int: + try: + return os.stat(path).st_size + except OSError: + return 0 + + +def _stop(proc: subprocess.Popen) -> None: + """Stop the child for real: SIGTERM, then SIGKILL, and wait for each. + + This function only returns once the child is known to be gone. A child + that ignores SIGTERM is escalated to SIGKILL (with a diagnostic saying so) + and waited for again; a child that survives *that* is a + :class:`CliError` (exit 2), because returning here would let + :func:`_run_bounded` report a bound as having stopped a recording that is + in fact still running and still holding the capture device. + """ + try: + proc.terminate() + except OSError as exc: + # Already reaped / already gone: nothing left to stop. + emit_diagnostic(f"warning: could not signal the recording pipeline: {exc}") + return + try: + proc.wait(timeout=TERMINATE_TIMEOUT_S) + return + except subprocess.TimeoutExpired: + emit_diagnostic( + f"warning: recording pipeline (pid {proc.pid}) did not exit within " + f"{TERMINATE_TIMEOUT_S:g}s of SIGTERM; escalating to SIGKILL" + ) + except OSError as exc: + emit_diagnostic(f"warning: could not wait for the recording pipeline: {exc}") + return + + try: + proc.kill() + proc.wait(timeout=KILL_TIMEOUT_S) + except subprocess.TimeoutExpired as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=( + f"recording pipeline (pid {proc.pid}) survived SIGTERM and SIGKILL; " + "the recording is still running and its bound was not enforced" + ), + remediation=( + f"the process is likely stuck in uninterruptible I/O on the capture " + f"device — inspect it with `ps -o stat= -p {proc.pid}`, then unplug or " + "reset the device if it stays in D state" + ), + ) from exc + except OSError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=( + f"could not kill the recording pipeline (pid {proc.pid}): {exc}; " + "the recording may still be running" + ), + remediation=f"check the process by hand (`ps -p {proc.pid}`) and stop it", + ) from exc + + +def _run_bounded( + proc: subprocess.Popen, output_path: str, duration_s: float, max_bytes: int +) -> str: + """Poll the child until a bound is hit or it exits; returns ``stopped_reason``. + + ``"eos"`` — the pipeline ended on its own (the argv's ``num-buffers`` bound + ran out, the normal path); ``"error"`` — it exited non-zero; + ``"duration"`` / ``"max_bytes"`` — this loop stopped it because the child + outlived its own bound or the artifact outgrew the size cap. + + Returning is a claim that the child is gone: :func:`_stop` waits for it and + raises rather than returning while it lives. The reason says *why* the + recording ended; whether the artifact honoured ``max_bytes`` is a separate + question, checked against the file itself in :func:`_apply` — the exit + check below can fire in the same poll interval in which the cap was blown. + """ + started = _monotonic() + deadline = duration_s + STOP_GRACE_S + while True: + code = proc.poll() + if code is not None: + return "eos" if code == 0 else "error" + if _artifact_size(output_path) >= max_bytes: + _stop(proc) + return "max_bytes" + if _monotonic() - started >= deadline: + _stop(proc) + return "duration" + _sleep(POLL_INTERVAL_S) + + +def _apply( + *, + device: devices.MicrophoneDevice, + node: str, + argv: list[str], + plan: _Plan, + params: dict[str, object], +) -> tuple[str, int, str, str]: + """Enforce access, spawn, bound, and record the activation. + + Returns ``(stopped_reason, bytes_written, started_at, ended_at)``. + """ + # Enforced before anything is spawned, so a busy (exit 3) or forbidden + # (exit 2) device is a typed error rather than a gst-launch crash. + access.require_access(node, "audio") + + output_path = plan.output_path + max_bytes = plan.max_bytes + + started_at = _now_iso() + with activation.activation_scope("record", device.stable_id, params) as act: + proc = _spawn(argv) + act.params["pid"] = proc.pid + stopped_reason = _run_bounded(proc, output_path, plan.duration_s, max_bytes) + size = _artifact_size(output_path) + act.params["stopped_reason"] = stopped_reason + act.params["bytes_written"] = size + + if stopped_reason == "error": + raise CliError( + code=EXIT_ENV_ERROR, + message=f"recording pipeline exited non-zero; wrote {size} bytes", + remediation="run the printed pipeline by hand to see gst-launch-1.0's own " + "diagnostics, or re-run with --probe to check the engine and device first", + ) + if size > max_bytes and stopped_reason != "max_bytes": + # The cap is polled. When *this loop* stopped the child at the cap, + # an overshoot of one poll interval is the bound doing its job and + # is reported as bytes_written. But a pipeline that blows the cap + # and exits on its own inside a single poll interval was never + # stopped by this module — the file on disk is over the cap the + # caller asked for, and saying "eos, all good" about it would be a + # lie. The artifact is deliberately kept: + # deleting a recording the caller may still want is not this verb's + # decision to make. + raise CliError( + code=EXIT_ENV_ERROR, + message=( + f"recording exceeded its size cap: wrote {size} bytes, " + f"--max-bytes is {max_bytes}" + ), + remediation=( + f"the file was kept at {output_path} so you can decide what to do with " + "it (inspect it, truncate it, delete it); re-run with a larger " + "--max-bytes, a shorter --duration, or a lower rate/channel count if " + "you want it to fit" + ), + ) + if size == 0: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"recording produced no bytes at {output_path}", + remediation="check that the requested rate/channels/format are supported by " + "this device (run the printed pipeline by hand to see why it produced nothing)", + ) + + return stopped_reason, size, started_at, _now_iso() + + +# --------------------------------------------------------------------------- +# handler +# --------------------------------------------------------------------------- + + +def cmd_record(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + root = getattr(args, "root", "/") or "/" + resolved_at = _now_iso() + + duration_s = _validate_duration(args.duration) + max_bytes = _validate_max_bytes(args.max_bytes) + output_path, container = _resolve_output(args.output, overwrite=bool(args.overwrite)) + + device = devices.resolve(args.device, root=root) + node = capture_node_path(device, root=root) + fmt, fmt_source = advertised_format( + root, device, rate=args.rate, channels=args.channels, sample_format=args.format + ) + argv = engine.build_audio_record_argv( + device.alsa_address, fmt, output_path, container=container, duration_s=duration_s + ) + + plan = _Plan( + container=container, + fmt=fmt, + fmt_source=fmt_source, + duration_s=duration_s, + max_bytes=max_bytes, + output_path=output_path, + ) + + apply_mode = bool(args.apply) + probe_mode = bool(args.probe) or apply_mode + + cap: engine.Capability | None = None + if probe_mode: + cap = engine.require_engine() + engine.require_elements(cap, _CONTAINER_ELEMENTS[container]) + + access_state = _checked_access(node) if probe_mode else _paper_access(node) + if apply_mode: + mode = "apply" + elif probe_mode: + mode = "probe" + else: + mode = "dry-run" + + data = _payload( + device=device, + selector=args.device, + node=node, + plan=plan, + argv=argv, + mode=mode, + cap=cap, + access_state=access_state, + resolved_at=resolved_at, + ) + + if not apply_mode: + _emit(data, json_mode=json_mode) + return + + params: dict[str, object] = { + "output_path": output_path, + "container": container, + "rate": fmt.rate, + "channels": fmt.channels, + "sample_format": fmt.sample_format, + "duration_s": duration_s, + "max_bytes": max_bytes, + "capture_node": node, + } + stopped_reason, size, started_at, ended_at = _apply( + device=device, + node=node, + argv=argv, + plan=plan, + params=params, + ) + + data["access"] = _checked_access(node) + data["bytes_written"] = size + data["stopped_reason"] = stopped_reason + data["pipeline"] = list(argv) + data["timestamps"] = { + "resolved_at": resolved_at, + "started_at": started_at, + "ended_at": ended_at, + } + _emit(data, json_mode=json_mode) + + +# --------------------------------------------------------------------------- +# registration +# --------------------------------------------------------------------------- + + +def _float_type(raw: str) -> float: + try: + return float(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid value {raw!r}: must be a number") from exc + + +def _int_type(raw: str) -> int: + try: + return int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid value {raw!r}: must be a whole number") from exc + + +def _positive_int(raw: str) -> int: + value = _int_type(raw) + if value <= 0: + raise argparse.ArgumentTypeError(f"invalid value {raw!r}: must be positive") + return value + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "record", + help="Record a bounded audio clip from a microphone to a file.", + description=( + "Record a bounded clip from a resolved microphone. Dry-run by default: " + "resolves the device and validates the request without opening anything. " + "Pass --apply to actually record. A duration and a size cap are always " + "enforced — there is no flag that means 'forever'." + ), + epilog=( + "Hardware: the default dry run touches nothing. --probe additionally detects " + "the GStreamer engine and checks the capture node, still without spawning. " + "--apply opens the device, records, and writes one activation-log line." + ), + ) + p.add_argument( + "device", + metavar="DEVICE", + help="Stable device id, or a unique substring of one (see 'microphone list').", + ) + p.add_argument( + "output", + metavar="OUTPUT_PATH", + help="File path to write the recording to. The container comes from the extension: " + ".mka (Opus in Matroska) or .wav (raw PCM).", + ) + p.add_argument( + "--duration", + type=_float_type, + default=DEFAULT_DURATION_S, + metavar="SECONDS", + help=f"Recording cap in seconds, 0 < duration <= {MAX_DURATION_S:g} " + f"(default {DEFAULT_DURATION_S:g}).", + ) + p.add_argument( + "--max-bytes", + type=_int_type, + default=DEFAULT_MAX_BYTES, + metavar="BYTES", + help=f"Output-size cap in bytes, on top of --duration, 0 < max-bytes <= " + f"{MAX_BYTES_CEILING} (default {DEFAULT_MAX_BYTES}).", + ) + p.add_argument( + "--rate", + type=_positive_int, + default=None, + metavar="HZ", + help=( + "Sample rate. Default: the first rate the device advertises in " + f"/proc/asound (else {DEFAULT_RATE}). Applied as an exact caps filter." + ), + ) + p.add_argument( + "--channels", + type=_positive_int, + default=None, + metavar="N", + help=f"Channel count. Default: the device's advertised count (else {DEFAULT_CHANNELS}).", + ) + p.add_argument( + "--format", + default=None, + metavar="FMT", + help=( + "Sample format, GStreamer spelling. Default: the device's advertised format " + f"(else {DEFAULT_SAMPLE_FORMAT})." + ), + ) + p.add_argument( + "--overwrite", + action="store_true", + help="Replace OUTPUT_PATH if it already exists (refused by default).", + ) + p.add_argument( + "--probe", + action="store_true", + help="Check the engine and the capture node's access state instead of describing " + "them on paper. Spawns nothing and writes no file.", + ) + p.add_argument( + "--apply", + action="store_true", + help="Actually record (implies --probe). Without it, record only resolves and " + "validates the request.", + ) + p.add_argument( + "--root", + default="/", + metavar="PATH", + help="Filesystem root to resolve the device under (default: /); mainly for " + "pointing at a synthetic device tree in tests.", + ) + p.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + p.set_defaults(func=cmd_record) diff --git a/microphone_cli/cli/_commands/stream.py b/microphone_cli/cli/_commands/stream.py new file mode 100644 index 0000000..ff88f08 --- /dev/null +++ b/microphone_cli/cli/_commands/stream.py @@ -0,0 +1,820 @@ +"""``microphone stream audio`` — serve a live microphone stream over RTP/UDP. + +Cited (audio half only) from ``webcam-cli/webcam_cli/cli/_commands/stream.py``: +the noun group + ``_no_verb`` overview shape (lines 1490-1672) and the +``_payload`` key set (lines 1130-1193) — ``verb``, ``medium``, ``mode``, +``applied``, ``probed``, ``hardware_touched``, ``engine_checked``, ``device``, +``source``, ``request``, ``negotiation``, ``attach``, ``warmup``, ``pipeline``, +``pipeline_str``, ``bounded``, ``lifetime``, ``exclusive_access``, ``access``, +``consent``, ``started_at``, ``pid``. The video-only keys of the source +(pixel format, warm-up *frames*) are dropped rather than faked: ALSA capture +has no sensor that has to settle, so ``warmup`` here reports zero and says why. + +Three levels of hardware contact, and nothing in between: + +* **no flag (default)** — a dry run. Resolves the device from ``/proc``/``/sys`` + (filesystem reads only), builds the exact argv that ``--apply`` would run and + prints it. No engine detection, no ``open()`` of a device node, no spawn: + ``hardware_touched`` and ``engine_checked`` are both ``false``. The reported + access state comes from a plain ``os.path.exists`` on the capture node, which + is a stat, not an open. +* **``--probe``** — additionally detects the GStreamer engine + (:func:`microphone_cli.engine.require_engine`, which shells out to + ``gst-inspect-1.0`` and opens no device) and reports + :func:`microphone_cli.access.check_access` on the capture node. Still spawns + nothing: ``engine_checked`` is ``true``, ``hardware_touched`` stays ``false``. +* **``--apply``** — requires the engine and the elements this encode choice + actually emits, *enforces* access (a busy device is the typed exit-3 error, + never a silent wait), then spawns ``gst-launch-1.0`` with its stderr captured, + waits :data:`STARTUP_SETTLE_S` and polls it. A child that is already gone is + a typed exit-2 error quoting the pipeline's own first complaint — a stream + that died at negotiation is never reported as live. A child that is still + running is written to the activation log **open-ended** + (``ended_at: null``): the process outlives this command, so claiming an end + time would be a lie. Streams are unbounded by design; the pid is returned so + the caller can stop it. + +The spawn goes through the module-level :func:`_spawn` seam, and the settle +wait through :func:`_sleep`, so tests can drive every path without a real +subprocess. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shlex +import subprocess # nosec B404 - the spawn seam; fixed argv, never a shell +import tempfile +import time +from datetime import datetime, timezone + +from microphone_cli import access, activation, devices, engine +from microphone_cli.cli._commands import JSON_FLAG_HELP +from microphone_cli.cli._commands.overview import emit_overview +from microphone_cli.cli._errors import EXIT_ENV_ERROR, CliError +from microphone_cli.cli._output import emit_result + +#: How long to let a freshly spawned pipeline settle before deciding it is +#: live. Long enough for gst-launch-1.0 to fail caps negotiation or fail to +#: open the device (both happen in milliseconds), short enough not to be felt. +STARTUP_SETTLE_S = 0.5 +#: How many lines of the child's stderr are quoted back in the remediation. +STARTUP_DIAGNOSTIC_LINES = 4 +#: Substrings that mark a GStreamer stderr line as worth quoting. +_DIAGNOSTIC_MARKERS = ("ERROR", "WARNING", "not-negotiated", "Could not", "failed") + +DEFAULT_PORT = 5000 +DEFAULT_HOST = "127.0.0.1" +DEFAULT_RATE = 48000 +DEFAULT_CHANNELS = 1 +DEFAULT_SAMPLE_FORMAT = "S16LE" + +# ALSA spelling (as /proc/asound/cardN/stream0 prints it) -> GStreamer spelling. +_ALSA_TO_GST_FORMAT = { + "S16_LE": "S16LE", + "S16_BE": "S16BE", + "S24_LE": "S24LE", + "S24_3LE": "S24LE", + "S32_LE": "S32LE", + "U8": "U8", + "FLOAT_LE": "F32LE", +} + + +def _resolved_rate(rate: int | None, advertised: list[int]) -> tuple[int, str]: + """``(rate, source)`` — an explicit value wins, else the first advertised one.""" + if rate is not None: + return rate, "explicit" + if advertised: + return advertised[0], "advertised" + return DEFAULT_RATE, "default" + + +def _resolved_channels(channels: int | None, advertised: int | None) -> tuple[int, str]: + """``(channels, source)`` — an explicit value wins, else what the device says.""" + if channels is not None: + return channels, "explicit" + if advertised: + return advertised, "advertised" + return DEFAULT_CHANNELS, "default" + + +def _resolved_sample_format(sample_format: str | None, advertised: list[str]) -> tuple[str, str]: + """``(sample_format, source)`` — GStreamer spelling of the first known ALSA format.""" + if sample_format is not None: + return sample_format, "explicit" + gst = _ALSA_TO_GST_FORMAT.get(advertised[0]) if advertised else None + if gst: + return gst, "advertised" + return DEFAULT_SAMPLE_FORMAT, "default" + + +def advertised_format( + root: str, + device: devices.MicrophoneDevice, + *, + rate: int | None, + channels: int | None, + sample_format: str | None, +) -> tuple[engine.AudioFormat, dict[str, str]]: + """Fill unset request fields from what the device advertises in ``stream0``. + + Found on hardware: a fixed 48 kHz mono default cannot open a device that + only offers 16 kHz stereo, and an exact caps filter never falls back. Each + field records where its value came from (``explicit`` / ``advertised`` / + ``default``) so the payload says what was assumed. + """ + from microphone_cli.cli._commands.inspect import _formats_rates_channels + + formats, rates, adv_channels = _formats_rates_channels(root, device) + + rate, rate_source = _resolved_rate(rate, rates) + channels, channels_source = _resolved_channels(channels, adv_channels) + sample_format, format_source = _resolved_sample_format(sample_format, formats) + source = { + "rate": rate_source, + "channels": channels_source, + "sample_format": format_source, + } + + return engine.AudioFormat(rate=rate, channels=channels, sample_format=sample_format), source + + +#: Every key :func:`_payload` emits. Exported so a test (and a reader) can +#: check the contract in one place instead of key-by-key. +PAYLOAD_KEYS = ( + "verb", + "medium", + "mode", + "applied", + "probed", + "hardware_touched", + "engine_checked", + "device", + "source", + "request", + "negotiation", + "attach", + "warmup", + "pipeline", + "pipeline_str", + "bounded", + "lifetime", + "exclusive_access", + "access", + "consent", + "started_at", + "pid", +) + +# A capture PCM directory under /proc/asound/cardN ("pcm0c"; playback is +# "pcm0p"). Recomputed here rather than imported from devices' private helper: +# this module only needs the number, and reaching into another module's +# underscore API would couple the two files' internals. +_CAPTURE_PCM_RE = re.compile(r"^pcm(?P\d+)c$") + +_WARMUP_NOTE = ( + "none — an ALSA capture device has no sensor that has to settle (unlike a UVC " + "camera's auto-exposure), so this verb discards no lead-in audio" +) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _spawn(argv: list[str], stderr: object = None) -> subprocess.Popen: + """Spawn ``argv`` — the single seam every ``--apply`` path goes through. + + Fixed argv, never a shell. Tests monkeypatch this attribute, which is why + it is a module-level function rather than an inline ``subprocess.Popen`` + call at the call site. + + ``stderr`` is a writable file object the child's stderr is redirected to + (``None`` means discard it). It is a real file rather than a pipe on + purpose: nobody drains this child, and a pipe whose buffer fills would + wedge the very pipeline it was meant to diagnose. + """ + return subprocess.Popen( # nosec B603 - fixed argv built by engine.py, shell=False + argv, + stdout=subprocess.DEVNULL, + stderr=stderr if stderr is not None else subprocess.DEVNULL, + ) + + +def _sleep(seconds: float) -> None: + """Sleep seam — patched in tests so the startup settle costs no wall clock.""" + time.sleep(seconds) + + +def _startup_diagnostic(text: str) -> list[str]: + """The first few meaningful lines of a dead pipeline's stderr. + + GStreamer's failures are already one-line and human-readable + (``ERROR: from element ...: Internal data stream error``, + ``streaming stopped, reason not-negotiated (-4)``), so the honest thing is + to hand them back verbatim. Marked lines come first; if nothing matches, + any non-empty lines are quoted rather than pretending there was no output. + """ + lines = [line.strip() for line in text.splitlines() if line.strip()] + marked = [line for line in lines if any(mark in line for mark in _DIAGNOSTIC_MARKERS)] + chosen = marked or lines + return chosen[:STARTUP_DIAGNOSTIC_LINES] + + +def capture_node_path(device: devices.MicrophoneDevice, root: str = "/") -> str: + """Return the ALSA capture node of ``device`` — ``/dev/snd/pcmCDc``. + + Joined under ``root`` so a fixture tree resolves inside itself: under a + synthetic root the node is simply absent, which is the honest answer and + keeps a test from ever naming a real node on the host. + + ``n`` is the card's lowest capture PCM (read from + ``/proc/asound/card/pcmc``), falling back to ``0`` when the + card exposes no such directory — the near-universal case for a USB + microphone, and a name that is at worst absent rather than wrong. + """ + card_dir = os.path.join(root or "/", "proc", "asound", f"card{device.card_index}") + try: + entries = sorted(os.listdir(card_dir)) + except OSError: + entries = [] + numbers = [ + int(matched.group("device")) + for matched in (_CAPTURE_PCM_RE.match(entry) for entry in entries) + if matched is not None + ] + pcm = min(numbers) if numbers else 0 + return os.path.join(root or "/", "dev", "snd", f"pcmC{device.card_index}D{pcm}c") + + +def _paper_access(node: str) -> dict[str, object]: + """Describe the capture node without opening it (dry-run level). + + ``os.path.exists`` is a stat, not an open: it cannot energize a device, it + cannot be refused for lack of ``audio``-group membership and it cannot + report ``busy``. So it answers only "is anything there", and the payload + says so with ``checked: false``. + """ + return { + "path": node, + "kind": "audio", + "checked": False, + "state": "absent" if not os.path.exists(node) else "unchecked", + "holder": None, + "remediation": "", + "note": "dry run — the node was stat'ed, never opened; pass --probe to really check", + } + + +def _checked_access(node: str) -> dict[str, object]: + report = access.check_access(node, "audio") + holder = ( + {"pid": report.holder.pid, "command": report.holder.command} + if report.holder is not None + else None + ) + return { + "path": report.path, + "kind": report.kind, + "checked": True, + "state": report.state.value, + "holder": holder, + "remediation": report.remediation, + "note": "one non-blocking open(2), closed immediately; no audio was read", + } + + +def _consumer(port: int, host: str, rate: int, channels: int) -> dict[str, str]: + """Ready-to-run receive pipelines for each wire codec. + + RTP L16 carries the stream's own clock rate and channel count in its caps + (``clock-rate``/``encoding-params``), so they follow the negotiated format + — found on hardware: a 16 kHz stereo array announced ``clock-rate=48000`` + and the blind consumer decoded it at the wrong speed. Opus is always + signalled at 48 kHz on the wire regardless of the capture rate. + """ + return { + "passthrough": ( + f"gst-launch-1.0 udpsrc address={host} port={port} " + f'"caps=application/x-rtp,media=audio,clock-rate={rate},' + f'encoding-name=L16,encoding-params={channels},channels={channels}" ' + "! rtpL16depay ! audioconvert ! autoaudiosink" + ), + "opus": ( + f"gst-launch-1.0 udpsrc address={host} port={port} " + '"caps=application/x-rtp,media=audio,clock-rate=48000,encoding-name=OPUS" ' + "! rtpopusdepay ! opusdec ! audioconvert ! autoaudiosink" + ), + } + + +def _attach(request: dict[str, object]) -> dict[str, object]: + host = str(request["host"]) + port = int(request["port"]) # type: ignore[arg-type] + return { + "mechanism": "gstreamer-udpsink", + "transport": "udp", + "host": host, + "port": port, + "uri": f"udp://{host}:{port}", + "encode": request["encode"], + "caps": ( + f"audio/x-raw,format={request['sample_format']}," + f"rate={request['rate']},channels={request['channels']}" + ), + "streamable": True, + "clients": ( + "UDP is unacknowledged and connectionless: anything bound to the port receives " + "packets from the moment it starts listening — there is no rewind and no " + "buffered history" + ), + "exposure": ( + f"packets go to {host}:{port} only. With the default loopback host no packet " + "leaves this machine; pass --host to send them elsewhere, which is a routable " + "address and is not authenticated by this tool" + ), + "consumer": _consumer(port, host, int(request["rate"]), int(request["channels"])), + } + + +def _payload( + *, + device: devices.MicrophoneDevice, + selector: str, + node: str, + request: dict[str, object], + argv: list[str], + mode: str, + engine_checked: bool, + access_state: dict[str, object], + started_at: str | None, + pid: int | None, +) -> dict[str, object]: + applied = mode == "apply" + probed = mode in ("probe", "apply") + device_dict = device.as_dict() + device_dict["selector"] = selector + return { + "verb": "stream audio", + "medium": "audio", + "mode": mode, + "applied": applied, + "probed": probed, + "hardware_touched": applied, + "engine_checked": engine_checked, + "device": device_dict, + "source": { + "alsa_address": device.alsa_address, + "capture_node": node, + "card_index": device.card_index, + }, + "request": request, + "negotiation": { + "requested": dict(request), + "planned": dict(request), + "probed": False, + "note": ( + "the requested rate/channels/format are applied as an exact caps filter — " + "an unsupported combination fails loudly at pipeline start rather than " + "being silently substituted" + ), + }, + "attach": _attach(request), + "warmup": {"seconds": 0.0, "basis": _WARMUP_NOTE}, + "pipeline": list(argv), + "pipeline_str": " ".join(shlex.quote(token) for token in argv), + "bounded": False, + "lifetime": ( + "unbounded — the stream runs until the spawned gst-launch-1.0 is stopped " + "(SIGINT/SIGTERM) or exits, outliving this command. There is no duration cap " + "by design; use `microphone record` for a bounded artifact. Its activation-log " + "line is therefore open-ended (ended_at: null) — nothing closes it, so a later " + "reader must not read it as a stream that has finished" + ), + "exclusive_access": ( + f"while this stream runs it holds {node} open — an ALSA capture PCM is " + "single-open, so another client gets the typed busy error (exit 3) naming " + "this process until the stream stops" + ), + "access": access_state, + "consent": { + "activation_log": str(activation.log_path()), + "logged": applied, + "log_line": ( + "written once, when the pipeline is confirmed running, with ended_at: null " + "and params.lifetime 'unbounded' — the line stays open because the stream " + "outlives this command; a startup failure instead logs a closed line " + "carrying params.error" + ), + "bytes_written": ( + "none — audio goes to the announced UDP attachment point only; no file, " + "no hidden buffer, and never to stdout" + ), + }, + "started_at": started_at, + "pid": pid, + } + + +def _render_text(data: dict[str, object]) -> str: + device = data["device"] + attach = data["attach"] + request = data["request"] + lines = [ + f"verb: stream audio ({data['mode']})", + f"device: {device['stable_id']}", # type: ignore[index] + f"source: {data['source']['alsa_address']} ({data['source']['capture_node']})", + f"format: {request['sample_format']} {request['rate']} Hz " # type: ignore[index] + f"{request['channels']} ch, encode={request['encode']}", # type: ignore[index] + f"attach: {attach['uri']}", # type: ignore[index] + f"access: {data['access']['state']}", + f"pipeline: {data['pipeline_str']}", + ] + if data["pid"] is not None: + lines.append(f"pid: {data['pid']}") + else: + lines.append("hardware: untouched — pass --apply to actually stream") + lines.append(f"consumer: {attach['consumer'][request['encode']]}") # type: ignore[index] + return "\n".join(lines) + + +def _required_elements(encode: str) -> list[str]: + """The optional elements the built argv will actually name, per encode choice.""" + if encode == "opus": + return ["audioconvert", "audioresample", "opusenc", "rtpopuspay", "udpsink"] + return ["audioconvert", "rtpL16pay", "udpsink"] + + +def _log_activation( + device_id: str, params: dict[str, object], started_at: str, *, ended_at: str | None +) -> None: + """Append one activation line. ``ended_at=None`` means "still running".""" + activation.record_activation( + activation.Activation( + verb="stream audio", + device=device_id, + params=dict(params), + started_at=started_at, + ended_at=ended_at, + ) + ) + + +def _read_child_stderr(handle) -> str: # type: ignore[no-untyped-def] + """Read back everything the child wrote to its captured stderr file.""" + try: + handle.seek(0) + raw = handle.read() + except OSError: # pragma: no cover - a closed/unseekable capture file + return "" + return raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw) + + +def _launch( + *, device_id: str, node: str, argv: list[str], request: dict[str, object] +) -> tuple[subprocess.Popen, str]: + """Spawn the pipeline, prove it is actually running, and log the activation. + + Two things this deliberately does *not* do. It does not treat a successful + ``Popen`` as a live stream: gst-launch-1.0 exits within milliseconds when + caps cannot be negotiated or the device cannot be opened, so the child is + given :data:`STARTUP_SETTLE_S` and then polled, and a child that is already + gone becomes a typed exit-2 error quoting its own stderr (never a + traceback). And it does not stamp ``ended_at`` on a stream that is still + running: the activation line for a live stream is written open-ended, so + the audit says "started, still open" rather than "started and finished". + + Returns ``(proc, started_at)``. + """ + params = dict(request) + params["capture_node"] = node + params["pipeline"] = list(argv) + started_at = _now_iso() + + # A real file, not a pipe: nothing drains this child, and a full pipe + # buffer would wedge the pipeline this capture exists to diagnose. + handle = tempfile.TemporaryFile(prefix="microphone-stream-", suffix=".stderr") + try: + try: + proc = _spawn(argv, stderr=handle) + except OSError as exc: + params["error"] = f"{type(exc).__name__}: {exc}" + _log_activation(device_id, params, started_at, ended_at=_now_iso()) + raise CliError( + code=EXIT_ENV_ERROR, + message=f"could not start the stream pipeline: {exc}", + remediation="check that gst-launch-1.0 is on PATH and executable " + "(`microphone stream audio --probe` reports the engine)", + ) from exc + + params["pid"] = proc.pid + _sleep(STARTUP_SETTLE_S) + code = proc.poll() + if code is not None: + detail = _startup_diagnostic(_read_child_stderr(handle)) + message = f"stream pipeline exited {code} during startup" + params["error"] = message + if detail: + params["pipeline_stderr"] = detail + _log_activation(device_id, params, started_at, ended_at=_now_iso()) + quoted = " | ".join(detail) + raise CliError( + code=EXIT_ENV_ERROR, + message=message, + remediation=( + f"the pipeline reported: {quoted}" + if detail + else "the pipeline wrote no diagnostics before exiting" + ) + + "; run the printed pipeline by hand to see the rest, or re-run with " + "--rate/--channels/--format matching what `microphone inspect` reports " + "this device advertises", + ) + + params["lifetime"] = "unbounded" + _log_activation(device_id, params, started_at, ended_at=None) + return proc, started_at + finally: + # Our copy only; the child keeps its own descriptor. + handle.close() + + +def cmd_stream_audio(args: argparse.Namespace) -> None: + json_mode = bool(getattr(args, "json", False)) + root = getattr(args, "root", "/") or "/" + + device = devices.resolve(args.device, root=root) + node = capture_node_path(device, root=root) + + fmt, fmt_source = advertised_format( + root, device, rate=args.rate, channels=args.channels, sample_format=args.format + ) + request: dict[str, object] = { + "rate": fmt.rate, + "channels": fmt.channels, + "sample_format": fmt.sample_format, + "format_source": fmt_source, + "encode": args.encode, + "host": args.host, + "port": args.port, + } + # Built before any hardware decision so the dry run prints exactly the argv + # --apply would run — and so an invalid rate/port/encode is a typed user + # error at every level, not only under --apply. + argv = engine.build_audio_stream_argv( + device.alsa_address, fmt, args.port, encode=args.encode, host=args.host + ) + + apply_mode = bool(args.apply) + probe_mode = bool(args.probe) or apply_mode + + if not probe_mode: + data = _payload( + device=device, + selector=args.device, + node=node, + request=request, + argv=argv, + mode="dry-run", + engine_checked=False, + access_state=_paper_access(node), + started_at=None, + pid=None, + ) + _emit(data, json_mode=json_mode) + return + + cap = engine.require_engine() + engine.require_elements(cap, _required_elements(args.encode)) + + if not apply_mode: + data = _payload( + device=device, + selector=args.device, + node=node, + request=request, + argv=argv, + mode="probe", + engine_checked=True, + access_state=_checked_access(node), + started_at=None, + pid=None, + ) + _emit(data, json_mode=json_mode) + return + + # --apply: enforce access before anything is spawned, so a busy or + # forbidden device is the typed error rather than a gst-launch crash. + access.require_access(node, "audio") + + proc, started_at = _launch(device_id=device.stable_id, node=node, argv=argv, request=request) + + data = _payload( + device=device, + selector=args.device, + node=node, + request=request, + argv=argv, + mode="apply", + engine_checked=True, + access_state=_checked_access(node), + started_at=started_at, + pid=proc.pid, + ) + _emit(data, json_mode=json_mode) + + +def _emit(data: dict[str, object], *, json_mode: bool) -> None: + emit_result(data if json_mode else _render_text(data), json_mode=json_mode) + + +# --- overview ---------------------------------------------------------------- + + +def stream_sections() -> list[dict[str, object]]: + return [ + { + "title": "Verbs", + "items": [ + "stream audio — serve a live microphone stream over RTP/UDP", + "stream overview — this description", + ], + }, + { + "title": "Hardware split", + "items": [ + "default: a dry run — resolves the device from /proc and /sys, prints the " + "exact gst-launch-1.0 argv, opens nothing, spawns nothing", + "--probe: also detects the GStreamer engine and checks the capture node's " + "access state; still spawns nothing", + "--apply: requires the engine, enforces access (busy is exit 3), spawns the " + f"pipeline, waits {STARTUP_SETTLE_S:g}s and checks it is still alive (a " + "pipeline that died at startup is exit 2 quoting its own stderr), then " + "writes one open-ended activation-log line (ended_at: null)", + ], + }, + { + "title": "Attachment", + "items": [ + f"udpsink to --host (default {DEFAULT_HOST}) on --port (default {DEFAULT_PORT})", + "--encode passthrough (RTP L16, raw PCM) or opus (needs the opusenc element)", + "the --json payload announces the uri, caps and a ready-to-run consumer " + "pipeline per codec — a second process needs nothing else", + ], + }, + { + "title": "Contracts", + "items": [ + f"defaults: --rate {DEFAULT_RATE}, --channels {DEFAULT_CHANNELS}, " + f"--format {DEFAULT_SAMPLE_FORMAT}", + "streams are unbounded: no duration flag; stop the returned pid " + "(use `microphone record` for a bounded artifact)", + "no warm-up: an ALSA capture device has no sensor settle time", + "exit codes: 1 user error, 2 engine/permission, 3 device busy", + ], + }, + ] + + +def cmd_stream_overview(args: argparse.Namespace) -> None: + emit_overview( + "microphone stream", + stream_sections(), + json_mode=bool(getattr(args, "json", False)), + ) + + +def _no_verb(args: argparse.Namespace) -> None: + cmd_stream_overview(args) + + +# --- registration ------------------------------------------------------------- + +_HARDWARE_EPILOG = ( + "Hardware: the default dry run touches nothing (it resolves the device and prints the " + "plan). --probe additionally detects the GStreamer engine and checks the capture node, " + "still without spawning. --apply opens the device and streams, and is written to the " + "activation log. Streams are unbounded: stop the returned pid." +) + + +def _positive_int(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid value {raw!r}: must be a whole number") from exc + if value <= 0: + raise argparse.ArgumentTypeError(f"invalid value {raw!r}: must be positive") + return value + + +def _port_type(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid --port {raw!r}: must be a whole number") from exc + return value + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "stream", + help="Serve a live microphone stream over RTP/UDP (dry-run by default).", + description=( + "Expose a live audio attachment point another process can consume. " + "Dry-run by default: nothing is opened until --apply." + ), + ) + p.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + p.set_defaults(func=_no_verb, json=False) + + # parser_class must propagate, or this noun's parse errors bypass the + # structured error contract and exit 2 instead of 1. + noun_sub = p.add_subparsers(dest="stream_command", parser_class=type(p)) + + ov = noun_sub.add_parser( + "overview", + help="Describe the stream verb group (verbs, hardware split, attachment).", + ) + ov.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + ov.set_defaults(func=cmd_stream_overview) + + audio = noun_sub.add_parser( + "audio", + help="Serve a live microphone stream via direct ALSA (dry-run by default).", + epilog=_HARDWARE_EPILOG, + ) + audio.add_argument( + "device", + help="Stable device id, or a unique substring of one (see 'microphone list'). " + "A bare hw:N is refused: ALSA card numbering is plug-order, not identity.", + ) + audio.add_argument("--json", action="store_true", help=JSON_FLAG_HELP) + audio.add_argument( + "--apply", + action="store_true", + help="Actually open the device and serve the stream (implies --probe).", + ) + audio.add_argument( + "--probe", + action="store_true", + help="Check the engine and the capture node's access state instead of describing " + "them on paper. Spawns nothing.", + ) + audio.add_argument( + "--port", + type=_port_type, + default=DEFAULT_PORT, + metavar="N", + help=f"UDP port of the attachment point (default {DEFAULT_PORT}).", + ) + audio.add_argument( + "--host", + default=DEFAULT_HOST, + metavar="ADDR", + help=f"UDP destination address (default {DEFAULT_HOST}, i.e. loopback only).", + ) + audio.add_argument( + "--rate", + type=_positive_int, + default=None, + metavar="HZ", + help=( + "Sample rate. Default: the first rate the device advertises in " + f"/proc/asound (else {DEFAULT_RATE}). Applied as an exact caps filter." + ), + ) + audio.add_argument( + "--channels", + type=_positive_int, + default=None, + metavar="N", + help=f"Channel count. Default: the device's advertised count (else {DEFAULT_CHANNELS}).", + ) + audio.add_argument( + "--format", + default=None, + metavar="FMT", + help=( + "Sample format, GStreamer spelling. Default: the device's advertised format " + f"(else {DEFAULT_SAMPLE_FORMAT})." + ), + ) + audio.add_argument( + "--encode", + choices=("passthrough", "opus"), + default="passthrough", + help="Wire codec. 'passthrough' (default) pays raw PCM as RTP L16; 'opus' " + "encodes first (requires the opusenc element).", + ) + audio.add_argument( + "--root", + default="/", + metavar="PATH", + help="Filesystem root to resolve the device under (default: /); mainly for " + "pointing at a synthetic device tree in tests.", + ) + audio.set_defaults(func=cmd_stream_audio) diff --git a/microphone_cli/cli/_commands/whoami.py b/microphone_cli/cli/_commands/whoami.py index b6c00bb..5425f70 100644 --- a/microphone_cli/cli/_commands/whoami.py +++ b/microphone_cli/cli/_commands/whoami.py @@ -1,11 +1,12 @@ -"""``microphone-cli whoami`` — the smallest identity probe. +"""``microphone whoami`` — the smallest identity probe. Reports the agent's identity as declared in ``culture.yaml``: its nick (``suffix``), the backend it runs on, and the served model (if any) — plus the package version. Read-only; touches nothing but its own ``culture.yaml``. -When you clone this template, rename the package and update ``culture.yaml`` — -``whoami`` then reflects your new agent's identity with no code change. +The ``culture.yaml`` it reads is the agent's own — found by walking up from this +module, not from the caller's working directory — so identity is this CLI's, never +whatever repo happens to be the CWD. """ from __future__ import annotations diff --git a/microphone_cli/cli/_errors.py b/microphone_cli/cli/_errors.py index bf97c29..485c048 100644 --- a/microphone_cli/cli/_errors.py +++ b/microphone_cli/cli/_errors.py @@ -17,10 +17,12 @@ # 0 = success # 1 = user-input error (bad flag, missing required arg, unknown path) # 2 = environment / setup error (tool not installed, file unreadable) -# 3+ = reserved for future categorisation +# 3 = device busy (another process holds it open; retryable) +# 4+ = reserved for future categorisation EXIT_SUCCESS = 0 EXIT_USER_ERROR = 1 EXIT_ENV_ERROR = 2 +EXIT_BUSY_ERROR = 3 @dataclass diff --git a/microphone_cli/devices.py b/microphone_cli/devices.py new file mode 100644 index 0000000..778651a --- /dev/null +++ b/microphone_cli/devices.py @@ -0,0 +1,530 @@ +"""Device identity core — *which capture microphones exist, and how to name one*. + +This module answers exactly one question and stops: which USB audio capture +devices are attached, and what is the stable name of each. It never opens a +device, never checks permissions, never sets a gain and never reads DOA — those +are separate concerns owned elsewhere. Everything here is filesystem parsing of +``/proc/asound`` and ``/sys``, so it is safe to call from any context, +including a dry run. + +Cited from ``webcam_cli/devices.py`` (lines 105-159, 286-387 and 470-531 of the +reference implementation in the sibling ``webcam-cli`` repo) with the video +half dropped: the ``/dev/v4l/by-id`` scan, the video/audio pairing and the +``LogicalDevice`` grouping have no counterpart here, while the sysfs USB-parent +walk, the ``/proc/asound/cards`` parse, the udev-style stable-id synthesis and +the selector-refusal policy carry over almost verbatim. + +Four facts about Linux USB audio shape the design: + +1. **ALSA card numbers are plug-order, not identity.** ``hw:1`` is a different + microphone after a replug. Nothing here is keyed on the index: + :attr:`MicrophoneDevice.card_index` is reported because callers see it in + ``arecord -l``, but it is explicitly ephemeral. Persist + :attr:`MicrophoneDevice.stable_id`; address ALSA through + :attr:`MicrophoneDevice.alsa_address` (``hw:CARD=``), which is keyed on + the card's name and survives renumbering. + +2. **Identity lives in the USB descriptors.** Each ALSA card's sysfs node hangs + off a USB *interface* directory (``5-1.1:1.0``) below a USB *device* + directory (``5-1.1``); the device directory carries ``idVendor``, + ``idProduct``, ``manufacturer``, ``product`` and ``serial``. The stable id + is rebuilt from those with udev's own escaping rules, so it matches what + ``/dev/snd/by-id`` would publish. + +3. **Two identical arrays are still two devices.** The reference host can carry + more than one ``38fb:1001``; they differ only by serial. Selection therefore + refuses an ambiguous selector rather than silently picking the first match. + +4. **USB only.** A capture card with no USB parent in sysfs (an analog HDA + line-in, a PCIe capture card) is skipped: it has no USB descriptors to + derive a stable identity from. "Not listed" is not the same as "not + present". + +All reads are taken relative to ``root``, which exists so tests can point at a +synthetic tree instead of the host. ALSA addresses in the returned values are +always reported as ALSA names them and are never prefixed with ``root``. +""" + +from __future__ import annotations + +import glob +import os +import re +from dataclasses import dataclass + +from microphone_cli.cli._errors import EXIT_USER_ERROR, CliError + +_SYS_DIR = "sys" +_SOUND_CLASS_DIR = "sys/class/sound" +_ASOUND_DIR = "proc/asound" +_RUNTIME_GLOB = "run/user/*/pipewire-0" + +_LIST_HINT = "run `microphone list --json` to see the stable id of every attached microphone" + +# XVF3800-based microphone arrays, keyed by (idVendor, idProduct). +_ARRAY_USB_IDS = frozenset({("38fb", "1001"), ("2886", "001a")}) + +# A sysfs USB *device* directory: "3-1", "5-1.3". Interfaces ("3-1:1.0"), +# root hubs ("usb3") and platform nodes ("NVDA8000:01") deliberately do not match. +_USB_DEVICE_RE = re.compile(r"^\d+-\d+(?:\.\d+)*$") +# " 1 [Audio ]: USB-Audio - Reachy Mini Audio" +# Every quantifier is possessive so the match is single-pass with no +# backtracking budget to exhaust; ALSA card ids never contain whitespace. +_CARDS_LINE_RE = re.compile( + r"^\s*+(?P\d++)\s*+\[(?P[^\]\s]*+)\s*+\]\s*+:\s*+(?P.*+)$" +) +# A capture PCM directory under /proc/asound/cardN: "pcm0c" (playback is "pcm0p"). +_CAPTURE_PCM_RE = re.compile(r"^pcm(?P\d+)c$") +# udev's device-name allowlist; everything else becomes "_". +_UDEV_UNSAFE_RE = re.compile(r"[^A-Za-z0-9#+\-.:=@_]") +# A raw ALSA card selector, which this module refuses on purpose: +# "1", "hw:1", "hw:1,0", "plughw:1". +_RAW_CARD_RE = re.compile(r"^(?:(?:plug)?hw:)?(?P\d+)(?:,\d+)?$") +# The stable form of an ALSA address, which is accepted: "hw:CARD=Audio,DEV=0". +_CARD_NAME_RE = re.compile(r"^(?:plug)?hw:CARD=(?P[^,]+)(?:,.*)?$", re.IGNORECASE) +# " Channels: 6" inside the "Capture:" section of /proc/asound/cardN/stream0. +_CHANNELS_RE = re.compile(r"^\s*Channels:\s*(?P\d+)\s*$") +_SECTION_RE = re.compile(r"^(?P
Playback|Capture):\s*$") +# "channels: 6" in an open PCM's hw_params (only present while a client holds it). +_HW_PARAMS_CHANNELS_RE = re.compile(r"^channels:\s*(?P\d+)\s*$") + +_USB_ATTRS = ("idVendor", "idProduct", "serial", "manufacturer", "product") + + +def is_array_ids(vendor: str | None, product: str | None) -> bool: + """Whether a ``(idVendor, idProduct)`` pair identifies an XVF3800 array.""" + if not vendor or not product: + return False + return (vendor.lower(), product.lower()) in _ARRAY_USB_IDS + + +@dataclass(frozen=True) +class UsbIds: + """The USB vendor and product ids of a device, as sysfs spells them.""" + + vendor: str | None + product: str | None + + def as_dict(self) -> dict[str, object]: + return {"vendor": self.vendor, "product": self.product} + + def __str__(self) -> str: + return f"{self.vendor or '????'}:{self.product or '????'}" + + +@dataclass(frozen=True) +class MicrophoneDevice: + """One USB audio capture device. + + ``card_index`` is the current ALSA card number. It is **ephemeral** — it + changes when devices are replugged and must never be persisted as identity. + Persist ``stable_id``; address ALSA through ``alsa_address``. + """ + + stable_id: str + label: str + alsa_address: str + card_id: str + card_index: int + usb_path: str + usb_ids: UsbIds + serial: str | None + is_array: bool + channels: int | None + pipewire_visible: bool | None + + def as_dict(self) -> dict[str, object]: + return { + "stable_id": self.stable_id, + "label": self.label, + "alsa_address": self.alsa_address, + "card_id": self.card_id, + "card_index": self.card_index, + "usb_path": self.usb_path, + "usb_ids": self.usb_ids.as_dict(), + "serial": self.serial, + "is_array": self.is_array, + "channels": self.channels, + "pipewire_visible": self.pipewire_visible, + } + + +# --------------------------------------------------------------------------- +# filesystem helpers (all root-relative, all failure-tolerant) +# --------------------------------------------------------------------------- + + +def _under(root: str, *parts: str) -> str: + return os.path.join(root or "/", *parts) + + +def _listdir(path: str) -> list[str]: + try: + return sorted(os.listdir(path)) + except OSError: + return [] + + +def _read_text(path: str) -> str | None: + try: + with open(path, encoding="utf-8", errors="replace") as handle: + return handle.read() + except OSError: + return None + + +def _read_attr(path: str) -> str | None: + text = _read_text(path) + return text.strip() if text is not None else None + + +def _follow(link: str) -> str | None: + """Resolve one sysfs symlink *lexically*, relative to its own directory. + + Lexical rather than ``realpath`` so a fixture tree resolves inside itself + instead of escaping to the host's real ``/sys``. + """ + try: + target = os.readlink(link) + except OSError: + return None + if os.path.isabs(target): + return os.path.normpath(target) + return os.path.normpath(os.path.join(os.path.dirname(link), target)) + + +def _usb_device_dir(sysfs_path: str, sys_root: str) -> str | None: + """Walk up a resolved sysfs path to the nearest USB *device* directory. + + ``.../usb5/5-1/5-1.1/5-1.1:1.0/sound/card1`` lands on ``.../usb5/5-1/5-1.1`` + — the directory holding the USB descriptors identity is built from. Only + components below ``sys_root`` are considered, so a fixture living at a path + that happens to look like a USB address cannot confuse the walk. + """ + relative = os.path.relpath(sysfs_path, sys_root) + if relative.startswith(os.pardir): + return None + parts = relative.split(os.sep) + for cut in range(len(parts), 0, -1): + if _USB_DEVICE_RE.match(parts[cut - 1]): + return os.path.join(sys_root, *parts[:cut]) + return None + + +def _usb_attrs(usb_dir: str | None) -> dict[str, str]: + if usb_dir is None: + return {} + attrs: dict[str, str] = {} + for key in _USB_ATTRS: + value = _read_attr(os.path.join(usb_dir, key)) + if value: + attrs[key] = value + return attrs + + +def _udev_safe(text: str) -> str: + return _UDEV_UNSAFE_RE.sub("_", text) + + +def _synthesise_stable_id(attrs: dict[str, str], usb_path: str) -> str: + """Rebuild udev's ``usb-__`` id from USB descriptors. + + Follows udev's own rules: vendor string, else vendor id; product string, + else product id; then the serial, with unsafe characters replaced by ``_``. + + A device whose firmware ships no serial descriptor cannot be told apart + from its twin by descriptors alone, so the id falls back to its sysfs USB + device path (``usb-path-3-1``). That id is *stable while the device stays + in that port* and changes if it is moved — which is the honest thing to + report, since there is nothing better to key on. + """ + serial = attrs.get("serial") + if not serial: + return f"usb-path-{usb_path}" if usb_path else "usb-path-unknown" + vendor = attrs.get("manufacturer") or attrs.get("idVendor") + model = attrs.get("product") or attrs.get("idProduct") + parts = [part for part in (vendor, model, serial) if part] + return "usb-" + "_".join(_udev_safe(part) for part in parts) + + +# --------------------------------------------------------------------------- +# channel count +# --------------------------------------------------------------------------- + + +def _channels_from_stream(root: str, index: int) -> int | None: + """Capture channel count from ``/proc/asound/cardN/stream0``. + + The USB-audio driver publishes one block per direction; only the + ``Capture:`` block matters. The largest ``Channels:`` value inside it is + used, because a device with several altsets lists one line per altset and + the widest is the one that carries every microphone of an array. + """ + text = _read_text(_under(root, _ASOUND_DIR, f"card{index}", "stream0")) + if text is None: + return None + section: str | None = None + found: list[int] = [] + for line in text.splitlines(): + matched_section = _SECTION_RE.match(line.strip()) + if matched_section is not None: + section = matched_section.group("section") + continue + matched = _CHANNELS_RE.match(line) + if matched is not None and section == "Capture": + found.append(int(matched.group("channels"))) + return max(found) if found else None + + +def _channels_from_hw_params(root: str, index: int, device: int) -> int | None: + """Capture channel count from an open PCM's ``hw_params``, if one is open. + + ``hw_params`` reads ``closed`` while nothing holds the device, so this is a + fallback that usually yields ``None`` — it only helps on a card whose + driver publishes no ``stream0`` and that some other client happens to have + open. + """ + card_dir = _under(root, _ASOUND_DIR, f"card{index}", f"pcm{device}c") + for sub in _listdir(card_dir): + if not sub.startswith("sub"): + continue + text = _read_text(os.path.join(card_dir, sub, "hw_params")) + for line in (text or "").splitlines(): + matched = _HW_PARAMS_CHANNELS_RE.match(line.strip()) + if matched is not None: + return int(matched.group("channels")) + return None + + +# --------------------------------------------------------------------------- +# PipeWire visibility heuristic +# --------------------------------------------------------------------------- + + +def _pipewire_evidence(root: str) -> bool: + """Whether a PipeWire runtime socket exists anywhere under ``root``. + + Checking for the socket rather than talking to it keeps this module free of + runtime dependencies and safe in a dry run: the socket is never connected + to, only stat'ed. + """ + return bool(glob.glob(_under(root, _RUNTIME_GLOB))) + + +def _pipewire_visible(has_pipewire: bool, capture_device: int | None) -> bool | None: + """Heuristic answer to "would PipeWire enumerate this card as a source?". + + * ``None`` — no PipeWire runtime socket was found under ``root``, so the + question is undeterminable from the filesystem alone. This is the honest + answer on a host running bare ALSA or JACK, and the one a fixture tree + without a socket gets. + * ``True`` — a PipeWire socket exists and the card exposes a capture PCM, + which is the condition PipeWire's ALSA monitor uses to publish a source. + * ``False`` — a PipeWire socket exists but the card exposes no capture PCM. + + **Known limitation**: PipeWire also skips cards udev has tagged + ``ACP_IGNORE``, and that tag lives in the udev database rather than in + sysfs, so it cannot be read here. Because :func:`enumerate_devices` already + drops cards with no capture PCM, ``False`` is unreachable through + enumeration today — in practice this field is ``True`` or ``None``. Treat + ``True`` as "nothing in procfs says otherwise", not as a confirmed graph + node; only PipeWire itself can confirm that. + """ + if not has_pipewire: + return None + return capture_device is not None + + +# --------------------------------------------------------------------------- +# scanning +# --------------------------------------------------------------------------- + + +def _capture_pcm_device(root: str, index: int) -> int | None: + """Lowest capture PCM device number of an ALSA card, or ``None`` if it has none. + + A USB card with only ``pcmNp`` entries is a speaker, not a microphone, and + has no business in a microphone listing. + """ + devices = [ + int(matched.group("device")) + for matched in ( + _CAPTURE_PCM_RE.match(entry) + for entry in _listdir(_under(root, _ASOUND_DIR, f"card{index}")) + ) + if matched is not None + ] + return min(devices) if devices else None + + +def _card_name(rest: str) -> str: + """The human name from a ``/proc/asound/cards`` line's tail. + + ``USB-Audio - Reachy Mini Audio`` becomes ``Reachy Mini Audio``; a line with + no driver prefix is returned whole. + """ + _, separator, tail = rest.partition(" - ") + return (tail if separator else rest).strip() + + +def _build(root: str, index: int, card_id: str, name: str) -> MicrophoneDevice | None: + """Assemble one device, or ``None`` if the card is not a USB microphone.""" + capture_device = _capture_pcm_device(root, index) + if capture_device is None: + return None # playback-only card + + sys_root = _under(root, _SYS_DIR) + card_dir = _follow(_under(root, _SOUND_CLASS_DIR, f"card{index}")) + usb_dir = _usb_device_dir(card_dir, sys_root) if card_dir else None + if usb_dir is None: + return None # not USB: no descriptors, so no stable identity + + attrs = _usb_attrs(usb_dir) + usb_path = os.path.basename(usb_dir) + stable_id = _synthesise_stable_id(attrs, usb_path) + channels = _channels_from_stream(root, index) or _channels_from_hw_params( + root, index, capture_device + ) + return MicrophoneDevice( + stable_id=stable_id, + label=attrs.get("product") or name or stable_id, + # Keyed on the card *name*, not the index: the index is plug-order. + alsa_address=f"hw:CARD={card_id}", + card_id=card_id, + card_index=index, + usb_path=usb_path, + usb_ids=UsbIds(vendor=attrs.get("idVendor"), product=attrs.get("idProduct")), + serial=attrs.get("serial"), + is_array=is_array_ids(attrs.get("idVendor"), attrs.get("idProduct")), + channels=channels, + pipewire_visible=_pipewire_visible(_pipewire_evidence(root), capture_device), + ) + + +def enumerate_devices(root: str = "/") -> tuple[MicrophoneDevice, ...]: + """Return every USB audio capture device attached under ``root``. + + Playback-only cards and non-USB cards are skipped (see the module + docstring). The result is sorted by ``stable_id`` and is a pure function of + the filesystem: no device is opened and no ALSA library is loaded. + """ + text = _read_text(_under(root, _ASOUND_DIR, "cards")) + if text is None: + return () + + devices: list[MicrophoneDevice] = [] + for line in text.splitlines(): + matched = _CARDS_LINE_RE.match(line) + if matched is None: + continue + device = _build( + root, + int(matched.group("index")), + matched.group("id"), + _card_name(matched.group("rest")), + ) + if device is not None: + devices.append(device) + devices.sort(key=lambda device: (device.stable_id, device.card_index)) + return tuple(devices) + + +# --------------------------------------------------------------------------- +# resolution +# --------------------------------------------------------------------------- + + +def _user_error(message: str, remediation: str) -> CliError: + return CliError(code=EXIT_USER_ERROR, message=message, remediation=remediation) + + +def _raw_card_error(selector: str, index: int, devices: tuple[MicrophoneDevice, ...]) -> CliError: + """Refuse ``hw:N`` / ``N``, but name the stable id that should be used instead.""" + owner = next((device for device in devices if device.card_index == index), None) + if owner is None: + remediation = ( + f"no attached microphone is ALSA card {index}; {_LIST_HINT} " + "and pass a stable id (they start with `usb-`)" + ) + else: + remediation = ( + f"ALSA card {index} is plug-order, not identity, and moves between replugs — " + f"select {owner.stable_id!r} (or {owner.alsa_address!r}) instead; {_LIST_HINT}" + ) + return _user_error( + f"selector {selector!r} is an ALSA card number, not a stable microphone id", + remediation, + ) + + +def _normalise(selector: str) -> str: + """Reduce an ALSA address to the bare card id; leave anything else alone.""" + matched = _CARD_NAME_RE.match(selector) + return matched.group("id") if matched else selector + + +def _exact_matches(candidate: str, devices: tuple[MicrophoneDevice, ...]) -> list[MicrophoneDevice]: + """Devices whose stable id, card id or serial equals ``candidate`` exactly.""" + return [ + device + for device in devices + if candidate + in ( + device.stable_id.casefold(), + device.card_id.casefold(), + (device.serial or "").casefold(), + ) + ] + + +def _select(selector: str, devices: tuple[MicrophoneDevice, ...]) -> MicrophoneDevice: + raw = selector.strip() + if not raw: + raise _user_error("empty microphone selector", f"pass a stable id; {_LIST_HINT}") + + raw_card = _RAW_CARD_RE.match(raw) + if raw_card is not None: + raise _raw_card_error(raw, int(raw_card.group("index")), devices) + + candidate = _normalise(raw).casefold() + exact = _exact_matches(candidate, devices) + if len(exact) == 1: + return exact[0] + + matches = exact or [ + device + for device in devices + if candidate in device.stable_id.casefold() or candidate in device.label.casefold() + ] + if len(matches) == 1: + return matches[0] + if not matches: + raise _user_error( + f"no attached microphone matches selector {selector!r}", + f"{_LIST_HINT}, then pass one of them or a unique substring of one", + ) + named = ", ".join(device.stable_id for device in matches) + raise _user_error( + f"selector {selector!r} is ambiguous — it matches {len(matches)} microphones: {named}", + f"pass a full stable id or a longer, unique substring; {_LIST_HINT}", + ) + + +def resolve(selector: str, root: str = "/") -> MicrophoneDevice: + """Resolve a selector to exactly one microphone. + + ``selector`` may be a full ``stable_id``, an ALSA ``card_id``, a USB + ``serial``, an ``hw:CARD=`` address, or a unique case-insensitive + substring of a stable id or a label. + + A raw ``hw:N`` / ``plughw:N`` / bare integer is refused on purpose: card + numbering is plug-order, so accepting it would hand callers a selector that + silently means a different microphone after a replug. The refusal names the + stable id to use instead. + + Raises :class:`~microphone_cli.cli._errors.CliError` with + ``code=EXIT_USER_ERROR`` (exit status 1) when nothing matches, when the + selector is a raw card number, or when more than one microphone matches — + in which case the message lists every candidate's stable id. + """ + return _select(selector, enumerate_devices(root=root)) diff --git a/microphone_cli/engine.py b/microphone_cli/engine.py new file mode 100644 index 0000000..d0d94d7 --- /dev/null +++ b/microphone_cli/engine.py @@ -0,0 +1,427 @@ +"""GStreamer audio engine adapter: capability detection and pipeline construction. + +This module owns *what the capture stack can do and how to invoke it* for +microphone-cli's audio surface. It does not own device enumeration/pairing or +CLI verbs — those are sibling concerns built on top of this one. + +Zero runtime dependencies: everything shells out to the ``gst-launch-1.0`` / +``gst-inspect-1.0`` binaries via :mod:`subprocess`. No PyGObject/``gi`` +import, ever — that would end the zero-runtime-dependency posture +``pyproject.toml``'s ``dependencies = []`` depends on. + +Cited (audio subset only) from ``webcam-cli/webcam_cli/engine.py``: +``Capability``/detection scaffolding (module docstring + element lists, +lines 65-87), the audio pipeline builder shape (``build_audio_pipeline``, +lines 1044-1082), and ``require_engine`` (lines 351-369). Adapted for +microphone-cli's own element set (RTP/UDP streaming and Matroska/WAV +recording instead of webcam-cli's video/Matroska-only shapes) and its own +core-element list (``alsasrc``, ``audioconvert``, ``audioresample``, +``queue`` — no ``matroskamux``/``v4l2src``, which are webcam-cli concerns). + +The pipeline builders (:func:`build_audio_stream_argv`, +:func:`build_audio_record_argv`) are pure string/list construction — no +subprocess call — so they are trivially unit-testable and never touch a +device. They return an argv list, never a shell string, because this project +runs subprocesses without a shell. Capability detection (:func:`detect`, +:func:`require_engine`, :func:`require_elements`) is the only part of this +module that shells out. +""" + +from __future__ import annotations + +import shutil +import subprocess # nosec B404 - shelling out to gst-* is the documented engine posture +from dataclasses import dataclass +from typing import Sequence + +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError + +GST_LAUNCH = "gst-launch-1.0" +GST_INSPECT = "gst-inspect-1.0" + +# Core elements gate Capability.available: gst-launch-1.0 itself plus the +# elements every audio shape this module builds needs regardless of encode +# choice or container. ``alsasrc`` is the capture source, ``audioconvert``/ +# ``audioresample`` bridge whatever format ALSA hands back to whatever an +# encoder or sink wants, and ``queue`` is present in every builder output +# (see the pipeline builders below) so a host missing it cannot build any +# pipeline this module knows how to emit. +CORE_ELEMENTS: tuple[str, ...] = ("alsasrc", "audioconvert", "audioresample", "queue") + +# Optional elements: surfaced in Capability.plugins so a caller can branch on +# them (e.g. a host without opusenc can still do WAV-in-passthrough +# recording, and one without matroskamux can still stream). Missing optional +# elements never make Capability.available False; they are required +# conditionally, at the moment a builder decides to emit them. +OPTIONAL_ELEMENTS: tuple[str, ...] = ( + "opusenc", + "matroskamux", + "wavenc", + "rtpopuspay", + "rtpL16pay", + "udpsink", +) + +ALL_ELEMENTS: tuple[str, ...] = CORE_ELEMENTS + OPTIONAL_ELEMENTS + +# Timeout (seconds) applied to every gst-* subprocess call in this module. +_PROBE_TIMEOUT_S = 10 + +# Debian/Ubuntu packages that cover this module's element set. Named +# explicitly in the install hint so a "gst-launch-1.0 not found" or +# "missing element" error tells an agent exactly what to apt install rather +# than pointing at generic GStreamer documentation. +GST_TOOLS_PACKAGE = "gstreamer1.0-tools" +GST_PLUGINS_BASE_PACKAGE = "gstreamer1.0-plugins-base" +GST_PLUGINS_GOOD_PACKAGE = "gstreamer1.0-plugins-good" +GST_ALSA_PACKAGE = "gstreamer1.0-alsa" + +_INSTALL_HINT = ( + "install GStreamer with the tools plus base/good plugin sets and ALSA support, e.g. " + f"'sudo apt install {GST_TOOLS_PACKAGE} {GST_PLUGINS_BASE_PACKAGE} " + f"{GST_PLUGINS_GOOD_PACKAGE} {GST_ALSA_PACKAGE}'" +) + + +@dataclass(frozen=True) +class Capability: + """What the GStreamer engine can do on this host.""" + + gst_launch: str | None + gst_inspect: str | None + plugins: dict[str, bool] + available: bool + + +@dataclass(frozen=True) +class AudioFormat: + """A negotiable/negotiated audio format.""" + + rate: int + channels: int + sample_format: str = "S16LE" + + +# --- capability detection ---------------------------------------------------- + + +def _gst_inspect_supports_exists(gst_inspect: str) -> bool: + """Detect, once per :func:`detect` call, whether ``--exists`` is available. + + Ported from webcam-cli's identical helper: ``--exists`` probes an + element's presence without instantiating it, so it never opens a device + node as a side effect (webcam-cli strace-verified this for ``v4l2src``; + the same caution applies here for ``alsasrc`` and ALSA device nodes). + Any failure to even run ``--help``, or a non-zero exit, is treated as + "unsupported" so callers take the safe (if side-effecting) fallback path + rather than assume a flag that might not exist. + """ + try: + result = subprocess.run( + [gst_inspect, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=_PROBE_TIMEOUT_S, + text=True, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + if result.returncode != 0: + return False + return "--exists" in (result.stdout or "") + + +def _element_present(gst_inspect: str, element: str, use_exists: bool) -> bool: + """Probe whether one GStreamer element/plugin is present. + + Two forms, selected by ``use_exists`` (see + :func:`_gst_inspect_supports_exists`): ``gst-inspect-1.0 --exists + `` (preferred, side-effect-free) or the plain + ``gst-inspect-1.0 `` form for an older ``gst-inspect-1.0`` + that predates ``--exists``. Any failure to even run the probe is treated + as absent, never a crash. + """ + argv = [gst_inspect, "--exists", element] if use_exists else [gst_inspect, element] + try: + result = subprocess.run( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=_PROBE_TIMEOUT_S, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def detect() -> Capability: + """Detect the GStreamer engine and report its capability set. + + Never raises: an absent engine is reported as + ``Capability(available=False, ...)``, not an exception. Use + :func:`require_engine` when absence should be a typed error. + """ + gst_launch = shutil.which(GST_LAUNCH) + gst_inspect = shutil.which(GST_INSPECT) + + if gst_inspect is not None: + use_exists = _gst_inspect_supports_exists(gst_inspect) + plugins = { + element: _element_present(gst_inspect, element, use_exists) for element in ALL_ELEMENTS + } + else: + plugins = dict.fromkeys(ALL_ELEMENTS, False) + + core_present = all(plugins[element] for element in CORE_ELEMENTS) + available = gst_launch is not None and core_present + + return Capability( + gst_launch=gst_launch, + gst_inspect=gst_inspect, + plugins=plugins, + available=available, + ) + + +def require_engine() -> Capability: + """Return the detected :class:`Capability`, or raise a typed exit-2 error. + + Raises: + CliError: ``code=EXIT_ENV_ERROR`` when ``gst-launch-1.0`` or any core + element (``alsasrc``, ``audioconvert``, ``audioresample``, + ``queue``) is missing. Carries an install hint naming the actual + apt packages. + """ + cap = detect() + if cap.available: + return cap + + if cap.gst_launch is None: + message = f"{GST_LAUNCH} is not installed" + else: + missing = [element for element in CORE_ELEMENTS if not cap.plugins.get(element, False)] + message = f"required GStreamer element(s) missing: {', '.join(missing)}" + + raise CliError(EXIT_ENV_ERROR, message, remediation=_INSTALL_HINT) + + +def require_elements(cap: Capability, names: Sequence[str]) -> None: + """Raise a typed exit-2 error if ``cap`` says this host lacks any of ``names``. + + Used by callers that are about to emit an *optional* element (e.g. + ``opusenc`` for an opus-encoded stream, ``matroskamux`` for a Matroska + recording) beyond the always-required :data:`CORE_ELEMENTS`. Never + degrades to a different codec/container — a missing element is a typed + error naming the element and the package that carries it, not a silent + substitution. + """ + missing = [name for name in names if not cap.plugins.get(name, False)] + if not missing: + return + raise CliError( + EXIT_ENV_ERROR, + f"required GStreamer element(s) missing: {', '.join(missing)}", + remediation=_INSTALL_HINT, + ) + + +# --- pipeline construction ---------------------------------------------------- + + +def _validate_audio_format(fmt: AudioFormat) -> None: + if fmt.rate <= 0 or fmt.channels <= 0: + raise CliError( + EXIT_USER_ERROR, + f"invalid audio format rate={fmt.rate} channels={fmt.channels}: " + "both must be positive", + remediation="pass a rate/channels pair the device actually supports", + ) + if not fmt.sample_format: + raise CliError( + EXIT_USER_ERROR, + "invalid audio format: sample_format must not be empty", + remediation="pass a sample format the device supports, e.g. 'S16LE'", + ) + + +def _validate_port(port: int) -> None: + if not 0 < port < 65536: + raise CliError( + EXIT_USER_ERROR, + f"invalid UDP port {port}: must be between 1 and 65535", + remediation="pass a valid UDP port number", + ) + + +def _audio_caps_string(fmt: AudioFormat) -> str: + return f"audio/x-raw,format={fmt.sample_format},rate={fmt.rate},channels={fmt.channels}" + + +#: Buffer period (microseconds) alsasrc is explicitly told to use via its +#: ``latency-time`` property when a caller wants a duration-bounded record. +#: Fixing this rather than relying on alsasrc's own default is what makes +#: the ``num-buffers`` bound below deterministic: alsasrc emits +#: (approximately) one buffer per ``latency-time`` period, so pinning the +#: period to a known value lets us compute exactly how many buffers cover +#: ``duration_s`` instead of guessing at alsasrc's undocumented default. +_BOUNDED_LATENCY_TIME_US = 10_000 + + +def _bounded_source_props(duration_s: float) -> tuple[int, int]: + """Return ``(num_buffers, latency_time)`` alsasrc properties bounding a record. + + **Why ``num-buffers`` + an explicit ``latency-time`` rather than some + other bound:** ``gst-launch-1.0`` has no built-in "run for N seconds and + stop" flag for a live source; the two real options are (a) send SIGINT/ + EOS from the calling process after a wall-clock sleep, or (b) tell the + source itself to stop after a fixed number of buffers. This module picks + (b) because it keeps :func:`build_audio_record_argv` pure — the argv + alone fully describes a bounded record, with no external timer the + caller must also get right. ``alsasrc`` emits one buffer per + ``latency-time`` period, so pinning ``latency-time`` to + :data:`_BOUNDED_LATENCY_TIME_US` (10 ms) makes ``num-buffers = + duration_s / 0.01`` an exact, reproducible bound rather than one that + depends on alsasrc's platform-default period size. + """ + if duration_s <= 0: + raise CliError( + EXIT_USER_ERROR, + f"invalid duration {duration_s}s: must be positive", + remediation="pass a positive duration_s, or omit it for an unbounded record", + ) + num_buffers = max(1, round(duration_s * 1_000_000 / _BOUNDED_LATENCY_TIME_US)) + return num_buffers, _BOUNDED_LATENCY_TIME_US + + +def build_audio_stream_argv( + alsa_address: str, + fmt: AudioFormat, + port: int, + *, + encode: str = "passthrough", + host: str = "127.0.0.1", +) -> list[str]: + """Build a ``gst-launch-1.0`` argv streaming ``alsa_address`` over RTP/UDP. + + ``alsa_address`` is a direct ALSA ``hw:`` address (e.g. + ``hw:CARD=Mic,DEV=0`` — see ``arecord -l``); this function does not + validate its shape, only ``fmt``/``port``/``encode``, since address + parsing is a device concern owned elsewhere. + + ``-e`` is passed to ``gst-launch-1.0`` so that a SIGINT/shutdown sends + EOS through the pipeline (draining ``queue`` and any encoder) instead of + killing it mid-buffer — the difference between a stream a downstream RTP + depayloader can close out cleanly and one it can't. + + Args: + encode: ``"passthrough"`` pays linear PCM as RTP L16 (``audioconvert`` + to the big-endian ``S16BE`` the payloader requires, then + ``rtpL16pay``; no resampling or lossy encoder). + ``"opus"`` re-encodes through ``audioconvert ! audioresample ! + opusenc`` before ``rtpopuspay``, trading CPU for bandwidth. + Any other value is a typed user error. + """ + _validate_audio_format(fmt) + _validate_port(port) + + argv: list[str] = [ + GST_LAUNCH, + "-e", + "alsasrc", + f"device={alsa_address}", + "!", + _audio_caps_string(fmt), + "!", + "queue", + "!", + ] + + if encode == "passthrough": + # RTP L16 is network byte order: rtpL16pay only accepts S16BE, so a + # little-endian capture must pass through audioconvert first (found on + # hardware: "could not link queue0 to rtpl16pay0" with S16LE). + argv += ["audioconvert", "!", "audio/x-raw,format=S16BE", "!", "rtpL16pay", "!"] + elif encode == "opus": + argv += [ + "audioconvert", + "!", + "audioresample", + "!", + "opusenc", + "!", + "rtpopuspay", + "!", + ] + else: + raise CliError( + EXIT_USER_ERROR, + f"unsupported encode {encode!r}", + remediation="use encode='passthrough' or encode='opus'", + ) + + argv += ["udpsink", f"host={host}", f"port={port}"] + return argv + + +def build_audio_record_argv( + alsa_address: str, + fmt: AudioFormat, + output_path: str, + *, + container: str = "mka", + duration_s: float | None = None, +) -> list[str]: + """Build a ``gst-launch-1.0`` argv recording ``alsa_address`` to ``output_path``. + + ``-e`` is passed so shutdown (SIGINT, or the ``num-buffers`` bound below + running out) sends EOS through the pipeline, letting ``matroskamux`` / + ``wavenc`` finalize the file's header/index rather than leaving a + truncated one behind. + + Args: + container: ``"mka"`` encodes ``audioconvert ! audioresample ! + opusenc ! matroskamux`` (Opus-in-Matroska); ``"wav"`` uses + ``wavenc`` directly over the raw PCM caps (no re-encode). Any + other value is a typed user error. + duration_s: when given, bounds the record via ``alsasrc``'s + ``num-buffers`` property with an explicit ``latency-time`` — see + :func:`_bounded_source_props` for why that combination, rather + than an external timer, is what makes this argv self-contained. + ``None`` (the default) builds an unbounded record; the caller is + responsible for stopping it (e.g. sending SIGINT for the ``-e`` + EOS to take effect). + """ + _validate_audio_format(fmt) + + argv: list[str] = [GST_LAUNCH, "-e", "alsasrc", f"device={alsa_address}"] + + if duration_s is not None: + num_buffers, latency_time = _bounded_source_props(duration_s) + argv += [f"num-buffers={num_buffers}", f"latency-time={latency_time}"] + + argv += ["!", _audio_caps_string(fmt), "!", "queue", "!"] + + if container == "mka": + argv += [ + "audioconvert", + "!", + "audioresample", + "!", + "opusenc", + "!", + "matroskamux", + "!", + ] + elif container == "wav": + argv += ["wavenc", "!"] + else: + raise CliError( + EXIT_USER_ERROR, + f"unsupported container {container!r}", + remediation="use container='mka' or container='wav'", + ) + + argv += ["filesink", f"location={output_path}"] + return argv diff --git a/microphone_cli/explain/__init__.py b/microphone_cli/explain/__init__.py index 3d97e46..15a5ff7 100644 --- a/microphone_cli/explain/__init__.py +++ b/microphone_cli/explain/__init__.py @@ -16,7 +16,7 @@ def resolve(path: tuple[str, ...]) -> str: raise CliError( code=EXIT_USER_ERROR, message=f"no explain entry for: {display}", - remediation="list entries with: microphone-cli explain microphone-cli", + remediation="list entries with: microphone explain microphone", ) diff --git a/microphone_cli/explain/catalog.py b/microphone_cli/explain/catalog.py index 4ef097a..84c78bd 100644 --- a/microphone_cli/explain/catalog.py +++ b/microphone_cli/explain/catalog.py @@ -1,7 +1,13 @@ -"""Markdown catalog for ``microphone-cli explain ``. +"""Markdown catalog for ``microphone explain ``. -Each entry is verbatim markdown. Keys are command-path tuples. The empty tuple -and ``("microphone-cli",)`` both resolve to the root entry. +Each entry is verbatim markdown. Keys are command-path tuples. The empty tuple, +``("microphone",)`` and the legacy ``("microphone-cli",)`` all resolve to the +root entry — an agent that guesses the distribution name still lands on the +docs, even though only ``microphone`` is ever typed. + +Every path registered in :func:`microphone_cli.cli._build_parser` needs an entry +here; ``tests/test_cli.py`` walks the live parser tree and fails when one is +missing, so this file cannot silently fall behind the surface. Keep bodies self-contained: an agent reading one entry should get enough context without chaining reads. @@ -10,116 +16,749 @@ from __future__ import annotations _ROOT = """\ -# microphone-cli +# microphone + +Agent for the USB microphones and microphone arrays attached to this host. It +enumerates what is attached and gives each a stable name, reports honestly what +can be opened right now, exposes the array firmware an ordinary audio API cannot +reach (direction-of-arrival, echo-canceller state, raw XVF3800 parameters), and +hands a consumer either a live stream or a bounded recorded file. Interpreting +what is *in* the audio is a speech model's job, not this tool's. -A clonable template for AgentCulture mesh agents. It carries an agent-first CLI -(cited from the teken `python-cli` reference), a mesh identity (`culture.yaml` + -`CLAUDE.md`), the canonical guildmaster skill kit under `.claude/skills/`, and a -buildable/deployable package baseline. Clone it, rename the package, edit -`culture.yaml`, and you have a new agent. +Three names, one typable: the installed command is `microphone`, the import +package is `microphone_cli`, and the PyPI distribution is `microphone-cli`. Only +`microphone` is ever typed. ## Verbs -- `microphone-cli whoami` — identity probe from `culture.yaml`. -- `microphone-cli learn` — structured self-teaching prompt. -- `microphone-cli explain ` — markdown docs for any noun/verb. -- `microphone-cli overview` — descriptive snapshot of the agent. -- `microphone-cli doctor` — check the agent-identity invariants. -- `microphone-cli cli overview` — describe the CLI surface. +- `microphone list` — attached microphones: stable id, ALSA address, access state. +- `microphone inspect ` — capture formats, rates, channels, firmware identity. +- `microphone gain get|set ` — read or change capture gain. +- `microphone array doa ` — direction of arrival, raw firmware radians. +- `microphone array aec get|set ` — echo-canceller state and switches. +- `microphone param list|get|set` — raw XVF3800 firmware parameters. +- `microphone stream audio ` — serve a live attachment point (unbounded). +- `microphone record ` — record a bounded clip to one file. +- `microphone whoami` — identity probe from `culture.yaml`. +- `microphone learn` — structured self-teaching prompt. +- `microphone explain ` — markdown docs for any noun/verb. +- `microphone overview` — descriptive snapshot of the agent. +- `microphone doctor` — check the agent-identity invariants. +- `microphone cli overview` — describe the CLI surface. + +## What touches the hardware + +Every verb sits on one of three levels, readable from the flags alone: + +- **default (no flag)** — a dry run. Resolves the device from filesystem reads, + validates the request, prints the plan it would run. **Nothing is opened, no + transfer is issued, nothing is spawned and nothing is logged.** +- **`--probe`** — checks the capture engine and the capture node's real access + state (`stream audio`, `record`). Still spawns nothing. +- **`--apply`** — commits: opens the device and streams/records, or issues the + ALSA and firmware writes. Written to the activation log. + +Read-only vendor control transfers are the case in between: `inspect` (firmware +identity, arrays only), `array doa`, `array aec get`, `param get` and `gain get` +open the USB device and *read* from it with no `--apply`. They change nothing, +but they need permission on the device node. + +`list` opens nothing beyond one non-blocking permission probe per capture node. + +## Naming a device + +Use the stable id printed by `microphone list` (or a unique substring of it). A +bare `hw:N` or card index is refused on purpose: ALSA card numbering is plug +order and re-enumeration order, not identity, so an index is not a reproducible +instruction. `microphone list --json` prints the id to use. ## Exit-code policy - `0` success -- `1` user-input error -- `2` environment / setup error -- `3+` reserved +- `1` user-input error (unknown device, unknown parameter, bad value, bad flag) +- `2` environment error (no capture engine, forbidden device node) — not + retryable without a config/environment fix +- `3` device busy (EBUSY) — retryable; another process holds the device, and + waiting for it to release (or stopping it) is enough, no fix needed. Kept + distinct from `2` on purpose: an agent that gets the same code for both + cannot tell "wait and retry" from "this will never work" without + string-matching the message. +- `4+` reserved + +## Who this is for + +`media-cli` is the composing consumer: it orchestrates capture across tools and +calls this CLI for everything microphone-shaped rather than re-deriving it. +Agents and human operators are the other two readers — an agent needs to decide +from the surface alone whether an invocation opens a device, and an operator +needs to debug an array that is misbehaving. + +## Consent + +Every `--apply` is appended to the activation log — by default +`~/.local/state/microphone-cli/activation.jsonl` (XDG state dir; override with +`$MICROPHONE_ACTIVATION_LOG`) — and a recording writes only to the path you +name, with no hidden buffer and never to stdout. + +A hardware activity light **cannot** be promised: that is device firmware, +outside this tool's control. This tool records activations; it does not prevent +covert use, and nothing here should be read as claiming otherwise. + +## See also + +- `microphone explain list` +- `microphone explain inspect` +- `microphone explain array` +- `microphone explain param` +- `microphone explain stream` +- `microphone explain record` +""" + +_LIST = """\ +# microphone list + +Every microphone attached to this host, with the access state of its capture +node. Read-only: no capture, no format enumeration, no engine call, no USB +control transfer. The only hardware touch is one non-blocking `open()`/`close()` +per capture node — that pair *is* the permission probe. + +`list` never fails because one device is unhappy: a forbidden, absent or busy +device is reported as such, with its remediation carried through, and `list` +itself still exits 0. + +## Usage + + microphone list + microphone list --json + microphone list --root PATH # resolve under a synthetic device tree (tests) + +## What one entry means + +- **`stable_id`** — the device's identity, built from its USB descriptors + (vendor, product, serial) rather than the ALSA card index. This is the + selector every other verb accepts. +- **`card_index` / ALSA address** — where the kernel put it *this boot*. Plug + order and re-enumeration order, not identity; useful to show, never to store. +- **`is_array`** — whether the device is an XVF3800-based microphone array, and + therefore whether the `array` and `param` nouns have anything to talk to. +- **`access`** — whether the capture node is present, forbidden, busy, or + openable, each with its own remediation. + +## See also + +- `microphone explain inspect` +""" + +_INSPECT = """\ +# microphone inspect + +One device's capture capability, as the USB-audio driver itself reports it: +sample formats, sample rates and channel count, read from +`/proc/asound/card/stream0`. No ALSA library is loaded and no device is +opened for capture. + +On an XVF3800-based array this additionally looks the device up on the USB bus +and reads its firmware identity over a read-only vendor control transfer. That +control path needs permission on a device node most agents do not have without a +udev rule, so a permission or lookup failure there is reported as +`firmware: {"error": ...}` rather than raised — `inspect` is descriptive and must +not hard-fail on a permissions problem non-array microphones do not even have. A +non-array microphone reports `firmware: null`: there is no XVF3800 control +protocol to speak to it with. + +## Usage + + microphone inspect + microphone inspect --json + microphone inspect --root PATH + +`` is the stable id from `microphone list`, or a unique substring of it. + +## See also + +- `microphone explain list` +- `microphone explain array` +""" + +_GAIN = """\ +# microphone gain + +Noun group for capture gain. Two independent knobs exist on an XVF3800 array and +only one on a plain USB microphone: + +- **ALSA** — the kernel capture-volume mixer control, read and written through + `amixer`. Every USB capture device has one. +- **Firmware** — `AUDIO_MGR_MIC_GAIN` on the XVF3800 itself, read and written + over a USB vendor control transfer. Arrays only. + +`gain get` reports both where both exist. `gain set` defaults to `--target both` +(silently ALSA-only on a non-array device) and is a **dry run unless `--apply`** +is passed: without it the plan is computed and printed with no `cset` issued and +no USB device opened. With `--apply`, both actions are issued inside one +activation-log line. + +Value mapping: the CLI value is a float. For ALSA it is linearly mapped from +`0.0..1.0` onto the control's reported `[min, max]` and rounded with Python's +`round()` (banker's rounding: ties go to the nearest even integer). For firmware +the value is written verbatim as the raw float — there is no range to map onto. + +## Usage + + microphone gain overview + microphone gain get + microphone gain set [--apply] [--target alsa|firmware|both] + +## See also + +- `microphone explain param` +""" + +_GAIN_OVERVIEW = """\ +# microphone gain overview + +Describes the `gain` verb group: its verbs, the two independent gain knobs +(ALSA mixer and, on arrays, the `AUDIO_MGR_MIC_GAIN` firmware parameter), the +`0.0..1.0` value mapping, and the fact that `gain set` is a dry run unless +`--apply` is passed. A bare `microphone gain` prints the same thing. + +## Usage + + microphone gain overview + microphone gain overview --json +""" + +_GAIN_GET = """\ +# microphone gain get + +Reads the current capture gain. On every device that means the ALSA capture +mixer control; on an XVF3800 array it additionally reads `AUDIO_MGR_MIC_GAIN` +over a **read-only** USB vendor control transfer. Nothing is written and no +`--apply` is involved, but the firmware read does open the USB device node, so +it needs permission on it. + +A device with no capture mixer control is a typed user error naming the card, +not a silent zero. + +## Usage + + microphone gain get + microphone gain get --json + +## See also + +- `microphone explain gain` +""" + +_GAIN_SET = """\ +# microphone gain set + +Sets the capture gain from a float in `0.0..1.0`. **Dry run unless `--apply`.** +Without `--apply` the ALSA control's range is read, the integer that would be +written is computed, and the plan is printed — no `cset`, no USB device opened. +With `--apply`, the ALSA write and (on an array, unless `--target` narrows it) +the firmware write are both issued and wrapped in exactly one activation-log +line. + +`--target alsa|firmware|both` picks which knob to move; `both` is the default +and degrades silently to ALSA-only on a non-array device. + +## Usage + + microphone gain set 0.75 + microphone gain set 0.75 --apply + microphone gain set 0.75 --apply --target firmware --json + +## See also + +- `microphone explain gain` +""" + +_ARRAY = """\ +# microphone array + +Noun group for XVF3800 microphone-array firmware — the state an ordinary audio +API cannot reach. Two verb families hang off it: + +- `array doa ` — read `DOA_VALUE_RADIANS`, once or continuously. +- `array aec get|set ` — the echo-canceller's observable state and the + switches that are safe to flip. + +Reads are read-only vendor control transfers: they change nothing but do open +the USB device node, so they need permission on it. Writes (`array aec set`) are +a dry run unless `--apply`, and `--apply` writes inside an activation scope, so +every hardware-touching run leaves exactly one activation-log line. + +A bare `microphone array` prints this noun's own overview. + +## Usage + + microphone array overview + microphone array doa [--watch] [--interval S] [--count N] + microphone array aec get + microphone array aec set [--apply] + +## See also + +- `microphone explain param` +- `microphone explain inspect` +""" + +_ARRAY_OVERVIEW = """\ +# microphone array overview + +Describes the `array` verb group: `doa`, the `aec` sub-noun, which of them read +and which write, and the fact that writes need `--apply`. A bare +`microphone array` prints the same thing. + +## Usage + + microphone array overview + microphone array overview --json +""" + +_ARRAY_DOA = """\ +# microphone array doa + +Reads `DOA_VALUE_RADIANS` from the array firmware: the estimated direction of +arrival of the dominant sound source. + +**The value is reported exactly as the firmware reports it** — radians, in the +array's own frame. No degree conversion, no coordinate transform, no re-basing +onto a robot or room frame happens here. A consumer that needs another frame +owns that conversion, and can only do it correctly if it starts from the +untouched firmware value. + +This is a read-only vendor control transfer: nothing is written, and no +`--apply` exists for it. It does open the USB device node, so it needs +permission on it. + +`--watch` polls continuously, printing one JSON object per line (JSON Lines) +until Ctrl-C or `--count` samples; `--interval` sets the seconds between polls. + +## Usage + + microphone array doa + microphone array doa --json + microphone array doa --watch --interval 0.25 --count 20 + +## See also + +- `microphone explain array` +""" + +_ARRAY_AEC = """\ +# microphone array aec + +Sub-noun for the XVF3800 echo canceller. `aec get` reports its observable state +— converged, bypass, high-pass filter, echo suppression, microphone count, array +geometry — over read-only control transfers. `aec set` flips the switches that +are safe to flip, and is a **dry run unless `--apply`**; with `--apply` the +writes are issued inside an activation scope, leaving exactly one activation-log +line. + +A bare `microphone array aec` prints this sub-noun's own overview. + +## Usage + + microphone array aec overview + microphone array aec get + microphone array aec set [--apply] + +## See also + +- `microphone explain array` +""" + +_ARRAY_AEC_OVERVIEW = """\ +# microphone array aec overview + +Describes the `aec` sub-noun: the state fields `get` reads, the switches `set` +can flip, and the `--apply` gate in front of every write. A bare +`microphone array aec` prints the same thing. + +## Usage + + microphone array aec overview + microphone array aec overview --json +""" + +_ARRAY_AEC_GET = """\ +# microphone array aec get + +Reads the echo canceller's observable state: whether it has converged, whether +it is bypassed, the high-pass filter and echo-suppression settings, the +microphone count and the array geometry. Read-only vendor control transfers — +nothing is written, and there is no `--apply` for this verb. It does open the +USB device node, so it needs permission on it. + +## Usage + + microphone array aec get + microphone array aec get --json + +## See also + +- `microphone explain array` +""" + +_ARRAY_AEC_SET = """\ +# microphone array aec set + +Flips the echo-canceller switches that are safe to flip, each as an explicit +`on`/`off` flag. **Dry run unless `--apply`**: without it the requested writes +are validated and the plan is printed with no transfer issued. With `--apply` +the writes go out inside an activation scope, leaving exactly one activation-log +line. + +Only a curated set of switches is exposed here. Anything else on the firmware is +reachable through `microphone param set`, which carries its own persistent-tier +gate. + +## Usage + + microphone array aec set --bypass off + microphone array aec set --bypass off --apply --json + +## See also + +- `microphone explain array` +- `microphone explain param` +""" + +_PARAM = """\ +# microphone param + +The low-level noun: read and write raw XVF3800 firmware parameters by name +(case-insensitive, always echoed upper-case). Every verb operates directly on +one row of the firmware's parameter table. + +Validation — unknown name, wrong access direction, wrong value count or type — +happens **before** any USB transfer is issued, in dry-run and in apply mode +alike, so a rejected command never touches hardware. + +## The persistent tier + +A second gate sits in front of `--apply`. Some parameters survive a power-cycle +(after `SAVE_CONFIGURATION`), trigger a reboot, or are otherwise destructive. +Writing one of those requires `--allow-persistent` **in addition to** `--apply`. +An ordinary `rw` write is volatile and reverts on the next power-cycle, so that +extra flag is the one place this noun asks for explicit confirmation. The check +runs before the device is opened and before any transfer is issued. + +## Usage + + microphone param overview + microphone param list + microphone param get + microphone param set [--apply] [--allow-persistent] + +## See also + +- `microphone explain array` +- `microphone explain gain` +""" + +_PARAM_OVERVIEW = """\ +# microphone param overview + +Describes the `param` noun: its verbs, the pre-transfer validation, and the +persistent/destructive tier that needs `--allow-persistent` on top of `--apply`. +A bare `microphone param` prints the same thing. + +## Usage + + microphone param overview + microphone param overview --json +""" + +_PARAM_LIST = """\ +# microphone param list + +Lists every row of the XVF3800 parameter table this tool knows: name, access +direction, value type and count, and whether the parameter is in the persistent +or destructive tier. Pure data — no device is resolved, no device is opened and +no transfer is issued, so this works with no microphone attached at all. + +Use it to find the exact `` for `param get` / `param set`. + +## Usage + + microphone param list + microphone param list --json + +## See also + +- `microphone explain param` +""" + +_PARAM_GET = """\ +# microphone param get + +Reads one raw firmware parameter by name (case-insensitive). The name is +validated against the parameter table — unknown name, or a name that is not +readable — before the device is opened, so a rejected command never touches +hardware. + +This is a read-only vendor control transfer: nothing is written and there is no +`--apply`. It does open the USB device node, so it needs permission on it. + +## Usage + + microphone param get AUDIO_MGR_MIC_GAIN + microphone param get AUDIO_MGR_MIC_GAIN --json + +## See also + +- `microphone explain param` +""" + +_PARAM_SET = """\ +# microphone param set + +Writes one raw firmware parameter. **Dry run unless `--apply`**: without it the +name, access direction, value count and value types are validated and the plan +is printed, with no device opened and no transfer issued. + +A parameter in the persistent or destructive tier additionally requires +`--allow-persistent`. Those parameters survive a power-cycle (after +`SAVE_CONFIGURATION`), trigger a reboot, or are otherwise irreversible, unlike an +ordinary `rw` write which is volatile and reverts on the next power-cycle. The +persistent check runs before the device is opened and before any transfer. + +## Usage + + microphone param set AUDIO_MGR_MIC_GAIN 0.5 + microphone param set AUDIO_MGR_MIC_GAIN 0.5 --apply + microphone param set SAVE_CONFIGURATION 1 --apply --allow-persistent + +## See also + +- `microphone explain param` +""" + +_STREAM = """\ +# microphone stream + +Noun group for live attachment points: a running microphone another process can +consume, rather than an artifact on disk. Today there is one verb, +`stream audio`. A bare `microphone stream` prints this noun's own overview. + +Streams are **unbounded by construction**: there is no `--duration`. Stop one +with SIGINT/SIGTERM, or by killing the pid the command returns. For a bounded +artifact use `microphone record`. + +## Usage + + microphone stream overview + microphone stream audio [--apply] ## See also -- `microphone-cli explain whoami` -- `microphone-cli explain doctor` +- `microphone explain record` +""" + +_STREAM_OVERVIEW = """\ +# microphone stream overview + +Describes the `stream` verb group: its verbs, the three-level hardware split, +the attachment point (host, port, wire codec) and the defaults for rate, +channels and sample format. A bare `microphone stream` prints the same thing. + +## Usage + + microphone stream overview + microphone stream overview --json +""" + +_STREAM_AUDIO = """\ +# microphone stream audio + +Serves a live microphone stream over RTP/UDP — an attachment point, not a file. +Unbounded: there is no `--duration`; stop it with SIGINT/SIGTERM or by killing +the returned pid. + +## Three levels of hardware contact + +- **default** — a dry run. Resolves the device from `/proc` and `/sys` + (filesystem reads only), builds the exact argv `--apply` would run and prints + it. No engine detection, no `open()`, no spawn: `hardware_touched` and + `engine_checked` are both `false`. The reported access state comes from a + `stat`, not an open. +- **`--probe`** — additionally detects the GStreamer engine (by shelling out to + `gst-inspect-1.0`, which opens no device) and reports the capture node's real + access state. Still spawns nothing. +- **`--apply`** — requires the engine and the elements the chosen encoding needs, + *enforces* access (a busy device is the typed exit-3 error, never a silent + wait), then spawns the pipeline inside an activation scope. The pid is + returned so the caller can stop it. + +There is no warm-up: an ALSA capture device has no sensor that has to settle, so +`warmup` reports zero and says why rather than faking a video-shaped field. + +## Usage + + microphone stream audio + microphone stream audio --probe --json + microphone stream audio --apply --port 5004 --encode opus + +## See also + +- `microphone explain stream` +- `microphone explain record` +""" + +_RECORD = """\ +# microphone record + +Records a bounded audio clip from a resolved microphone to one file. The +container comes from the extension: `.mka` (Opus in Matroska) or `.wav` (raw +PCM). + +**There is no flag that means "forever."** `--duration` and `--max-bytes` both +have defaults and both have ceilings, and both are enforced twice over: the +pipeline argv is self-limiting, *and* the growing artifact is polled and the +child stopped when either bound is reached. The JSON says which bound won, in +`stopped_reason`. + +Hardware contact is the same three-level split as `stream audio`: nothing by +default (resolve and validate only), engine plus access check with `--probe`, +and an actual recording with `--apply` — which writes one activation-log line. +An existing output path is refused unless `--overwrite` is passed. + +## Usage + + microphone record out.mka + microphone record out.wav --duration 5 --apply + microphone record out.mka --probe --json + +## See also + +- `microphone explain stream` """ _WHOAMI = """\ -# microphone-cli whoami +# microphone whoami Reports the agent's identity from `culture.yaml`: nick (`suffix`), backend, -served model, and the package version. Read-only. +served model, and the package version. Read-only, and the `culture.yaml` it +reads is the agent's own — found by walking up from the installed module, not +from the caller's working directory. ## Usage - microphone-cli whoami - microphone-cli whoami --json + microphone whoami + microphone whoami --json """ _LEARN = """\ -# microphone-cli learn +# microphone learn + +Prints a structured self-teaching prompt: purpose, the three-level hardware +split (and which read-only verbs still open the USB device), the command map, +the device-selector rule, the exit-code policy, `--json` support, the consent +posture, and the `explain` pointer. -Prints a structured self-teaching prompt covering purpose, command map, -exit-code policy, `--json` support, and the `explain` pointer. +Read this before invoking anything that could open a microphone. ## Usage - microphone-cli learn - microphone-cli learn --json + microphone learn + microphone learn --json """ _EXPLAIN = """\ -# microphone-cli explain +# microphone explain Prints markdown documentation for any noun/verb path. Unlike `--help` (terse, -positional), `explain` is global and addressable by path. +positional), `explain` is global and addressable by path, so an agent can read +about `array aec set` without first constructing that command. ## Usage - microphone-cli explain microphone-cli - microphone-cli explain whoami - microphone-cli explain --json + microphone explain microphone + microphone explain list + microphone explain array aec set + microphone explain --json """ _OVERVIEW = """\ -# microphone-cli overview +# microphone overview Read-only descriptive snapshot of the agent: identity (from `culture.yaml`), the -verb surface, and the sibling-pattern artifacts the template carries. Accepts an -ignored `target` so a stray path never hard-fails. +verb surface, which invocations energize hardware, the contracts the microphone +verbs obey, and the consent posture. Accepts an ignored `target` so a stray path +never hard-fails. + +Each noun group has its own overview too — `microphone stream overview`, +`microphone array overview`, `microphone param overview`, +`microphone gain overview` — and `microphone cli overview` describes the CLI +surface itself. ## Usage - microphone-cli overview - microphone-cli overview --json + microphone overview + microphone overview --json """ _DOCTOR = """\ -# microphone-cli doctor +# microphone doctor Checks the agent-identity invariants `steward doctor` verifies: -prompt-file-present and backend-consistency (`colleague` → `AGENTS.colleague.md`), plus a -skills-present check. Exits 1 when unhealthy. +prompt-file-present and backend-consistency (`colleague` → `AGENTS.colleague.md`), +plus a skills-present check. Exits 1 when unhealthy. + +This is an *identity* check, not a capture-readiness check: it says nothing about +whether a microphone is attached, whether a capture engine is installed, or +whether this process can open a device node. Use `microphone list` for that. ## Usage - microphone-cli doctor - microphone-cli doctor --json + microphone doctor + microphone doctor --json """ _CLI = """\ -# microphone-cli cli +# microphone cli Noun group for CLI-surface introspection. `cli overview` describes the CLI -itself (distinct from the global `overview`, which describes the agent). +itself — its verbs and the conventions every one of them obeys — as distinct +from the global `overview`, which describes the agent. ## Usage - microphone-cli cli overview - microphone-cli cli overview --json + microphone cli overview + microphone cli overview --json """ ENTRIES: dict[tuple[str, ...], str] = { (): _ROOT, - ("microphone-cli",): _ROOT, ("microphone",): _ROOT, + # Legacy alias: the distribution name, kept resolvable so a guess still + # lands on the docs. Never advertised as something to type. + ("microphone-cli",): _ROOT, + ("list",): _LIST, + ("inspect",): _INSPECT, + ("gain",): _GAIN, + ("gain", "overview"): _GAIN_OVERVIEW, + ("gain", "get"): _GAIN_GET, + ("gain", "set"): _GAIN_SET, + ("array",): _ARRAY, + ("array", "overview"): _ARRAY_OVERVIEW, + ("array", "doa"): _ARRAY_DOA, + ("array", "aec"): _ARRAY_AEC, + ("array", "aec", "overview"): _ARRAY_AEC_OVERVIEW, + ("array", "aec", "get"): _ARRAY_AEC_GET, + ("array", "aec", "set"): _ARRAY_AEC_SET, + ("param",): _PARAM, + ("param", "overview"): _PARAM_OVERVIEW, + ("param", "list"): _PARAM_LIST, + ("param", "get"): _PARAM_GET, + ("param", "set"): _PARAM_SET, + ("stream",): _STREAM, + ("stream", "overview"): _STREAM_OVERVIEW, + ("stream", "audio"): _STREAM_AUDIO, + ("record",): _RECORD, ("whoami",): _WHOAMI, ("learn",): _LEARN, ("explain",): _EXPLAIN, diff --git a/microphone_cli/mixer.py b/microphone_cli/mixer.py new file mode 100644 index 0000000..56fb02d --- /dev/null +++ b/microphone_cli/mixer.py @@ -0,0 +1,289 @@ +"""ALSA mixer control via ``amixer`` — no ALSA library bindings. + +Wraps ``amixer -c contents`` / ``amixer -c cset numid= `` +so gain get/set can read and write the capture-volume control without linking +against ``libasound``. This keeps microphone-cli at zero runtime dependencies +(see CLAUDE.md) — ``amixer`` is a subprocess, not an import. + +``-c `` accepts either the ALSA card index or the card id; every caller +here passes the numeric ``card_index`` (not the stable id), matching what +``amixer -c`` and ``arecord -l`` both expect and matching the caller's other +subprocess-based tooling. A card id would also work (``amixer`` resolves both +forms) but the index is what :mod:`microphone_cli.devices` hands back as +``card_index``, so that is what is documented and tested here. + +Testing seam: every function that shells out takes ``run`` (defaulting to +:func:`subprocess.run`), so tests inject a fake and no test ever calls the +real ``amixer``. +""" + +from __future__ import annotations + +import re +import subprocess # nosec B404 - fixed argv, no shell, used only as a type/default +from dataclasses import dataclass +from typing import Callable, Sequence + +from .cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError + +__all__ = [ + "MixerControl", + "RunFunc", + "list_controls", + "find_capture_volume", + "get_gain", + "set_gain_argv", + "set_gain", +] + +#: Signature every ``run=`` seam here expects: ``subprocess.run``-compatible. +RunFunc = Callable[..., "subprocess.CompletedProcess[str]"] + +_AMIXER_MISSING_HINT = ( + "amixer was not found on PATH; install alsa-utils " + "(e.g. `sudo apt install alsa-utils` / `sudo dnf install alsa-utils`) and retry." +) + +# "numid=3,iface=MIXER,name='Mic Capture Volume'" +# A second control with the same name carries a trailing ",index=1" (found on +# hardware: the XVF3800 exposes two 'Headset Capture Volume' controls). Without +# the optional suffix the second block's value lines were attributed to the +# first control, so set_gain's readback reported the wrong control's value. +_NUMID_RE = re.compile( + r"^numid=(?P\d+),iface=(?P[^,]+),name='(?P[^']*)'" + r"(?:,index=(?P\d+))?$" +) +# "; type=INTEGER,access=rw---R--,values=1,min=0,max=30,step=0" +_ATTR_RE = re.compile(r"^;\s*type=(?P[^,]+),access=(?P[^,]+),(?P.*)") +# ": values=20" or ": values=20,20" +# No trailing ``$``: ``.*`` already runs to the end of the (single) line, and +# the anchor only gives the engine a reason to backtrack (SonarCloud S8786). +_VALUE_LINE_RE = re.compile(r"^:\s*values=(?P.*)") + + +def _int_pairs(rest: str) -> list[tuple[str, int]]: + """``"values=1,min=0,max=30,step=0"`` -> ``[("values", 1), ("min", 0), ...]``. + + Plain splitting instead of a regex: the field is a comma-separated list of + ``key=int`` tokens, and a scanning regex here was the one SonarCloud + flagged as super-linear (S8786). Tokens that are not ``key=int`` are + skipped, matching the old ``findall`` behaviour. + """ + pairs: list[tuple[str, int]] = [] + for token in rest.split(","): + key, sep, value = token.strip().partition("=") + if not sep: + continue + try: + pairs.append((key, int(value))) + except ValueError: + continue + return pairs + + +_PREFERRED_NAME_HINTS = ("capture volume", "mic") + + +@dataclass(frozen=True) +class MixerControl: + """One ``amixer contents`` block, parsed.""" + + numid: int + iface: str + name: str + control_type: str + access: str + count: int + min: int | None + max: int | None + step: int | None + values: tuple[int | str, ...] + + @property + def value(self) -> int | str | None: + """The first (and usually only) reported value, or ``None`` if empty.""" + return self.values[0] if self.values else None + + def as_dict(self) -> dict[str, object]: + return { + "control": self.name, + "numid": self.numid, + "iface": self.iface, + "type": self.control_type, + "access": self.access, + "count": self.count, + "value": self.value, + "values": list(self.values), + "min": self.min, + "max": self.max, + "step": self.step, + } + + +def _run_amixer(argv: Sequence[str], *, run: RunFunc) -> str: + try: + result = run(list(argv), capture_output=True, text=True, check=False) + except FileNotFoundError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message="amixer is not installed", + remediation=_AMIXER_MISSING_HINT, + ) from exc + if result.returncode != 0: + stderr = (result.stderr or "").strip() + raise CliError( + code=EXIT_ENV_ERROR, + message=f"amixer failed (exit {result.returncode}): {stderr or 'no output'}", + remediation=( + "confirm the card index with `microphone device list --json`, " + "then retry `amixer -c contents` by hand" + ), + ) + return result.stdout or "" + + +def _parse_values(raw: str) -> tuple[int | str, ...]: + out: list[int | str] = [] + for part in raw.split(","): + part = part.strip() + try: + out.append(int(part)) + except ValueError: + out.append(part) + return tuple(out) + + +def _header_fields(line: str) -> dict[str, object] | None: + """Parse a ``numid=...`` header line into a fresh ``current`` dict, or + ``None`` if ``line`` is not a header.""" + header = _NUMID_RE.match(line) + if header is None: + return None + return { + "numid": int(header.group("numid")), + "iface": header.group("iface"), + "name": header.group("name"), + } + + +def _apply_attr_line(current: dict[str, object], line: str) -> bool: + """Merge a ``; type=...`` attribute line into ``current``. Returns whether + ``line`` was an attribute line at all.""" + attr = _ATTR_RE.match(line) + if attr is None: + return False + current["type"] = attr.group("type") + current["access"] = attr.group("access") + for key, value in _int_pairs(attr.group("rest")): + if key == "values": + current["count"] = value + elif key in ("min", "max", "step"): + current[key] = value + return True + + +def _apply_value_line(current: dict[str, object], line: str) -> bool: + """Merge a ``: values=...`` line into ``current``. Returns whether + ``line`` was a value line at all.""" + value_line = _VALUE_LINE_RE.match(line) + if value_line is None: + return False + current["values"] = _parse_values(value_line.group("values")) + return True + + +def _control_from_fields(fields: dict[str, object]) -> MixerControl: + return MixerControl( + numid=fields["numid"], # type: ignore[arg-type] + iface=fields["iface"], # type: ignore[arg-type] + name=fields["name"], # type: ignore[arg-type] + control_type=fields.get("type", ""), # type: ignore[arg-type] + access=fields.get("access", ""), # type: ignore[arg-type] + count=fields.get("count", 0), # type: ignore[arg-type] + min=fields.get("min"), # type: ignore[arg-type] + max=fields.get("max"), # type: ignore[arg-type] + step=fields.get("step"), # type: ignore[arg-type] + values=fields.get("values", ()), # type: ignore[arg-type] + ) + + +def list_controls(card: str | int, *, run: RunFunc = subprocess.run) -> list[MixerControl]: + """Parse ``amixer -c contents`` into every :class:`MixerControl`. + + Never raises for a card with zero controls (returns ``[]``); raises + :class:`CliError` (``code=2``) if ``amixer`` is missing or exits non-zero. + """ + text = _run_amixer(["amixer", "-c", str(card), "contents"], run=run) + + controls: list[MixerControl] = [] + current: dict[str, object] | None = None + + for raw_line in text.splitlines(): + line = raw_line.strip() + header = _header_fields(line) + if header is not None: + if current is not None: + controls.append(_control_from_fields(current)) + current = header + continue + if current is None: + continue + if _apply_attr_line(current, line): + continue + _apply_value_line(current, line) + + if current is not None: + controls.append(_control_from_fields(current)) + return controls + + +def find_capture_volume(controls: Sequence[MixerControl]) -> MixerControl | None: + """Pick the capture-gain control: prefer a name containing "Capture Volume", + then any name containing "Mic"; ``None`` if neither is present.""" + lowered = [(control, control.name.lower()) for control in controls] + for hint in _PREFERRED_NAME_HINTS: + for control, name in lowered: + if hint in name: + return control + return None + + +def get_gain(card: str | int, *, run: RunFunc = subprocess.run) -> MixerControl: + """Return the card's capture-volume control. + + Raises :class:`CliError` (``code=1``) if the card exposes no capture + control amixer can name. + """ + controls = list_controls(card, run=run) + control = find_capture_volume(controls) + if control is None: + raise CliError( + code=EXIT_USER_ERROR, + message=f"card {card} has no capture-volume mixer control", + remediation=( + f"run `amixer -c {card} contents` to inspect the controls this card exposes" + ), + ) + return control + + +def set_gain_argv(card: str | int, numid: int, value: int) -> list[str]: + """The exact ``amixer`` argv :func:`set_gain` would issue — pure, for dry-run display.""" + return ["amixer", "-c", str(card), "cset", f"numid={numid}", str(value)] + + +def set_gain( + card: str | int, + control: MixerControl, + value: int, + *, + run: RunFunc = subprocess.run, +) -> MixerControl: + """Write ``value`` to ``control`` via ``amixer cset``, then re-read it. + + Returns the control's post-write state (a fresh :func:`get_gain`), so + callers report what the hardware actually holds rather than the value they + asked for. + """ + _run_amixer(set_gain_argv(card, control.numid, value), run=run) + return get_gain(card, run=run) diff --git a/microphone_cli/usbctl.py b/microphone_cli/usbctl.py new file mode 100644 index 0000000..0b0fd2c --- /dev/null +++ b/microphone_cli/usbctl.py @@ -0,0 +1,273 @@ +"""Stdlib USB control transfers over ``usbdevfs`` — no pyusb, no libusb. + +The kernel exposes every USB device as a character node at +``/dev/bus/usb/BBB/DDD``. Opening that node ``O_RDWR`` and issuing the +``USBDEVFS_CONTROL`` ioctl performs a control transfer, which is all the +XVF3800 vendor protocol needs. Doing it this way keeps microphone-cli at +zero runtime dependencies (see CLAUDE.md). + +Layout mirrored from ``/usr/include/linux/usbdevice_fs.h``:: + + struct usbdevfs_ctrltransfer { + __u8 bRequestType; + __u8 bRequest; + __u16 wValue; + __u16 wIndex; + __u16 wLength; + __u32 timeout; /* in milliseconds */ + void *data; + }; + +``USBDEVFS_CONTROL`` is ``_IOWR('U', 0, struct usbdevfs_ctrltransfer)``; the +constant is *derived* from the struct size here rather than hard-coded, so it +stays correct on non-64-bit layouts. + +Testing seam: :data:`_ioctl` is a module-level callable defaulting to +``fcntl.ioctl``. Tests replace it, so no test ever touches ``/dev``. +""" + +from __future__ import annotations + +import ctypes +import fcntl +import os +from typing import Any, Callable + +from .cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError + +__all__ = [ + "UsbdevfsCtrlTransfer", + "USBDEVFS_CONTROL", + "DEFAULT_TIMEOUT_MS", + "control_transfer", + "find_devices", + "open_device", +] + +# ``_IOC`` bit layout (asm-generic): dir<<30 | size<<16 | type<<8 | nr. +_IOC_NRBITS = 8 +_IOC_TYPEBITS = 8 +_IOC_SIZEBITS = 14 +_IOC_NRSHIFT = 0 +_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS +_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS +_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS +_IOC_WRITE = 1 +_IOC_READ = 2 + +#: Control transfers on this device tolerate a long firmware turnaround; the +#: vendored reachy_mini script uses 100 s and so do we. +DEFAULT_TIMEOUT_MS = 100000 + + +def udev_rule(vendor: str | None, product: str | None) -> str: + """Return the udev rule line that grants non-root access to one USB id. + + Names the ids of the device that was actually refused; falls back to + ``XXXX``/``YYYY`` placeholders when they are unknown so the hint never + points at the wrong board. + """ + vid = vendor.lower() if vendor else "XXXX" + pid = product.lower() if product else "YYYY" + return f'SUBSYSTEM=="usb", ATTR{{idVendor}}=="{vid}", ATTR{{idProduct}}=="{pid}", MODE="0666"' + + +def _ioc(direction: int, type_: int, nr: int, size: int) -> int: + return ( + (direction << _IOC_DIRSHIFT) + | (size << _IOC_SIZESHIFT) + | (type_ << _IOC_TYPESHIFT) + | (nr << _IOC_NRSHIFT) + ) + + +def _iowr(type_: int, nr: int, size: int) -> int: + """``_IOWR(type, nr, size)`` from ````.""" + return _ioc(_IOC_READ | _IOC_WRITE, type_, nr, size) + + +class UsbdevfsCtrlTransfer(ctypes.Structure): + """``struct usbdevfs_ctrltransfer`` (natural alignment, as the kernel uses).""" + + _fields_ = [ + ("bRequestType", ctypes.c_uint8), + ("bRequest", ctypes.c_uint8), + ("wValue", ctypes.c_uint16), + ("wIndex", ctypes.c_uint16), + ("wLength", ctypes.c_uint16), + ("timeout", ctypes.c_uint32), + ("data", ctypes.c_void_p), + ] + + +#: ``_IOWR('U', 0, struct usbdevfs_ctrltransfer)`` — 0xC0185500 on 64-bit. +USBDEVFS_CONTROL = _iowr(ord("U"), 0, ctypes.sizeof(UsbdevfsCtrlTransfer)) + +#: Injected ioctl. Tests replace this; production uses ``fcntl.ioctl``. +_ioctl: Callable[..., Any] = fcntl.ioctl + + +def control_transfer( + fd: int, + request_type: int, + request: int, + value: int, + index: int, + data_or_length: int | bytes | bytearray, + timeout_ms: int = DEFAULT_TIMEOUT_MS, +) -> bytes | int: + """Issue one USB control transfer on an open usbdevfs ``fd``. + + ``data_or_length`` is an ``int`` for an IN (device-to-host) transfer — the + number of bytes to request, and the call returns the bytes read — or a + bytes-like payload for an OUT transfer, where the call returns the number + of bytes the kernel reports as transferred. + """ + if isinstance(data_or_length, int): + length = data_or_length + buf = ctypes.create_string_buffer(length) + else: + payload = bytes(data_or_length) + length = len(payload) + buf = ( + ctypes.create_string_buffer(payload, length) + if length + else ctypes.create_string_buffer(1) + ) + + xfer = UsbdevfsCtrlTransfer( + bRequestType=request_type & 0xFF, + bRequest=request & 0xFF, + wValue=value & 0xFFFF, + wIndex=index & 0xFFFF, + wLength=length & 0xFFFF, + timeout=timeout_ms, + data=ctypes.cast(buf, ctypes.c_void_p), + ) + + try: + ret = _ioctl(fd, USBDEVFS_CONTROL, xfer) + except OSError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"USB control transfer failed: {exc}", + remediation=( + "Confirm the device is still attached (`microphone` device list) and that no " + "other process holds it; replug the device if the error persists." + ), + ) from exc + + count = ret if isinstance(ret, int) and 0 <= ret <= length else length + if isinstance(data_or_length, int): + return bytes(buf.raw[:count]) + return count + + +def _read_attr(path: str) -> str | None: + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + return handle.read().strip() + except OSError: + return None + + +def _read_device_attrs(devdir: str) -> dict[str, str] | None: + """Read one sysfs device directory's identity attrs, or ``None`` if it is + not a device directory (an interface directory such as ``1-3:1.0`` lacks + ``idVendor``/``idProduct``/``busnum``/``devnum``).""" + vid = _read_attr(os.path.join(devdir, "idVendor")) + pid = _read_attr(os.path.join(devdir, "idProduct")) + busnum = _read_attr(os.path.join(devdir, "busnum")) + devnum = _read_attr(os.path.join(devdir, "devnum")) + if not (vid and pid and busnum and devnum): + return None + return { + "vendor": vid.lower(), + "product": pid.lower(), + "serial": _read_attr(os.path.join(devdir, "serial")) or "", + "busnum": busnum, + "devnum": devnum, + } + + +def _matches_filters( + attrs: dict[str, str], + vendor: str | None, + product: str | None, + serial: str | None, +) -> bool: + if vendor is not None and attrs["vendor"] != vendor.lower(): + return False + if product is not None and attrs["product"] != product.lower(): + return False + if serial is not None and attrs["serial"] != serial: + return False + return True + + +def find_devices( + root: str = "/", + vendor: str | None = None, + product: str | None = None, + serial: str | None = None, +) -> list[dict[str, str]]: + """Enumerate USB devices from sysfs, newest-style attrs only. + + Walks ``/sys/bus/usb/devices/*/`` and keeps entries that expose + ``idVendor``, ``idProduct``, ``busnum`` and ``devnum`` (interface + directories such as ``1-3:1.0`` do not, and are skipped). Filters compare + case-insensitively. Returns dicts with ``node`` (the + ``/dev/bus/usb/BBB/DDD`` path), ``vendor``, ``product``, ``serial``, + ``busnum``, ``devnum`` and ``sysfs``. + """ + base = os.path.join(root, "sys", "bus", "usb", "devices") + try: + names = sorted(os.listdir(base)) + except OSError: + return [] + + out: list[dict[str, str]] = [] + for name in names: + devdir = os.path.join(base, name) + attrs = _read_device_attrs(devdir) + if attrs is None or not _matches_filters(attrs, vendor, product, serial): + continue + try: + node = "/dev/bus/usb/{:03d}/{:03d}".format(int(attrs["busnum"]), int(attrs["devnum"])) + except ValueError: + continue + out.append({**attrs, "node": node, "sysfs": devdir}) + return out + + +def open_device(node: str, *, vendor: str | None = None, product: str | None = None) -> int: + """Open a ``/dev/bus/usb/BBB/DDD`` node ``O_RDWR`` and return the fd. + + ``vendor``/``product`` are only used to make the permission-denied + remediation name the refused device's own USB ids. + """ + try: + return os.open(node, os.O_RDWR) + except PermissionError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"permission denied opening {node}", + remediation=( + "Grant your user access to the device with a udev rule, then replug it:\n" + f" echo '{udev_rule(vendor, product)}' | sudo tee " + "/etc/udev/rules.d/99-microphone-cli.rules\n" + " sudo udevadm control --reload-rules && sudo udevadm trigger" + ), + ) from exc + except FileNotFoundError as exc: + raise CliError( + code=EXIT_USER_ERROR, + message=f"no such USB device node: {node}", + remediation="Run `microphone list` to see the devices that exist right now.", + ) from exc + except OSError as exc: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"cannot open {node}: {exc}", + remediation="Check that the device is attached and not claimed by another process.", + ) from exc diff --git a/microphone_cli/xvf3800.py b/microphone_cli/xvf3800.py new file mode 100644 index 0000000..fa530a9 --- /dev/null +++ b/microphone_cli/xvf3800.py @@ -0,0 +1,554 @@ +"""XVF3800 vendor control protocol (read/write firmware parameters). + +The XMOS XVF3800 in the Reachy Mini Audio card (and in the older ReSpeaker +XVF3800 boards) exposes its parameters over USB *vendor* control transfers: + +* read — ``bmRequestType 0xC0`` (IN | VENDOR | DEVICE), ``bRequest 0``, + ``wValue = 0x80 | cmdid``, ``wIndex = resid``, ``wLength = payload + 1``. + Byte 0 of the reply is a status byte: ``0`` success, ``64`` "servicer busy, + retry", anything else an error. +* write — ``bmRequestType 0x40`` (OUT | VENDOR | DEVICE), ``bRequest 0``, + ``wValue = cmdid``, ``wIndex = resid``, little-endian packed payload. + +Parameter appendix: +https://www.xmos.com/documentation/XM-014888-PC/html/modules/fwk_xvf/doc/user_guide/AA_control_command_appendix.html + +Provenance +---------- +:data:`PARAMETERS` is vendored **verbatim** (name -> ``(resid, cmdid, count, +access, type)``) from Pollen Robotics' reachy_mini, file +``src/reachy_mini/media/audio_control_utils.py``, licensed **Apache-2.0** +(see that project's ``LICENSE``). The wire protocol implemented below is a +port of the same file; the transport is rewritten on stdlib ``usbdevfs`` +ioctls (:mod:`microphone_cli.usbctl`) instead of pyusb, since microphone-cli +carries no runtime dependencies. + +Two deliberate deviations from the upstream port, both bug fixes: upstream +returned the ``uint8`` / ``int32`` / ``uint32`` reply *including* the status +byte and without unpacking the 32-bit types; here the status byte is stripped +and 32-bit types are unpacked little-endian, so a read returns exactly +``count`` values of the declared type. +""" + +from __future__ import annotations + +import os +import struct +import time +from dataclasses import dataclass +from typing import Any, Callable, Sequence + +from .cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from .usbctl import DEFAULT_TIMEOUT_MS, control_transfer + +__all__ = [ + "PARAMETERS", + "PERSISTENT", + "KNOWN_IDS", + "FIRMWARE_OVERLAYS", + "SEEED_VENDOR", + "REACHY_VENDOR", + "parameters_for", + "ParamInfo", + "param_info", + "Xvf3800", +] + +#: Reply status byte values. +CONTROL_SUCCESS = 0 +SERVICER_COMMAND_RETRY = 64 + +#: Retry budget for a busy servicer: 100 attempts, 10 ms apart. +MAX_READ_ATTEMPTS = 100 +RETRY_DELAY_S = 0.01 + +REQUEST_TYPE_IN = 0xC0 # IN | VENDOR | DEVICE +REQUEST_TYPE_OUT = 0x40 # OUT | VENDOR | DEVICE +REQUEST = 0 +READ_BIT = 0x80 + +#: USB ids known to speak this protocol. +KNOWN_IDS: dict[tuple[str, str], str] = { + ("38fb", "1001"): "Reachy Mini Audio", + ("2886", "001a"): "ReSpeaker XVF3800 (Seeed USB firmware)", +} + +#: USB vendor id of Seeed Studio boards running Seeed's own USB firmware. +SEEED_VENDOR = "2886" +#: USB vendor id of Pollen Robotics' Reachy Mini Audio firmware (the base table). +REACHY_VENDOR = "38fb" + +# Testing seam: replaced in tests so the retry loop costs no wall-clock time. +_sleep: Callable[[float], None] = time.sleep + +# name -> (resid, cmdid, count, access, type) +# Vendored verbatim from reachy_mini/media/audio_control_utils.py (Apache-2.0). +PARAMETERS: dict[str, tuple[int, int, int, str, str]] = { + # APPLICATION_SERVICER_RESID commands + "VERSION": (48, 0, 3, "ro", "uint8"), + "BLD_MSG": (48, 1, 50, "ro", "char"), + "BLD_HOST": (48, 2, 30, "ro", "char"), + "BLD_REPO_HASH": (48, 3, 40, "ro", "char"), + "BLD_MODIFIED": (48, 4, 6, "ro", "char"), + "BOOT_STATUS": (48, 5, 3, "ro", "char"), + "TEST_CORE_BURN": (48, 6, 1, "rw", "uint8"), + "REBOOT": (48, 7, 1, "wo", "uint8"), + "USB_BIT_DEPTH": (48, 8, 2, "rw", "uint8"), + "SAVE_CONFIGURATION": (48, 9, 1, "wo", "uint8"), + "CLEAR_CONFIGURATION": (48, 10, 1, "wo", "uint8"), + # AEC_RESID commands + "SHF_BYPASS": (33, 70, 1, "rw", "uint8"), + "AEC_NUM_MICS": (33, 71, 1, "ro", "int32"), + "AEC_NUM_FARENDS": (33, 72, 1, "ro", "int32"), + "AEC_MIC_ARRAY_TYPE": (33, 73, 1, "ro", "int32"), + "AEC_MIC_ARRAY_GEO": (33, 74, 12, "ro", "float"), + "AEC_AZIMUTH_VALUES": (33, 75, 4, "ro", "radians"), + "TEST_AEC_DISABLE_CONTROL": (33, 76, 1, "wo", "uint32"), + "AEC_CURRENT_IDLE_TIME": (33, 77, 1, "ro", "uint32"), + "AEC_MIN_IDLE_TIME": (33, 78, 1, "ro", "uint32"), + "AEC_RESET_MIN_IDLE_TIME": (33, 79, 1, "wo", "uint32"), + "AEC_SPENERGY_VALUES": (33, 80, 4, "ro", "float"), + "AEC_FIXEDBEAMSAZIMUTH_VALUES": (33, 81, 2, "rw", "radians"), + "AEC_FIXEDBEAMSELEVATION_VALUES": (33, 82, 2, "rw", "radians"), + "AEC_FIXEDBEAMSGATING": (33, 83, 1, "rw", "uint8"), + "SPECIAL_CMD_AEC_FAR_MIC_INDEX": (33, 90, 2, "wo", "int32"), + "SPECIAL_CMD_AEC_FILTER_COEFF_START_OFFSET": (33, 91, 1, "rw", "int32"), + "SPECIAL_CMD_AEC_FILTER_COEFFS": (33, 92, 15, "rw", "float"), + "SPECIAL_CMD_AEC_FILTER_LENGTH": (33, 93, 1, "ro", "int32"), + "AEC_FILTER_CMD_ABORT": (33, 94, 1, "wo", "int32"), + "AEC_AECPATHCHANGE": (33, 0, 1, "ro", "int32"), + "AEC_HPFONOFF": (33, 1, 1, "rw", "int32"), + "AEC_AECSILENCELEVEL": (33, 2, 2, "rw", "float"), + "AEC_AECCONVERGED": (33, 3, 1, "ro", "int32"), + "AEC_AECEMPHASISONOFF": (33, 4, 1, "rw", "int32"), + "AEC_FAR_EXTGAIN": (33, 5, 1, "rw", "float"), + "AEC_PCD_COUPLINGI": (33, 6, 1, "rw", "float"), + "AEC_PCD_MINTHR": (33, 7, 1, "rw", "float"), + "AEC_PCD_MAXTHR": (33, 8, 1, "rw", "float"), + "AEC_RT60": (33, 9, 1, "ro", "float"), + "AEC_ASROUTONOFF": (33, 35, 1, "rw", "int32"), + "AEC_ASROUTGAIN": (33, 36, 1, "rw", "float"), + "AEC_FIXEDBEAMSONOFF": (33, 37, 1, "rw", "int32"), + "AEC_FIXEDBEAMNOISETHR": (33, 38, 2, "rw", "float"), + # AUDIO_MGR_RESID commands + "AUDIO_MGR_MIC_GAIN": (35, 0, 1, "rw", "float"), + "AUDIO_MGR_REF_GAIN": (35, 1, 1, "rw", "float"), + "AUDIO_MGR_CURRENT_IDLE_TIME": (35, 2, 1, "ro", "int32"), + "AUDIO_MGR_MIN_IDLE_TIME": (35, 3, 1, "ro", "int32"), + "AUDIO_MGR_RESET_MIN_IDLE_TIME": (35, 4, 1, "wo", "int32"), + "MAX_CONTROL_TIME": (35, 5, 1, "ro", "int32"), + "RESET_MAX_CONTROL_TIME": (35, 6, 1, "wo", "int32"), + "I2S_CURRENT_IDLE_TIME": (35, 7, 1, "ro", "int32"), + "I2S_MIN_IDLE_TIME": (35, 8, 1, "ro", "int32"), + "I2S_RESET_MIN_IDLE_TIME": (35, 9, 1, "wo", "int32"), + "I2S_INPUT_PACKED": (35, 10, 1, "rw", "uint8"), + "AUDIO_MGR_SELECTED_AZIMUTHS": (35, 11, 2, "ro", "radians"), + "AUDIO_MGR_SELECTED_CHANNELS": (35, 12, 2, "rw", "uint8"), + "AUDIO_MGR_OP_PACKED": (35, 13, 2, "rw", "uint8"), + "AUDIO_MGR_OP_UPSAMPLE": (35, 14, 2, "rw", "uint8"), + "AUDIO_MGR_OP_L": (35, 15, 2, "rw", "uint8"), + "AUDIO_MGR_OP_L_PK0": (35, 16, 2, "rw", "uint8"), + "AUDIO_MGR_OP_L_PK1": (35, 17, 2, "rw", "uint8"), + "AUDIO_MGR_OP_L_PK2": (35, 18, 2, "rw", "uint8"), + "AUDIO_MGR_OP_R": (35, 19, 2, "rw", "uint8"), + "AUDIO_MGR_OP_R_PK0": (35, 20, 2, "rw", "uint8"), + "AUDIO_MGR_OP_R_PK1": (35, 21, 2, "rw", "uint8"), + "AUDIO_MGR_OP_R_PK2": (35, 22, 2, "rw", "uint8"), + "AUDIO_MGR_OP_ALL": (35, 23, 12, "rw", "uint8"), + "I2S_INACTIVE": (35, 24, 1, "ro", "uint8"), + "AUDIO_MGR_FAR_END_DSP_ENABLE": (35, 25, 1, "rw", "uint8"), + "AUDIO_MGR_SYS_DELAY": (35, 26, 1, "rw", "int32"), + "I2S_DAC_DSP_ENABLE": (35, 27, 1, "rw", "uint8"), + # GPO_SERVICER_RESID commands + "GPO_READ_VALUES": (20, 0, 5, "ro", "uint8"), + "GPO_WRITE_VALUE": (20, 1, 2, "wo", "uint8"), + "GPO_PORT_PIN_INDEX": (20, 2, 2, "rw", "uint32"), + "GPO_PIN_VAL": (20, 3, 3, "wo", "uint8"), + "GPO_PIN_ACTIVE_LEVEL": (20, 4, 1, "rw", "uint32"), + "GPO_PIN_PWM_DUTY": (20, 5, 1, "rw", "uint8"), + "GPO_PIN_FLASH_MASK": (20, 6, 1, "rw", "uint32"), + "LED_EFFECT": (20, 12, 1, "rw", "uint8"), + "LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8"), + "LED_GAMMIFY": (20, 14, 1, "rw", "uint8"), + "LED_SPEED": (20, 15, 1, "rw", "uint8"), + "LED_COLOR": (20, 16, 1, "rw", "uint32"), + "LED_DOA_COLOR": (20, 17, 2, "rw", "uint32"), + "DOA_VALUE": (20, 18, 2, "ro", "uint32"), + "DOA_VALUE_RADIANS": (20, 19, 2, "ro", "radians"), + # PP_RESID commands + "PP_CURRENT_IDLE_TIME": (17, 70, 1, "ro", "uint32"), + "PP_MIN_IDLE_TIME": (17, 71, 1, "ro", "uint32"), + "PP_RESET_MIN_IDLE_TIME": (17, 72, 1, "wo", "uint32"), + "SPECIAL_CMD_PP_NLMODEL_NROW_NCOL": (17, 90, 2, "ro", "int32"), + "SPECIAL_CMD_NLMODEL_START": (17, 91, 1, "wo", "int32"), + "SPECIAL_CMD_NLMODEL_COEFF_START_OFFSET": (17, 92, 1, "rw", "int32"), + "SPECIAL_CMD_PP_NLMODEL": (17, 93, 15, "rw", "float"), + "PP_NL_MODEL_CMD_ABORT": (17, 94, 1, "wo", "int32"), + "SPECIAL_CMD_PP_NLMODEL_BAND": (17, 95, 1, "rw", "uint8"), + "SPECIAL_CMD_PP_EQUALIZATION_NUM_BANDS": (17, 96, 1, "ro", "int32"), + "SPECIAL_CMD_EQUALIZATION_START": (17, 97, 1, "wo", "int32"), + "SPECIAL_CMD_EQUALIZATION_COEFF_START_OFFSET": (17, 98, 1, "rw", "int32"), + "SPECIAL_CMD_PP_EQUALIZATION": (17, 99, 15, "rw", "float"), + "PP_EQUALIZATION_CMD_ABORT": (17, 100, 1, "wo", "int32"), + "PP_AGCONOFF": (17, 10, 1, "rw", "int32"), + "PP_AGCMAXGAIN": (17, 11, 1, "rw", "float"), + "PP_AGCDESIREDLEVEL": (17, 12, 1, "rw", "float"), + "PP_AGCGAIN": (17, 13, 1, "rw", "float"), + "PP_AGCTIME": (17, 14, 1, "rw", "float"), + "PP_AGCFASTTIME": (17, 15, 1, "rw", "float"), + "PP_AGCALPHAFASTGAIN": (17, 16, 1, "rw", "float"), + "PP_AGCALPHASLOW": (17, 17, 1, "rw", "float"), + "PP_AGCALPHAFAST": (17, 18, 1, "rw", "float"), + "PP_LIMITONOFF": (17, 19, 1, "rw", "int32"), + "PP_LIMITPLIMIT": (17, 20, 1, "rw", "float"), + "PP_MIN_NS": (17, 21, 1, "rw", "float"), + "PP_MIN_NN": (17, 22, 1, "rw", "float"), + "PP_ECHOONOFF": (17, 23, 1, "rw", "int32"), + "PP_GAMMA_E": (17, 24, 1, "rw", "float"), + "PP_GAMMA_ETAIL": (17, 25, 1, "rw", "float"), + "PP_GAMMA_ENL": (17, 26, 1, "rw", "float"), + "PP_NLATTENONOFF": (17, 27, 1, "rw", "int32"), + "PP_NLAEC_MODE": (17, 28, 1, "rw", "int32"), + "PP_MGSCALE": (17, 29, 3, "rw", "float"), + "PP_FMIN_SPEINDEX": (17, 30, 1, "rw", "float"), + "PP_DTSENSITIVE": (17, 31, 1, "rw", "int32"), + "PP_ATTNS_MODE": (17, 32, 1, "rw", "int32"), + "PP_ATTNS_NOMINAL": (17, 33, 1, "rw", "float"), + "PP_ATTNS_SLOPE": (17, 34, 1, "rw", "float"), +} + +#: Names whose write persists across a reboot, reboots the device, or is +#: otherwise destructive — a later `param set` should require confirmation. +PERSISTENT: frozenset[str] = frozenset( + { + "SAVE_CONFIGURATION", + "CLEAR_CONFIGURATION", + "REBOOT", + "TEST_CORE_BURN", + "TEST_AEC_DISABLE_CONTROL", + "USB_BIT_DEPTH", + } + | {name for name in PARAMETERS if name.startswith("SPECIAL_CMD_")} +) + +_FLOAT_TYPES = ("float", "radians") +_WIDE_TYPES = ("float", "radians", "int32", "uint32") +_HALF_TYPES = ("uint16",) + +# Per-firmware overlays on top of PARAMETERS, keyed by USB vendor id. +# ``None`` removes an entry the firmware does not implement. +# +# Found on hardware (2026-09-06, ReSpeaker XVF3800 USB firmware v2.1.0, +# build ``ua-io16-sqr``): Seeed's firmware has no DOA_VALUE_RADIANS, its +# DOA_VALUE is two uint16 (degrees 0-359, speech flag) rather than two uint32, +# and it adds LED_RING_COLOR and the AIC3104 output levels. Entries taken from +# respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY python_control/xvf_host.py. +FIRMWARE_OVERLAYS: dict[str, dict[str, tuple[int, int, int, str, str] | None]] = { + SEEED_VENDOR: { + "DOA_VALUE": (20, 18, 2, "ro", "uint16"), + "DOA_VALUE_RADIANS": None, + "LED_RING_COLOR": (20, 19, 12, "rw", "uint32"), + "AIC3104_HP_LEVEL": (48, 11, 1, "rw", "uint8"), + "AIC3104_LINEOUT_LEVEL": (48, 12, 1, "rw", "uint8"), + "GPO_PIN_PWM_DUTY": None, + "GPO_PIN_FLASH_MASK": None, + "SPECIAL_CMD_NLMODEL_START": None, + "SPECIAL_CMD_NLMODEL_COEFF_START_OFFSET": None, + "SPECIAL_CMD_PP_NLMODEL": None, + "SPECIAL_CMD_PP_NLMODEL_BAND": None, + "SPECIAL_CMD_PP_NLMODEL_NROW_NCOL": None, + "SPECIAL_CMD_PP_EQUALIZATION_NUM_BANDS": None, + "SPECIAL_CMD_EQUALIZATION_START": None, + "SPECIAL_CMD_EQUALIZATION_COEFF_START_OFFSET": None, + "SPECIAL_CMD_PP_EQUALIZATION": None, + } +} + + +def parameters_for(vendor: str | None) -> dict[str, tuple[int, int, int, str, str]]: + """The parameter table for the firmware behind USB ``vendor`` (base when unknown).""" + table = dict(PARAMETERS) + for name, row in FIRMWARE_OVERLAYS.get((vendor or "").lower(), {}).items(): + if row is None: + table.pop(name, None) + else: + table[name] = row + return table + + +@dataclass(frozen=True) +class ParamInfo: + """One row of :data:`PARAMETERS`, resolved and named.""" + + name: str + resid: int + cmdid: int + count: int + access: str + type: str + persistent: bool + + def to_dict(self) -> dict[str, object]: + return { + "name": self.name, + "resid": self.resid, + "cmdid": self.cmdid, + "count": self.count, + "access": self.access, + "type": self.type, + "persistent": self.persistent, + } + + +def param_info(name: str, vendor: str | None = None) -> ParamInfo: + """Resolve a parameter name (case-insensitive) to its :class:`ParamInfo`. + + ``vendor`` selects the firmware overlay (see :data:`FIRMWARE_OVERLAYS`). + """ + key = str(name).strip().upper() + try: + resid, cmdid, count, access, type_ = parameters_for(vendor)[key] + except KeyError: + raise CliError( + code=EXIT_USER_ERROR, + message=f"unknown parameter: {name}" + + (f" (not implemented by vendor {vendor} firmware)" if vendor else ""), + remediation=( + "Run `microphone param list` to see the parameter names this firmware exposes." + ), + ) from None + return ParamInfo( + name=key, + resid=resid, + cmdid=cmdid, + count=count, + access=access, + type=type_, + persistent=key in PERSISTENT, + ) + + +def _read_length(info: ParamInfo) -> int: + """Bytes to request for a read: payload plus the leading status byte.""" + if info.type in _WIDE_TYPES: + return info.count * 4 + 1 + if info.type in _HALF_TYPES: + return info.count * 2 + 1 + return info.count + 1 + + +class Xvf3800: + """Parameter read/write against one XVF3800 device. + + Construct with either an open usbdevfs file descriptor (an ``int``, e.g. + from :func:`microphone_cli.usbctl.open_device`) or a callable with the + signature ``transfer(request_type, request, value, index, data_or_length)`` + returning ``bytes`` for IN transfers — the latter is what tests inject. + """ + + def __init__( + self, + target: int | Callable[..., Any], + timeout_ms: int | None = None, + vendor: str | None = None, + ) -> None: + #: USB vendor id, selecting the firmware overlay for name resolution. + self.vendor = (vendor or "").lower() or None + self._timeout_ms = DEFAULT_TIMEOUT_MS if timeout_ms is None else timeout_ms + self._fd: int | None = None + if callable(target): + self._transfer = target + else: + self._fd = int(target) + self._transfer = self._fd_transfer + + def _fd_transfer( + self, + request_type: int, + request: int, + value: int, + index: int, + data_or_length: int | bytes, + ) -> Any: + assert self._fd is not None + return control_transfer( + self._fd, + request_type, + request, + value, + index, + data_or_length, + timeout_ms=self._timeout_ms, + ) + + # -- read --------------------------------------------------------------- + + def read(self, name: str) -> Any: + """Read a parameter, retrying while the servicer reports status 64.""" + info = param_info(name, self.vendor) + if info.access == "wo": + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} is write-only and cannot be read", + remediation="Use `microphone param set` for write-only parameters.", + ) + + value = READ_BIT | info.cmdid + length = _read_length(info) + + for attempt in range(MAX_READ_ATTEMPTS): + response = self._transfer(REQUEST_TYPE_IN, REQUEST, value, info.resid, length) + data = bytes(response) + if not data: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"empty reply reading {info.name}", + remediation=( + "Replug the device and retry; the firmware returned no status byte." + ), + ) + status = data[0] + if status == CONTROL_SUCCESS: + return _decode(info, data) + if status != SERVICER_COMMAND_RETRY: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"unknown status code {status} reading {info.name}", + remediation=( + "The firmware rejected the command (status 66 usually means a length " + "mismatch). Check the parameter is supported by this firmware: " + "`microphone inspect --json` shows its version and build." + ), + ) + if attempt < MAX_READ_ATTEMPTS - 1: + _sleep(RETRY_DELAY_S) + + raise CliError( + code=EXIT_ENV_ERROR, + message=( + f"device stayed busy reading {info.name} " + f"after {MAX_READ_ATTEMPTS} attempts (status 64)" + ), + remediation=( + "Another controller may be holding the device — reachy-mini-daemon is the usual " + "one. Stop it (`systemctl --user stop reachy-mini-daemon`) or wait for it to " + "release the device, then retry." + ), + ) + + # -- write -------------------------------------------------------------- + + def write(self, name: str, values: Sequence[Any] | str) -> None: + """Write a parameter. Refuses read-only names and wrong value counts.""" + info = param_info(name, self.vendor) + if info.access == "ro": + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} is read-only and cannot be written", + remediation="Use `microphone param get` to read it.", + ) + payload = _encode(info, values) + self._transfer(REQUEST_TYPE_OUT, REQUEST, info.cmdid, info.resid, payload) + + # -- convenience -------------------------------------------------------- + + def firmware_info(self) -> dict[str, str]: + """Return ``{version, build, host, repo_hash}`` from the app servicer.""" + version = self.read("VERSION") + return { + "version": ".".join(str(int(part)) for part in version), + "build": str(self.read("BLD_MSG")), + "host": str(self.read("BLD_HOST")), + "repo_hash": str(self.read("BLD_REPO_HASH")), + } + + def close(self) -> None: + """Close the file descriptor, if this object opened one.""" + if self._fd is not None: + os.close(self._fd) + self._fd = None + + def __enter__(self) -> "Xvf3800": + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + +def _decode(info: ParamInfo, data: bytes) -> Any: + body = data[1:] + if info.type == "char": + return body.rstrip(b"\x00").decode("utf-8", errors="ignore") + if info.type == "uint8": + if len(body) < info.count: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"short reply reading {info.name}: {len(body)} of {info.count} bytes", + remediation="Replug the device and retry.", + ) + return list(body[: info.count]) + fmt = {"float": "f", "radians": "f", "int32": "i", "uint32": "I", "uint16": "H"}[info.type] + need = info.count * (2 if info.type in _HALF_TYPES else 4) + if len(body) < need: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"short reply reading {info.name}: {len(body)} of {need} bytes", + remediation="Replug the device and retry.", + ) + return list(struct.unpack("<" + fmt * info.count, body[:need])) + + +def _encode(info: ParamInfo, values: Sequence[Any] | str) -> bytes: + if info.type == "char": + raw = values.encode("utf-8") if isinstance(values, str) else bytes(bytearray(values)) + if len(raw) > info.count: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} accepts at most {info.count} characters, got {len(raw)}", + remediation=f"Shorten the value to {info.count} characters or fewer.", + ) + return raw + + if isinstance(values, str) or len(values) != info.count: + given = 1 if isinstance(values, str) else len(values) + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} takes {info.count} value(s), got {given}", + remediation=f"Pass exactly {info.count} value(s) of type {info.type}.", + ) + + try: + if info.type in _FLOAT_TYPES: + return struct.pack("<" + "f" * info.count, *(float(v) for v in values)) + if info.type == "uint8": + ints = [int(v) for v in values] + _check_int_range(info.name, ints, 0, 255, "uint8") + return bytes(bytearray(ints)) + if info.type in _HALF_TYPES: + ints = [int(v) for v in values] + _check_int_range(info.name, ints, 0, 65535, "uint16") + return struct.pack("<" + "H" * info.count, *ints) + fmt = "i" if info.type == "int32" else "I" + return struct.pack("<" + fmt * info.count, *(int(v) for v in values)) + except (TypeError, ValueError, struct.error) as exc: + raise CliError( + code=EXIT_USER_ERROR, + message=f"{info.name} value(s) not valid for type {info.type}: {exc}", + remediation=f"Pass {info.count} value(s) that fit type {info.type}.", + ) from exc + + +def _check_int_range(name: str, ints: Sequence[int], lo: int, hi: int, type_name: str) -> None: + """Raise a user-facing :class:`CliError` naming the offending value and range. + + ``& 0xFF``-style truncation silently turns -1 into 255 and 256 into 0, so this + check runs *before* any packing/masking — a bad value must be refused, not + silently reinterpreted and sent to the firmware. + """ + for value in ints: + if not lo <= value <= hi: + raise CliError( + code=EXIT_USER_ERROR, + message=(f"{name} value {value} out of range for {type_name}: must be {lo}..{hi}"), + remediation=f"Pass value(s) in {lo}..{hi}.", + ) diff --git a/pyproject.toml b/pyproject.toml index 00a9bf6..b43fd37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "microphone-cli" -version = "0.8.2" +version = "0.9.0" description = "Agent-first CLI for USB microphones and microphone arrays: enumerate devices, select and inspect channels, control gain and sample format, and read direction-of-arrival from array firmware." readme = "README.md" license = "Apache-2.0" diff --git a/scripts/acceptance/blind-consumer.sh b/scripts/acceptance/blind-consumer.sh new file mode 100755 index 0000000..06c7013 --- /dev/null +++ b/scripts/acceptance/blind-consumer.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Blind consumer: attach to a live `microphone stream audio` using nothing but +# its --json payload. +# +# Structurally blind by design. Its ONLY input is the payload file. It never +# runs `microphone`, never reads /dev or /proc/asound, and is never told the +# device's stable id. If it can receive and depayload audio from that file +# alone, the attachment contract holds; if it needs any fact the payload did +# not announce, that is a finding, not something to patch here. +# +# Usage: blind-consumer.sh [seconds] +# +# Evidence goes to (the caller passes a directory under $TMPDIR and +# deletes it). Nothing is written inside a git repository. + +set -euo pipefail + +payload="${1:?usage: blind-consumer.sh [seconds]}" +workdir="${2:?usage: blind-consumer.sh [seconds]}" +seconds="${3:-4}" + +[ -r "$payload" ] || { echo "blind-consumer: cannot read $payload" >&2; exit 2; } +mkdir -p "$workdir" + +# --- everything below is derived from the payload alone --------------------- + +get() { python3 -c 'import json,sys +d=json.load(open(sys.argv[1])) +for k in sys.argv[2].split("."): + d=d[k] +print(d)' "$payload" "$1"; } + +uri=$(get attach.uri) +host=$(get attach.host) +port=$(get attach.port) +encode=$(get attach.encode) +announced=$(get "attach.consumer.$encode") + +echo "blind-consumer: payload announces uri=$uri encode=$encode" +echo "blind-consumer: announced consumer command:" +echo " $announced" + +pass=0; fail=0 +ok() { pass=$((pass + 1)); echo " -> PASS: $1"; } +bad() { fail=$((fail + 1)); echo " -> FAIL: $1"; } + +# 1. The announced command, verbatim, for a bounded time. gst-launch treats +# SIGINT as "send EOS and exit 0", so 0 and timeout's 124 both mean it ran. +set +e +timeout --signal=INT "$seconds" bash -c "$announced" >"$workdir/announced.log" 2>&1 +status=$? +set -e +if [ "$status" -eq 0 ] || [ "$status" -eq 124 ]; then + if grep -qi "error" "$workdir/announced.log"; then + bad "announced consumer logged an ERROR (see announced.log)" + else + ok "announced consumer ran for ${seconds}s without error" + fi +else + bad "announced consumer exited $status (see announced.log)" +fi + +# 2. Count packets: swap the sink for fakesink, keep the announced caps, and +# stop after 50 buffers. Proves bytes actually arrive on the announced port. +counting=$(printf '%s' "$announced" | sed -E 's/! *autoaudiosink *$/! fakesink num-buffers=50/; s/udpsrc /udpsrc num-buffers=50 /') +set +e +timeout "$seconds" bash -c "$counting -v" >"$workdir/count.log" 2>&1 +status=$? +set -e +if [ "$status" -eq 0 ] && grep -q "Got EOS" "$workdir/count.log"; then + ok "50 RTP buffers received on $host:$port and depayloaded" +else + bad "packet count run exited $status without EOS (see count.log)" +fi + +echo "blind-consumer: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/acceptance/run.sh b/scripts/acceptance/run.sh new file mode 100755 index 0000000..8506af9 --- /dev/null +++ b/scripts/acceptance/run.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# On-device acceptance for the microphone-domain build plan (task t13). +# +# Drives every verb from a stable device id and --json alone against real +# hardware, then hands `stream audio`'s payload to a blind consumer that is +# never told the device. Headless, non-interactive, re-runnable. +# +# scripts/acceptance/run.sh --device STABLE_ID [--writes] [--media] +# [--port N] [--seconds N] [--skip-suite] +# +# Read-only by default. --writes adds the volatile gain and AEC round trips +# (restored afterwards; never --allow-persistent). --media adds record and +# stream, which switch the microphone on. +# +# PRIVACY. With --media this records audio. Every artifact goes under a run +# directory in $TMPDIR — never inside the repository — and is deleted on exit, +# including on failure. Only byte counts, formats and parameter values remain. + +set -euo pipefail + +DEVICE="${MICROPHONE_ACCEPTANCE_DEVICE:-}" +PORT=5004; SECONDS_PER_STEP=4; WRITES=0; MEDIA=0; RUN_SUITE=1 +while [ $# -gt 0 ]; do + case "$1" in + --device) DEVICE="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --seconds) SECONDS_PER_STEP="$2"; shift 2 ;; + --writes) WRITES=1; shift ;; + --media) MEDIA=1; shift ;; + --skip-suite) RUN_SUITE=0; shift ;; + -h|--help) sed -n '2,18p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done +[ -n "$DEVICE" ] || { echo "run.sh: --device STABLE_ID is required (see 'microphone list --json')" >&2; exit 2; } + +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(git -C "$HERE" rev-parse --show-toplevel) +RUN=$(mktemp -d -t microphone-acceptance.XXXXXX) +export MICROPHONE_ACTIVATION_LOG="$RUN/activation.jsonl" +cleanup() { rm -rf "$RUN"; } +trap cleanup EXIT + +M() { (cd "$REPO" && uv run microphone "$@"); } +J() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1], {"d": d}))' "$1"; } +pass=0; fail=0 +ok() { pass=$((pass + 1)); echo "PASS: $1"; } +bad() { fail=$((fail + 1)); echo "FAIL: $1"; } +step() { echo; echo "== $1"; } + +step "0. hardware-free suite" +if [ "$RUN_SUITE" -eq 1 ]; then + (cd "$REPO" && uv run pytest -n auto -q 2>&1 | tail -1) +fi + +step "1. list: the device is present by stable id" +if M list --json | J "[x['stable_id'] for x in d['devices']]" | grep -q "$DEVICE"; then ok "list names $DEVICE"; else bad "list does not name $DEVICE"; fi + +step "2. inspect: formats, rates, channels, firmware" +INSPECT=$(M inspect "$DEVICE" --json) +echo "$INSPECT" | J "(d['formats'], d['rates'], d['channels'], d['firmware'])" +if echo "$INSPECT" | J "bool(d['rates']) and bool(d['formats'])" | grep -q True; then ok "inspect advertises formats and rates"; else bad "inspect has no advertised format"; fi +IS_ARRAY=$(echo "$INSPECT" | J "d['device']['is_array']") + +step "3. gain get" +M gain get "$DEVICE" --json | J "(d['alsa'], d['firmware'])" && ok "gain get" + +if [ "$IS_ARRAY" = "True" ]; then + step "4. array doa: one sample and a 3-line watch" + DOA=$(M array doa "$DEVICE" --json); echo "$DOA" | J "(d['azimuth_deg'], d['azimuth_rad'], d['speech'], d['source'])" + LINES=$(M array doa "$DEVICE" --watch --count 3 --interval 0.3 | wc -l) + [ "$LINES" -eq 3 ] && ok "doa --watch --count 3 emitted 3 JSON lines" || bad "doa --watch emitted $LINES lines" + + step "5. array aec get" + M array aec get "$DEVICE" --json | J "{k: d[k] for k in ('converged','bypass','hpf','echo','num_mics')}" && ok "aec get reports 5+ fields" + + step "6. param: version and a persistent-tier refusal" + M param get "$DEVICE" VERSION --json | J "d['values']" + if M param set "$DEVICE" SAVE_CONFIGURATION 1 --apply --json >/dev/null 2>&1; then bad "SAVE_CONFIGURATION was NOT refused"; else ok "SAVE_CONFIGURATION refused without --allow-persistent"; fi +fi + +if [ "$WRITES" -eq 1 ]; then + step "7. volatile writes, restored" + BEFORE=$(M gain get "$DEVICE" --json | J "d['alsa']['value']") + MAX=$(M gain get "$DEVICE" --json | J "d['alsa']['max']") + AFTER=$(M gain set "$DEVICE" 0.5 --target alsa --apply --json | J "d['alsa']['value']") + [ "$AFTER" != "$BEFORE" ] && ok "alsa gain moved $BEFORE -> $AFTER" || bad "alsa gain did not move" + M gain set "$DEVICE" "$(python3 -c "print($BEFORE/$MAX)")" --target alsa --apply --json >/dev/null && echo "restored alsa gain to $BEFORE" + if [ "$IS_ARRAY" = "True" ]; then + ECHO=$(M array aec get "$DEVICE" --json | J "d['echo']") + TOGGLE=$([ "$ECHO" = "True" ] && echo off || echo on) + NEW=$(M array aec set "$DEVICE" --echo "$TOGGLE" --apply --json | J "d['state']['echo']") + [ "$NEW" != "$ECHO" ] && ok "aec echo toggled $ECHO -> $NEW" || bad "aec echo did not toggle" + M array aec set "$DEVICE" --echo "$([ "$ECHO" = "True" ] && echo on || echo off)" --apply --json >/dev/null && echo "restored aec echo to $ECHO" + fi +fi + +if [ "$MEDIA" -eq 1 ]; then + step "8. record 2 s with advertised format, under \$TMPDIR" + REC=$(M record "$DEVICE" "$RUN/clip.wav" --duration 2 --apply --json) + echo "$REC" | J "(d['bytes_written'], d['stopped_reason'], d['audio_format']['requested'])" + [ "$(echo "$REC" | J "d['bytes_written'] > 44")" = True ] && ok "record wrote audio" || bad "record wrote nothing" + + step "9. stream + blind consumer (never told the device)" + M stream audio "$DEVICE" --port "$PORT" --apply --json >"$RUN/stream.json" + PID=$(J "d['pid']" <"$RUN/stream.json") + sleep 1 + if bash "$HERE/blind-consumer.sh" "$RUN/stream.json" "$RUN/consumer" "$SECONDS_PER_STEP"; then ok "blind consumer attached from the payload alone"; else bad "blind consumer could not attach"; fi + kill -INT "$PID" 2>/dev/null || true; sleep 1 +fi + +step "10. activation log" +APPLIES=$( [ -f "$MICROPHONE_ACTIVATION_LOG" ] && wc -l <"$MICROPHONE_ACTIVATION_LOG" || echo 0 ) +echo "activation lines: $APPLIES (one per --apply; reads and dry runs add none)" + +echo; echo "== $pass passed, $fail failed (media under $RUN deleted on exit)" +[ "$fail" -eq 0 ] diff --git a/tests/fixtures/host-baseline/proc/asound/card0/pcm3p/info b/tests/fixtures/host-baseline/proc/asound/card0/pcm3p/info new file mode 100644 index 0000000..0e3cb20 --- /dev/null +++ b/tests/fixtures/host-baseline/proc/asound/card0/pcm3p/info @@ -0,0 +1,11 @@ +card: 0 +device: 3 +subdevice: 0 +stream: PLAYBACK +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/host-baseline/proc/asound/card1/pcm0c/info b/tests/fixtures/host-baseline/proc/asound/card1/pcm0c/info new file mode 100644 index 0000000..d9be89c --- /dev/null +++ b/tests/fixtures/host-baseline/proc/asound/card1/pcm0c/info @@ -0,0 +1,11 @@ +card: 1 +device: 0 +subdevice: 0 +stream: CAPTURE +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/host-baseline/proc/asound/card1/pcm0p/info b/tests/fixtures/host-baseline/proc/asound/card1/pcm0p/info new file mode 100644 index 0000000..83e312b --- /dev/null +++ b/tests/fixtures/host-baseline/proc/asound/card1/pcm0p/info @@ -0,0 +1,11 @@ +card: 1 +device: 0 +subdevice: 0 +stream: PLAYBACK +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/host-baseline/proc/asound/card1/stream0 b/tests/fixtures/host-baseline/proc/asound/card1/stream0 new file mode 100644 index 0000000..76bbb57 --- /dev/null +++ b/tests/fixtures/host-baseline/proc/asound/card1/stream0 @@ -0,0 +1,19 @@ +Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.1, high speed : USB Audio + +Playback: + Status: Stop + Interface 1 + Altset 1 + Format: S16_LE + Channels: 2 + Endpoint: 0x01 (1 OUT) (ADAPTIVE) + Rates: 48000 + +Capture: + Status: Stop + Interface 2 + Altset 1 + Format: S32_LE + Channels: 6 + Endpoint: 0x82 (2 IN) (ASYNC) + Rates: 48000 diff --git a/tests/fixtures/host-baseline/proc/asound/cards b/tests/fixtures/host-baseline/proc/asound/cards new file mode 100644 index 0000000..9b349d9 --- /dev/null +++ b/tests/fixtures/host-baseline/proc/asound/cards @@ -0,0 +1,4 @@ + 0 [NVIDIA ]: hda-acpi - NVIDIA + NVIDIA HDA Controller at 0x36078000 irq 310 + 1 [Audio ]: USB-Audio - Reachy Mini Audio + Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.1, high speed diff --git a/tests/fixtures/host-baseline/run/user/1000/pipewire-0 b/tests/fixtures/host-baseline/run/user/1000/pipewire-0 new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/host-baseline/sys/class/sound/card0 b/tests/fixtures/host-baseline/sys/class/sound/card0 new file mode 120000 index 0000000..f85fa6b --- /dev/null +++ b/tests/fixtures/host-baseline/sys/class/sound/card0 @@ -0,0 +1 @@ +../../devices/platform/NVDA2014:00/sound/card0 \ No newline at end of file diff --git a/tests/fixtures/host-baseline/sys/class/sound/card1 b/tests/fixtures/host-baseline/sys/class/sound/card1 new file mode 120000 index 0000000..0de24f2 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/class/sound/card1 @@ -0,0 +1 @@ +../../devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card1 \ No newline at end of file diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA2014:00/sound/card0/id b/tests/fixtures/host-baseline/sys/devices/platform/NVDA2014:00/sound/card0/id new file mode 100644 index 0000000..0dc7c42 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA2014:00/sound/card0/id @@ -0,0 +1 @@ +NVIDIA diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card1/id b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card1/id new file mode 100644 index 0000000..6d091b2 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card1/id @@ -0,0 +1 @@ +Audio diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idProduct b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idProduct new file mode 100644 index 0000000..dd11724 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idProduct @@ -0,0 +1 @@ +1001 diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idVendor b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idVendor new file mode 100644 index 0000000..e9e1d03 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idVendor @@ -0,0 +1 @@ +38fb diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/manufacturer b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/manufacturer new file mode 100644 index 0000000..83f609a --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/manufacturer @@ -0,0 +1 @@ +Pollen Robotics diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/product b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/product new file mode 100644 index 0000000..739e442 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/product @@ -0,0 +1 @@ +Reachy Mini Audio diff --git a/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/serial b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/serial new file mode 100644 index 0000000..5ff67b3 --- /dev/null +++ b/tests/fixtures/host-baseline/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/serial @@ -0,0 +1 @@ +RM0001 diff --git a/tests/fixtures/host-renumbered/proc/asound/card0/pcm3p/info b/tests/fixtures/host-renumbered/proc/asound/card0/pcm3p/info new file mode 100644 index 0000000..0e3cb20 --- /dev/null +++ b/tests/fixtures/host-renumbered/proc/asound/card0/pcm3p/info @@ -0,0 +1,11 @@ +card: 0 +device: 3 +subdevice: 0 +stream: PLAYBACK +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/host-renumbered/proc/asound/card2/pcm0c/info b/tests/fixtures/host-renumbered/proc/asound/card2/pcm0c/info new file mode 100644 index 0000000..7b3b5ef --- /dev/null +++ b/tests/fixtures/host-renumbered/proc/asound/card2/pcm0c/info @@ -0,0 +1,11 @@ +card: 2 +device: 0 +subdevice: 0 +stream: CAPTURE +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/host-renumbered/proc/asound/card2/pcm0p/info b/tests/fixtures/host-renumbered/proc/asound/card2/pcm0p/info new file mode 100644 index 0000000..d507c9c --- /dev/null +++ b/tests/fixtures/host-renumbered/proc/asound/card2/pcm0p/info @@ -0,0 +1,11 @@ +card: 2 +device: 0 +subdevice: 0 +stream: PLAYBACK +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/host-renumbered/proc/asound/card2/stream0 b/tests/fixtures/host-renumbered/proc/asound/card2/stream0 new file mode 100644 index 0000000..646cf76 --- /dev/null +++ b/tests/fixtures/host-renumbered/proc/asound/card2/stream0 @@ -0,0 +1,19 @@ +Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.3, high speed : USB Audio + +Playback: + Status: Stop + Interface 1 + Altset 1 + Format: S16_LE + Channels: 2 + Endpoint: 0x01 (1 OUT) (ADAPTIVE) + Rates: 48000 + +Capture: + Status: Stop + Interface 2 + Altset 1 + Format: S32_LE + Channels: 6 + Endpoint: 0x82 (2 IN) (ASYNC) + Rates: 48000 diff --git a/tests/fixtures/host-renumbered/proc/asound/cards b/tests/fixtures/host-renumbered/proc/asound/cards new file mode 100644 index 0000000..883ae2e --- /dev/null +++ b/tests/fixtures/host-renumbered/proc/asound/cards @@ -0,0 +1,4 @@ + 0 [NVIDIA ]: hda-acpi - NVIDIA + NVIDIA HDA Controller at 0x36078000 irq 310 + 2 [Audio ]: USB-Audio - Reachy Mini Audio + Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.3, high speed diff --git a/tests/fixtures/host-renumbered/run/user/1000/pipewire-0 b/tests/fixtures/host-renumbered/run/user/1000/pipewire-0 new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/host-renumbered/sys/class/sound/card0 b/tests/fixtures/host-renumbered/sys/class/sound/card0 new file mode 120000 index 0000000..f85fa6b --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/class/sound/card0 @@ -0,0 +1 @@ +../../devices/platform/NVDA2014:00/sound/card0 \ No newline at end of file diff --git a/tests/fixtures/host-renumbered/sys/class/sound/card2 b/tests/fixtures/host-renumbered/sys/class/sound/card2 new file mode 120000 index 0000000..1d8a40d --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/class/sound/card2 @@ -0,0 +1 @@ +../../devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card2 \ No newline at end of file diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA2014:00/sound/card0/id b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA2014:00/sound/card0/id new file mode 100644 index 0000000..0dc7c42 --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA2014:00/sound/card0/id @@ -0,0 +1 @@ +NVIDIA diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card2/id b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card2/id new file mode 100644 index 0000000..6d091b2 --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card2/id @@ -0,0 +1 @@ +Audio diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idProduct b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idProduct new file mode 100644 index 0000000..dd11724 --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idProduct @@ -0,0 +1 @@ +1001 diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idVendor b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idVendor new file mode 100644 index 0000000..e9e1d03 --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idVendor @@ -0,0 +1 @@ +38fb diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/manufacturer b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/manufacturer new file mode 100644 index 0000000..83f609a --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/manufacturer @@ -0,0 +1 @@ +Pollen Robotics diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/product b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/product new file mode 100644 index 0000000..739e442 --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/product @@ -0,0 +1 @@ +Reachy Mini Audio diff --git a/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/serial b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/serial new file mode 100644 index 0000000..5ff67b3 --- /dev/null +++ b/tests/fixtures/host-renumbered/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/serial @@ -0,0 +1 @@ +RM0001 diff --git a/tests/fixtures/respeaker/proc/asound/card0/pcm0c/info b/tests/fixtures/respeaker/proc/asound/card0/pcm0c/info new file mode 100644 index 0000000..f85fcf4 --- /dev/null +++ b/tests/fixtures/respeaker/proc/asound/card0/pcm0c/info @@ -0,0 +1,11 @@ +card: 0 +device: 0 +subdevice: 0 +stream: CAPTURE +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/respeaker/proc/asound/cards b/tests/fixtures/respeaker/proc/asound/cards new file mode 100644 index 0000000..f0f47e2 --- /dev/null +++ b/tests/fixtures/respeaker/proc/asound/cards @@ -0,0 +1,2 @@ + 0 [ArrayUAC10 ]: USB-Audio - ReSpeaker 4 Mic Array (UAC1.0) + SEEED ReSpeaker 4 Mic Array (UAC1.0) at usb-NVDA8000:01-1, full speed diff --git a/tests/fixtures/respeaker/sys/class/sound/card0 b/tests/fixtures/respeaker/sys/class/sound/card0 new file mode 120000 index 0000000..50ab906 --- /dev/null +++ b/tests/fixtures/respeaker/sys/class/sound/card0 @@ -0,0 +1 @@ +../../devices/platform/NVDA8000:01/usb3/3-1/3-1:1.0/sound/card0 \ No newline at end of file diff --git a/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/3-1:1.0/sound/card0/id b/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/3-1:1.0/sound/card0/id new file mode 100644 index 0000000..a25796b --- /dev/null +++ b/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/3-1:1.0/sound/card0/id @@ -0,0 +1 @@ +ArrayUAC10 diff --git a/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/idProduct b/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/idProduct new file mode 100644 index 0000000..ba94abf --- /dev/null +++ b/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/idProduct @@ -0,0 +1 @@ +001a diff --git a/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/idVendor b/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/idVendor new file mode 100644 index 0000000..88236a9 --- /dev/null +++ b/tests/fixtures/respeaker/sys/devices/platform/NVDA8000:01/usb3/3-1/idVendor @@ -0,0 +1 @@ +2886 diff --git a/tests/fixtures/two-arrays/proc/asound/card0/pcm0c/info b/tests/fixtures/two-arrays/proc/asound/card0/pcm0c/info new file mode 100644 index 0000000..f85fcf4 --- /dev/null +++ b/tests/fixtures/two-arrays/proc/asound/card0/pcm0c/info @@ -0,0 +1,11 @@ +card: 0 +device: 0 +subdevice: 0 +stream: CAPTURE +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/two-arrays/proc/asound/card0/stream0 b/tests/fixtures/two-arrays/proc/asound/card0/stream0 new file mode 100644 index 0000000..76bbb57 --- /dev/null +++ b/tests/fixtures/two-arrays/proc/asound/card0/stream0 @@ -0,0 +1,19 @@ +Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.1, high speed : USB Audio + +Playback: + Status: Stop + Interface 1 + Altset 1 + Format: S16_LE + Channels: 2 + Endpoint: 0x01 (1 OUT) (ADAPTIVE) + Rates: 48000 + +Capture: + Status: Stop + Interface 2 + Altset 1 + Format: S32_LE + Channels: 6 + Endpoint: 0x82 (2 IN) (ASYNC) + Rates: 48000 diff --git a/tests/fixtures/two-arrays/proc/asound/card1/pcm0c/info b/tests/fixtures/two-arrays/proc/asound/card1/pcm0c/info new file mode 100644 index 0000000..d9be89c --- /dev/null +++ b/tests/fixtures/two-arrays/proc/asound/card1/pcm0c/info @@ -0,0 +1,11 @@ +card: 1 +device: 0 +subdevice: 0 +stream: CAPTURE +id: USB Audio +name: USB Audio +subname: subdevice #0 +class: 0 +subclass: 0 +subdevices_count: 1 +subdevices_avail: 1 diff --git a/tests/fixtures/two-arrays/proc/asound/card1/stream0 b/tests/fixtures/two-arrays/proc/asound/card1/stream0 new file mode 100644 index 0000000..646cf76 --- /dev/null +++ b/tests/fixtures/two-arrays/proc/asound/card1/stream0 @@ -0,0 +1,19 @@ +Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.3, high speed : USB Audio + +Playback: + Status: Stop + Interface 1 + Altset 1 + Format: S16_LE + Channels: 2 + Endpoint: 0x01 (1 OUT) (ADAPTIVE) + Rates: 48000 + +Capture: + Status: Stop + Interface 2 + Altset 1 + Format: S32_LE + Channels: 6 + Endpoint: 0x82 (2 IN) (ASYNC) + Rates: 48000 diff --git a/tests/fixtures/two-arrays/proc/asound/cards b/tests/fixtures/two-arrays/proc/asound/cards new file mode 100644 index 0000000..f5e523f --- /dev/null +++ b/tests/fixtures/two-arrays/proc/asound/cards @@ -0,0 +1,4 @@ + 0 [Audio ]: USB-Audio - Reachy Mini Audio + Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.1, high speed + 1 [Audio_1 ]: USB-Audio - Reachy Mini Audio + Pollen Robotics Reachy Mini Audio at usb-NVDA8000:02-1.3, high speed diff --git a/tests/fixtures/two-arrays/run/user/1000/pipewire-0 b/tests/fixtures/two-arrays/run/user/1000/pipewire-0 new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/two-arrays/sys/class/sound/card0 b/tests/fixtures/two-arrays/sys/class/sound/card0 new file mode 120000 index 0000000..604432a --- /dev/null +++ b/tests/fixtures/two-arrays/sys/class/sound/card0 @@ -0,0 +1 @@ +../../devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card0 \ No newline at end of file diff --git a/tests/fixtures/two-arrays/sys/class/sound/card1 b/tests/fixtures/two-arrays/sys/class/sound/card1 new file mode 120000 index 0000000..34a1163 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/class/sound/card1 @@ -0,0 +1 @@ +../../devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card1 \ No newline at end of file diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card0/id b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card0/id new file mode 100644 index 0000000..6d091b2 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/5-1.1:1.0/sound/card0/id @@ -0,0 +1 @@ +Audio diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idProduct b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idProduct new file mode 100644 index 0000000..dd11724 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idProduct @@ -0,0 +1 @@ +1001 diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idVendor b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idVendor new file mode 100644 index 0000000..e9e1d03 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/idVendor @@ -0,0 +1 @@ +38fb diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/manufacturer b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/manufacturer new file mode 100644 index 0000000..83f609a --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/manufacturer @@ -0,0 +1 @@ +Pollen Robotics diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/product b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/product new file mode 100644 index 0000000..739e442 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/product @@ -0,0 +1 @@ +Reachy Mini Audio diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/serial b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/serial new file mode 100644 index 0000000..5ff67b3 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1/serial @@ -0,0 +1 @@ +RM0001 diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card1/id b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card1/id new file mode 100644 index 0000000..81dd539 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/5-1.3:1.0/sound/card1/id @@ -0,0 +1 @@ +Audio_1 diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idProduct b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idProduct new file mode 100644 index 0000000..dd11724 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idProduct @@ -0,0 +1 @@ +1001 diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idVendor b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idVendor new file mode 100644 index 0000000..e9e1d03 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/idVendor @@ -0,0 +1 @@ +38fb diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/manufacturer b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/manufacturer new file mode 100644 index 0000000..83f609a --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/manufacturer @@ -0,0 +1 @@ +Pollen Robotics diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/product b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/product new file mode 100644 index 0000000..739e442 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/product @@ -0,0 +1 @@ +Reachy Mini Audio diff --git a/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/serial b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/serial new file mode 100644 index 0000000..9d50798 --- /dev/null +++ b/tests/fixtures/two-arrays/sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.3/serial @@ -0,0 +1 @@ +RM0002 diff --git a/tests/test_access.py b/tests/test_access.py new file mode 100644 index 0000000..a96470a --- /dev/null +++ b/tests/test_access.py @@ -0,0 +1,255 @@ +"""Tests for microphone_cli.access — hardware-free, no real /dev or /proc access. + +Every scenario monkeypatches ``os.open`` (never opens a real device node) and, +for the BUSY/holder-lookup path, monkeypatches the module's private +``/proc`` helpers against a fake directory tree built under ``tmp_path``. +""" + +from __future__ import annotations + +import errno +from pathlib import Path + +import pytest + +from microphone_cli import access +from microphone_cli.cli._errors import ( + EXIT_BUSY_ERROR, + EXIT_ENV_ERROR, + EXIT_USER_ERROR, + CliError, +) + + +def _fake_proc_tree(tmp_path: Path, pid: int, command: str, target: str) -> Path: + """Build a fake /proc//{fd/0 -> target, comm} tree under tmp_path.""" + proc_dir = tmp_path / "proc" + fd_dir = proc_dir / str(pid) / "fd" + fd_dir.mkdir(parents=True) + (fd_dir / "3").symlink_to(target) + (proc_dir / str(pid) / "comm").write_text(f"{command}\n", encoding="utf-8") + return proc_dir + + +def _patch_proc(monkeypatch: pytest.MonkeyPatch, proc_dir: Path) -> None: + def _list_proc_pids() -> list[str]: + return [p.name for p in proc_dir.iterdir()] + + def _list_fds(pid: str) -> list[str]: + return [p.name for p in (proc_dir / pid / "fd").iterdir()] + + def _readlink(fd_path: str) -> str: + # fd_path looks like "/proc//fd/"; rebase onto proc_dir. + rel = Path(fd_path).relative_to("/proc") + import os as _os + + return _os.readlink(proc_dir / rel) + + def _read_command(pid: str) -> str: + return (proc_dir / pid / "comm").read_text(encoding="utf-8").strip() + + monkeypatch.setattr(access, "_list_proc_pids", _list_proc_pids) + monkeypatch.setattr(access, "_list_fds", _list_fds) + monkeypatch.setattr(access, "_readlink", _readlink) + monkeypatch.setattr(access, "_read_command", _read_command) + + +# --------------------------------------------------------------------------- # +# check_access — the four states +# --------------------------------------------------------------------------- # + + +def test_check_access_ok(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(access.os, "open", lambda path, flags: 7) + monkeypatch.setattr(access.os, "close", lambda fd: None) + + report = access.check_access("/dev/snd/pcmC0D0c", "audio") + + assert report.state is access.AccessState.OK + assert report.remediation == "" + assert report.holder is None + + +def test_check_access_absent(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(path: str, flags: int) -> int: + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + monkeypatch.setattr(access.os, "open", _raise) + + report = access.check_access("/dev/snd/pcmC9D9c", "audio") + + assert report.state is access.AccessState.ABSENT + assert "does not exist" in report.remediation + assert report.holder is None + + +def test_check_access_forbidden_audio_names_audio_group(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(path: str, flags: int) -> int: + raise PermissionError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(access.os, "open", _raise) + + report = access.check_access("/dev/snd/pcmC0D0c", "audio") + + assert report.state is access.AccessState.FORBIDDEN + assert "'audio' group" in report.remediation + + +def test_check_access_forbidden_usb_names_udev_rule(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(path: str, flags: int) -> int: + raise PermissionError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(access.os, "open", _raise) + + report = access.check_access("/dev/bus/usb/001/004", "usb") + + assert report.state is access.AccessState.FORBIDDEN + assert 'SUBSYSTEM=="usb", ATTR{idVendor}==' in report.remediation + + +def test_check_access_busy_with_identified_holder( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + target = "/dev/snd/pcmC0D0c" + proc_dir = _fake_proc_tree(tmp_path, pid=4242, command="arecord", target=target) + _patch_proc(monkeypatch, proc_dir) + monkeypatch.setattr(access.os.path, "realpath", lambda path: path) + + def _raise(path: str, flags: int) -> int: + raise OSError(errno.EBUSY, "Device or resource busy") + + monkeypatch.setattr(access.os, "open", _raise) + + report = access.check_access(target, "audio") + + assert report.state is access.AccessState.BUSY + assert report.holder == access.Holder(pid=4242, command="arecord") + assert "arecord" in report.remediation + assert "4242" in report.remediation + + +def test_check_access_busy_holder_unknown_degrades_gracefully( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # No fake /proc entries at all -> find_holder degrades to None rather + # than raising or hanging. + proc_dir = tmp_path / "proc" + proc_dir.mkdir() + _patch_proc(monkeypatch, proc_dir) + monkeypatch.setattr(access.os.path, "realpath", lambda path: path) + + def _raise(path: str, flags: int) -> int: + raise OSError(errno.EBUSY, "Device or resource busy") + + monkeypatch.setattr(access.os, "open", _raise) + + report = access.check_access("/dev/bus/usb/001/004", "usb") + + assert report.state is access.AccessState.BUSY + assert report.holder is None + assert "could not be identified" in report.remediation + + +def test_check_access_vanished_device_reports_absent(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(path: str, flags: int) -> int: + raise OSError(errno.ENODEV, "No such device") + + monkeypatch.setattr(access.os, "open", _raise) + + report = access.check_access("/dev/bus/usb/001/004", "usb") + + assert report.state is access.AccessState.ABSENT + assert "unplugged" in report.remediation + + +def test_check_access_invalid_kind_raises() -> None: + with pytest.raises(ValueError): + access.check_access("/dev/snd/pcmC0D0c", "video") + + +# --------------------------------------------------------------------------- # +# require_access / access_error — exit-code mapping +# --------------------------------------------------------------------------- # + + +def test_require_access_ok_does_not_raise(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(access.os, "open", lambda path, flags: 7) + monkeypatch.setattr(access.os, "close", lambda fd: None) + + access.require_access("/dev/snd/pcmC0D0c", "audio") # no raise + + +def test_require_access_absent_maps_to_exit_user_error(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(path: str, flags: int) -> int: + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + monkeypatch.setattr(access.os, "open", _raise) + + with pytest.raises(CliError) as exc: + access.require_access("/dev/snd/pcmC9D9c", "audio") + + assert exc.value.code == EXIT_USER_ERROR == 1 + + +def test_require_access_forbidden_maps_to_exit_env_error(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(path: str, flags: int) -> int: + raise PermissionError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(access.os, "open", _raise) + + with pytest.raises(CliError) as exc: + access.require_access("/dev/snd/pcmC0D0c", "audio") + + assert exc.value.code == EXIT_ENV_ERROR == 2 + + +def test_require_access_busy_maps_to_exit_busy_error_with_holder( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + target = "/dev/bus/usb/001/004" + proc_dir = _fake_proc_tree(tmp_path, pid=99, command="micctl", target=target) + _patch_proc(monkeypatch, proc_dir) + monkeypatch.setattr(access.os.path, "realpath", lambda path: path) + + def _raise(path: str, flags: int) -> int: + raise OSError(errno.EBUSY, "Device or resource busy") + + monkeypatch.setattr(access.os, "open", _raise) + + with pytest.raises(CliError) as exc: + access.require_access(target, "usb") + + assert exc.value.code == EXIT_BUSY_ERROR == 3 + assert "micctl" in exc.value.message + assert "99" in exc.value.message + + +def test_access_error_ok_report_is_a_programming_error() -> None: + report = access.AccessReport( + path="/dev/snd/pcmC0D0c", kind="audio", state=access.AccessState.OK, remediation="" + ) + with pytest.raises(ValueError): + access.access_error(report) + + +def test_busy_error_looks_up_holder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + target = "/dev/snd/pcmC0D0c" + proc_dir = _fake_proc_tree(tmp_path, pid=55, command="pulseaudio", target=target) + _patch_proc(monkeypatch, proc_dir) + monkeypatch.setattr(access.os.path, "realpath", lambda path: path) + + err = access.busy_error(target, "audio") + + assert err.code == EXIT_BUSY_ERROR + assert "pulseaudio" in err.message + assert "55" in err.message + + +def test_find_holder_returns_none_when_proc_unreadable(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise_listdir() -> list[str]: + raise OSError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(access, "_list_proc_pids", lambda: (_ for _ in ()).throw(OSError())) + monkeypatch.setattr(access.os.path, "realpath", lambda path: path) + + assert access.find_holder("/dev/snd/pcmC0D0c") is None diff --git a/tests/test_activation.py b/tests/test_activation.py new file mode 100644 index 0000000..89cc66d --- /dev/null +++ b/tests/test_activation.py @@ -0,0 +1,361 @@ +"""Tests for microphone_cli.activation — the append-only activation log. + +Adapted from webcam-cli/tests/test_activation.py (cited in the module under +test) for microphone-cli's field set: verb, device (stable id), params, +started_at, ended_at. Every test uses tmp_path and an explicit path or +monkeypatched env vars — never the real state dir. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +from pathlib import Path + +import pytest + +import microphone_cli.activation as activation_module +from microphone_cli.activation import ( + ENV_LOG_PATH, + Activation, + activation_scope, + log_path, + record_activation, +) +from microphone_cli.cli._errors import EXIT_ENV_ERROR, CliError + +# --- Activation dataclass --------------------------------------------------- + + +def test_activation_to_dict_shape() -> None: + act = Activation( + verb="gain", + device="usb-046d_C920_MIC_ARRAY_200901010001", + params={"db": 6}, + started_at="2026-07-24T12:00:00+00:00", + ended_at=None, + ) + assert act.to_dict() == { + "verb": "gain", + "device": "usb-046d_C920_MIC_ARRAY_200901010001", + "params": {"db": 6}, + "started_at": "2026-07-24T12:00:00+00:00", + "ended_at": None, + } + + +def test_activation_is_frozen() -> None: + act = Activation(verb="gain", device="d", params={}, started_at="s", ended_at=None) + with pytest.raises(dataclasses.FrozenInstanceError): + act.device = "other" # type: ignore[misc] + + +# --- log_path() -------------------------------------------------------------- + + +def test_log_path_env_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + override = tmp_path / "custom" / "activation.jsonl" + monkeypatch.setenv(ENV_LOG_PATH, str(override)) + assert log_path() == override + + +def test_log_path_xdg_state_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv(ENV_LOG_PATH, raising=False) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + assert log_path() == tmp_path / "microphone-cli" / "activation.jsonl" + + +def test_log_path_falls_back_to_local_state( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv(ENV_LOG_PATH, raising=False) + monkeypatch.delenv("XDG_STATE_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert log_path() == tmp_path / ".local" / "state" / "microphone-cli" / "activation.jsonl" + + +def test_log_path_does_not_touch_filesystem( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv(ENV_LOG_PATH, raising=False) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "nowhere")) + log_path() + assert list(tmp_path.rglob("*")) == [] + + +# --- record_activation: exactly one line ------------------------------------ + + +def test_record_activation_appends_exactly_one_line(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + act = Activation( + verb="gain", + device="usb-046d_C920_MIC_ARRAY_200901010001", + params={"db": 6}, + started_at="2026-07-24T12:00:00+00:00", + ended_at="2026-07-24T12:00:05+00:00", + ) + record_activation(act, path=log) + + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + parsed = json.loads(lines[0]) + assert parsed == act.to_dict() + assert set(parsed) == {"verb", "device", "params", "started_at", "ended_at"} + + +def test_record_activation_creates_parent_dirs(tmp_path: Path) -> None: + log = tmp_path / "a" / "b" / "c" / "activation.jsonl" + assert not log.parent.exists() + act = Activation(verb="gain", device="d", params={}, started_at="s", ended_at="e") + record_activation(act, path=log) + assert log.exists() + assert len(log.read_text(encoding="utf-8").splitlines()) == 1 + + +def test_record_activation_appends_never_overwrites(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + for i in range(3): + act = Activation( + verb="gain", + device=f"dev-{i}", + params={}, + started_at="s", + ended_at="e", + ) + record_activation(act, path=log) + + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 3 + assert [json.loads(line)["device"] for line in lines] == ["dev-0", "dev-1", "dev-2"] + + +def test_record_activation_second_call_appends_not_overwrites(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + first = Activation(verb="gain", device="d1", params={}, started_at="s1", ended_at="e1") + second = Activation(verb="format", device="d2", params={}, started_at="s2", ended_at="e2") + + record_activation(first, path=log) + record_activation(second, path=log) + + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + assert json.loads(lines[0]) == first.to_dict() + assert json.loads(lines[1]) == second.to_dict() + + +def test_record_activation_survives_short_writes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """os.write() may write fewer bytes than asked; the whole line must still land.""" + log = tmp_path / "activation.jsonl" + act = Activation( + verb="gain", + device="usb-046d_C920_MIC_ARRAY_200901010001", + params={"db": 6}, + started_at="2026-07-24T12:00:00+00:00", + ended_at="2026-07-24T12:00:05+00:00", + ) + + real_write = os.write + calls: list[int] = [] + + def flaky_write(fd: int, data: bytes) -> int: + calls.append(len(data)) + if len(calls) == 1: + # Only accept the first byte on the first call. + return real_write(fd, data[:1]) + return real_write(fd, data) + + monkeypatch.setattr(os, "write", flaky_write) + + record_activation(act, path=log) + + assert len(calls) > 1 # the short write actually forced a retry loop + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + assert json.loads(lines[0]) == act.to_dict() + + +def test_record_activation_raises_on_zero_byte_write( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A write() that returns 0 (and never progresses) must raise, not spin or truncate.""" + log = tmp_path / "activation.jsonl" + act = Activation(verb="gain", device="d", params={}, started_at="s", ended_at="e") + + monkeypatch.setattr(os, "write", lambda fd, data: 0) + + with pytest.raises(OSError): + record_activation(act, path=log) + + +def test_record_activation_propagates_write_failures(tmp_path: Path) -> None: + blocker = tmp_path / "blocker" + blocker.write_text("not a directory", encoding="utf-8") + bad_path = blocker / "activation.jsonl" # blocker is a file, not a dir + act = Activation(verb="gain", device="d", params={}, started_at="s", ended_at="e") + with pytest.raises(OSError): + record_activation(act, path=bad_path) + + +# --- activation_scope: exactly one line, including on crash ----------------- + + +def test_activation_scope_writes_nothing_until_exit(tmp_path: Path) -> None: + """No *record* (JSON line) exists until exit — even though entering the scope + now creates/opens the (empty) log file up front, to prove audit availability + before the protected action runs (see the "applied but not logged" findings + fixed below).""" + log = tmp_path / "activation.jsonl" + cm = activation_scope("gain", "dev", {"db": 3}, path=log) + act = cm.__enter__() + try: + assert act.ended_at is None + assert log.read_text(encoding="utf-8") == "" + finally: + cm.__exit__(None, None, None) + + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["ended_at"] is not None + + +def test_activation_scope_writes_one_line_on_success(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + with activation_scope( + "gain", + "usb-046d_C920_MIC_ARRAY_200901010001", + {"db": 6}, + path=log, + ) as act: + assert act.device == "usb-046d_C920_MIC_ARRAY_200901010001" + assert act.verb == "gain" + assert act.ended_at is None + + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["verb"] == "gain" + assert record["params"] == {"db": 6} + assert record["ended_at"] is not None + + +def test_activation_scope_records_on_raise(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + scope = activation_scope("format", "dev", {}, path=log) + with pytest.raises(RuntimeError): + with scope: + raise RuntimeError("boom") + + lines = log.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["ended_at"] is not None + assert record["params"]["error"] == "RuntimeError: boom" + + +def test_activation_scope_does_not_overwrite_existing_error_key(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + scope = activation_scope("format", "dev", {"error": "pre-existing"}, path=log) + with pytest.raises(RuntimeError): + with scope: + raise RuntimeError("boom") + + record = json.loads(log.read_text(encoding="utf-8").splitlines()[0]) + assert record["params"]["error"] == "pre-existing" + + +def test_activation_scope_uses_default_log_path_when_no_path_kwarg( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + override = tmp_path / "activation.jsonl" + monkeypatch.setenv(ENV_LOG_PATH, str(override)) + + with activation_scope("gain", "dev", {}): + pass + + assert override.exists() + assert len(override.read_text(encoding="utf-8").splitlines()) == 1 + + +def test_record_activation_uses_restrictive_file_mode(tmp_path: Path) -> None: + log = tmp_path / "activation.jsonl" + act = Activation(verb="gain", device="d", params={}, started_at="s", ended_at="e") + record_activation(act, path=log) + mode = os.stat(log).st_mode & 0o777 + assert mode == 0o600 + + +# --- activation_scope: audit availability established before the action ----- + + +def test_activation_scope_raises_before_body_when_log_dir_unwritable( + tmp_path: Path, +) -> None: + if os.geteuid() == 0: + pytest.skip("root ignores directory permission bits") + + log_dir = tmp_path / "state" + log_dir.mkdir() + log = log_dir / "activation.jsonl" + log_dir.chmod(0o500) # read + execute, no write: can't create a file inside it + + body_ran = False + try: + with pytest.raises(CliError) as exc_info: + with activation_scope("gain", "dev", {"db": 6}, path=log): + body_ran = True # pragma: no cover - must never execute + finally: + log_dir.chmod(0o700) # restore so tmp_path cleanup can remove it + + assert body_ran is False + assert not log.exists() + assert exc_info.value.code == EXIT_ENV_ERROR + assert str(log) in exc_info.value.message + assert ENV_LOG_PATH in exc_info.value.remediation + + +def test_activation_scope_reports_applied_but_not_logged_after_body_ran( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + log = tmp_path / "activation.jsonl" # writable: the pre-check must pass + + def failing_record_activation(activation: Activation, *, path: Path | None = None) -> None: + raise OSError("disk full") + + monkeypatch.setattr(activation_module, "record_activation", failing_record_activation) + + body_ran = False + with pytest.raises(CliError) as exc_info: + with activation_scope("gain", "usb-dev-1", {"db": 6}, path=log): + body_ran = True + + assert body_ran is True # the protected action DID run before the log write failed + message = exc_info.value.message.lower() + assert exc_info.value.code == EXIT_ENV_ERROR + assert "applied" in message + assert "was applied" in message or "already ran" in message + assert "not" not in message.split("applied")[0] # doesn't read as "not applied" + + +def test_activation_scope_reports_applied_but_not_logged_on_late_write_failure_after_raise( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Even when the body itself raised, a subsequent log-write failure must not be silent.""" + log = tmp_path / "activation.jsonl" + + def failing_record_activation(activation: Activation, *, path: Path | None = None) -> None: + raise OSError("disk full") + + monkeypatch.setattr(activation_module, "record_activation", failing_record_activation) + + scope = activation_scope("format", "dev", {}, path=log) + with pytest.raises(CliError) as exc_info: + with scope: + raise RuntimeError("boom") + + assert exc_info.value.code == EXIT_ENV_ERROR + assert "applied" in exc_info.value.message.lower() diff --git a/tests/test_array.py b/tests/test_array.py new file mode 100644 index 0000000..6ebef46 --- /dev/null +++ b/tests/test_array.py @@ -0,0 +1,585 @@ +"""Tests for the ``array`` noun group (DoA + AEC). + +Nothing here opens a device node: every transfer is served by +:class:`FakeFirmware`, a callable with the ``transfer(request_type, request, +value, index, data_or_length)`` signature :class:`microphone_cli.xvf3800.Xvf3800` +accepts, and the module-level ``_open_array`` seam is monkeypatched to hand +back an :class:`Xvf3800` bound to it. +""" + +from __future__ import annotations + +import argparse +import errno +import json +import os +import struct +from typing import Any, Callable + +import pytest + +from microphone_cli import devices as devices_mod +from microphone_cli.activation import ENV_LOG_PATH +from microphone_cli.cli import _CliArgumentParser, _dispatch +from microphone_cli.cli._commands import array as array_cmd +from microphone_cli.devices import MicrophoneDevice, UsbIds +from microphone_cli.xvf3800 import PARAMETERS, Xvf3800 + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") +BASELINE = os.path.join(FIXTURES, "host-baseline") +SELECTOR = "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001" + +_BY_ID = {(resid, cmdid): name for name, (resid, cmdid, *_rest) in PARAMETERS.items()} + + +def run(argv: list[str]) -> int: + """Parse and dispatch ``argv`` through a parser carrying only the array noun.""" + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + array_cmd.register(sub) + _CliArgumentParser._json_hint = any(token == "--json" for token in argv) + args = parser.parse_args(argv) + return _dispatch(args) + + +def _pack(name: str, values: Any) -> bytes: + _resid, _cmdid, count, _access, type_ = PARAMETERS[name] + if type_ == "char": + raw = str(values).encode("utf-8") + return raw.ljust(count, b"\x00") + if type_ == "uint8": + return bytes(bytearray(int(v) & 0xFF for v in values)) + fmt = {"float": "f", "radians": "f", "int32": "i", "uint32": "I"}[type_] + if fmt == "f": + return struct.pack("<" + fmt * count, *(float(v) for v in values)) + return struct.pack("<" + fmt * count, *(int(v) for v in values)) + + +def _unpack(name: str, payload: bytes) -> list[Any]: + _resid, _cmdid, count, _access, type_ = PARAMETERS[name] + if type_ == "uint8": + return list(payload[:count]) + fmt = {"float": "f", "radians": "f", "int32": "i", "uint32": "I"}[type_] + return list(struct.unpack("<" + fmt * count, payload[: count * 4])) + + +class FakeFirmware: + """An in-memory XVF3800: serves reads from ``values``, records writes.""" + + def __init__(self, **overrides: Any) -> None: + self.values: dict[str, Any] = { + "DOA_VALUE_RADIANS": [1.25, 1.0], + "AEC_AECCONVERGED": [1], + "SHF_BYPASS": [0], + "AEC_HPFONOFF": [1], + "PP_ECHOONOFF": [1], + "AEC_NUM_MICS": [4], + "AEC_MIC_ARRAY_TYPE": [0], + "AEC_MIC_ARRAY_GEO": [float(i) / 100.0 for i in range(12)], + "AEC_RT60": [0.25], + } + self.values.update(overrides) + self.reads: list[str] = [] + self.writes: list[tuple[str, list[Any]]] = [] + #: raise ``OSError(ENODEV)`` on the Nth read of this parameter. + self.fail_read_after: int | None = None + self.fail_param = "DOA_VALUE_RADIANS" + + def __call__( + self, + request_type: int, + request: int, + value: int, + index: int, + data_or_length: int | bytes, + ) -> Any: + if request_type & 0x80: + name = _BY_ID[(index, value & 0x7F)] + self.reads.append(name) + if ( + self.fail_read_after is not None + and name == self.fail_param + and self.reads.count(name) > self.fail_read_after + ): + raise OSError(errno.ENODEV, "No such device") + return b"\x00" + _pack(name, self.values[name]) + name = _BY_ID[(index, value)] + self.writes.append((name, _unpack(name, bytes(data_or_length)))) + self.values[name] = self.writes[-1][1] + return len(bytes(data_or_length)) + + +@pytest.fixture +def firmware(monkeypatch: pytest.MonkeyPatch) -> FakeFirmware: + fake = FakeFirmware() + opened: list[Any] = [] + + def _open(device: Any, root: str = "/", timeout_ms: int | None = None) -> Xvf3800: + opened.append(device) + return Xvf3800(fake) + + monkeypatch.setattr(array_cmd, "_open_array", _open) + monkeypatch.setattr(array_cmd, "_sleep", lambda _seconds: None) + fake.opened = opened # type: ignore[attr-defined] + return fake + + +def _lines(text: str) -> list[str]: + return [line for line in text.splitlines() if line.strip()] + + +# --------------------------------------------------------------------------- +# noun group shape +# --------------------------------------------------------------------------- + + +def test_bare_array_prints_the_noun_overview(capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array"]) + assert rc == 0 + out = capsys.readouterr().out + assert "microphone array" in out + assert "doa" in out + assert "aec" in out + + +def test_array_overview_json_lists_sections(capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array", "overview", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["subject"] == "microphone array" + assert [section["title"] for section in payload["sections"]] + + +def test_subparsers_use_the_structured_parser_class( + capsys: pytest.CaptureFixture[str], +) -> None: + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + array_cmd.register(sub) + array_parser = sub.choices["array"] + noun_sub = [ + action for action in array_parser._actions if isinstance(action, argparse._SubParsersAction) + ][0] + assert noun_sub._parser_class is _CliArgumentParser + aec_parser = noun_sub.choices["aec"] + aec_sub = [ + action for action in aec_parser._actions if isinstance(action, argparse._SubParsersAction) + ][0] + assert aec_sub._parser_class is _CliArgumentParser + + with pytest.raises(SystemExit) as exc: + run(["array", "bogus", "--json"]) + assert exc.value.code == 1 + payload = json.loads(capsys.readouterr().err) + assert payload["code"] == 1 + + +# --------------------------------------------------------------------------- +# doa +# --------------------------------------------------------------------------- + + +def test_doa_json_single_shot(firmware: FakeFirmware, capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--json"]) + assert rc == 0 + out = _lines(capsys.readouterr().out) + assert len(out) == 1 + payload = json.loads(out[0]) + assert payload["azimuth_rad"] == pytest.approx(1.25) + assert payload["speech"] is True + assert payload["source"] == "DOA_VALUE_RADIANS" + assert payload["device"] == SELECTOR + assert payload["ts"] + assert firmware.reads == ["DOA_VALUE_RADIANS"] + + +def test_doa_text_single_shot(firmware: FakeFirmware, capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array", "doa", SELECTOR, "--root", BASELINE]) + assert rc == 0 + out = _lines(capsys.readouterr().out) + assert len(out) == 1 + assert "azimuth_rad" in out[0] + assert "1.25" in out[0] + + +def test_doa_speech_flag_is_false_below_the_threshold( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str] +) -> None: + firmware.values["DOA_VALUE_RADIANS"] = [-0.5, 0.0] + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["speech"] is False + assert payload["azimuth_rad"] == pytest.approx(-0.5) + + +def test_doa_watch_count_prints_exactly_n_json_lines( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + slept: list[float] = [] + monkeypatch.setattr(array_cmd, "_sleep", slept.append) + rc = run( + [ + "array", + "doa", + SELECTOR, + "--root", + BASELINE, + "--watch", + "--count", + "3", + "--interval", + "0.25", + ] + ) + assert rc == 0 + out = _lines(capsys.readouterr().out) + assert len(out) == 3 + for line in out: + payload = json.loads(line) + assert payload["source"] == "DOA_VALUE_RADIANS" + assert slept == [0.25, 0.25] + + +def test_doa_watch_emits_json_lines_without_the_json_flag( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str] +) -> None: + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--watch", "--count", "2"]) + assert rc == 0 + out = _lines(capsys.readouterr().out) + assert len(out) == 2 + assert all(json.loads(line)["device"] == SELECTOR for line in out) + + +def test_doa_watch_device_disappears_on_the_third_poll( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str] +) -> None: + firmware.fail_read_after = 2 + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--watch", "--count", "5", "--json"]) + assert rc == 2 + captured = capsys.readouterr() + assert len(_lines(captured.out)) == 2 + err_lines = _lines(captured.err) + assert len(err_lines) == 1 + payload = json.loads(err_lines[0]) + assert payload["code"] == 2 + assert "disappear" in payload["message"] + assert payload["remediation"] + + +def test_doa_watch_sigint_exits_zero_after_flushing( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + def _interrupt(_seconds: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(array_cmd, "_sleep", _interrupt) + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--watch", "--json"]) + assert rc == 0 + captured = capsys.readouterr() + assert len(_lines(captured.out)) == 1 + assert captured.err == "" + + +def test_doa_rejects_a_non_positive_interval( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str] +) -> None: + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--watch", "--interval", "0", "--json"]) + assert rc == 1 + assert json.loads(capsys.readouterr().err)["code"] == 1 + + +def test_doa_refuses_a_device_that_is_not_an_array( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + plain = MicrophoneDevice( + stable_id="usb-Generic_Mic_0001", + label="Generic Mic", + alsa_address="hw:CARD=Mic", + card_id="Mic", + card_index=1, + usb_path="1-1", + usb_ids=UsbIds(vendor="046d", product="0825"), + serial="0001", + is_array=False, + channels=1, + pipewire_visible=True, + ) + monkeypatch.setattr(devices_mod, "resolve", lambda selector, root="/": plain) + rc = run(["array", "doa", "whatever", "--json"]) + assert rc == 1 + payload = json.loads(capsys.readouterr().err) + assert "array" in payload["message"] + assert payload["remediation"] + + +def test_doa_unknown_selector_is_a_user_error(capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array", "doa", "no-such-microphone", "--root", BASELINE, "--json"]) + assert rc == 1 + assert json.loads(capsys.readouterr().err)["code"] == 1 + + +# --------------------------------------------------------------------------- +# aec get +# --------------------------------------------------------------------------- + + +def test_aec_get_json_reports_the_documented_keys( + firmware: FakeFirmware, capsys: pytest.CaptureFixture[str] +) -> None: + rc = run(["array", "aec", "get", SELECTOR, "--root", BASELINE, "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + for key in ("converged", "bypass", "hpf", "echo", "num_mics", "geometry"): + assert key in payload + assert payload["converged"] is True + assert payload["bypass"] is False + assert payload["hpf"] is True + assert payload["echo"] is True + assert payload["num_mics"] == 4 + assert len(payload["geometry"]) == 12 + assert payload["device"] == SELECTOR + + +def test_aec_get_text(firmware: FakeFirmware, capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array", "aec", "get", SELECTOR, "--root", BASELINE]) + assert rc == 0 + out = capsys.readouterr().out + assert "converged: true" in out + assert "num_mics: 4" in out + + +def test_aec_overview(capsys: pytest.CaptureFixture[str]) -> None: + rc = run(["array", "aec", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["subject"] == "microphone array aec" + + +# --------------------------------------------------------------------------- +# aec set +# --------------------------------------------------------------------------- + + +def test_aec_set_without_apply_touches_no_hardware( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Any +) -> None: + log = tmp_path / "activation.jsonl" + monkeypatch.setenv(ENV_LOG_PATH, str(log)) + + def _boom(*_args: Any, **_kwargs: Any) -> Xvf3800: + raise AssertionError("dry run must not open the device") + + monkeypatch.setattr(array_cmd, "_open_array", _boom) + rc = run(["array", "aec", "set", SELECTOR, "--root", BASELINE, "--echo", "off", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == "dry-run" + assert payload["applied"] is False + assert payload["hardware_touched"] is False + assert payload["planned"] == [ + {"setting": "echo", "value": "off", "param": "PP_ECHOONOFF", "values": [0]} + ] + assert not log.exists() + + +def test_aec_set_with_apply_writes_and_logs( + firmware: FakeFirmware, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Any, +) -> None: + log = tmp_path / "activation.jsonl" + monkeypatch.setenv(ENV_LOG_PATH, str(log)) + rc = run( + [ + "array", + "aec", + "set", + SELECTOR, + "--root", + BASELINE, + "--echo", + "off", + "--apply", + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == "apply" + assert payload["applied"] is True + assert payload["hardware_touched"] is True + assert firmware.writes == [("PP_ECHOONOFF", [0])] + assert payload["state"]["echo"] is False + + entries = [json.loads(line) for line in log.read_text().splitlines() if line.strip()] + assert len(entries) == 1 + assert entries[0]["verb"] == "array aec set" + assert entries[0]["device"] == SELECTOR + assert entries[0]["params"]["echo"] == "off" + assert entries[0]["ended_at"] + + +def test_aec_set_applies_every_flag( + firmware: FakeFirmware, monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + monkeypatch.setenv(ENV_LOG_PATH, str(tmp_path / "activation.jsonl")) + rc = run( + [ + "array", + "aec", + "set", + SELECTOR, + "--root", + BASELINE, + "--echo", + "on", + "--bypass", + "on", + "--hpf", + "off", + "--apply", + "--json", + ] + ) + assert rc == 0 + assert firmware.writes == [ + ("PP_ECHOONOFF", [1]), + ("SHF_BYPASS", [1]), + ("AEC_HPFONOFF", [0]), + ] + + +def test_aec_set_without_any_flag_is_a_user_error( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = run(["array", "aec", "set", SELECTOR, "--root", BASELINE, "--json"]) + assert rc == 1 + payload = json.loads(capsys.readouterr().err) + assert payload["code"] == 1 + assert payload["remediation"] + + +def test_aec_set_records_a_line_even_when_the_write_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Any, +) -> None: + log = tmp_path / "activation.jsonl" + monkeypatch.setenv(ENV_LOG_PATH, str(log)) + fake = FakeFirmware() + + def _explode(*_args: Any, **_kwargs: Any) -> Any: + raise OSError(errno.ENODEV, "No such device") + + def _open(*_args: Any, **_kwargs: Any) -> Xvf3800: + return Xvf3800(_explode) + + monkeypatch.setattr(array_cmd, "_open_array", _open) + rc = run( + ["array", "aec", "set", SELECTOR, "--root", BASELINE, "--echo", "off", "--apply", "--json"] + ) + assert rc == 2 + assert json.loads(capsys.readouterr().err)["code"] == 2 + entries = [json.loads(line) for line in log.read_text().splitlines() if line.strip()] + assert len(entries) == 1 + assert "error" in entries[0]["params"] + assert fake.writes == [] + + +# --------------------------------------------------------------------------- +# the open seam itself +# --------------------------------------------------------------------------- + + +def test_open_array_reports_a_missing_usb_node( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # The fixture tree has no /sys/bus/usb/devices, so find_devices() is empty. + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--json"]) + assert rc == 2 + payload = json.loads(capsys.readouterr().err) + assert payload["code"] == 2 + assert payload["remediation"] + + +def test_open_array_opens_the_matching_node(monkeypatch: pytest.MonkeyPatch) -> None: + device = devices_mod.resolve(SELECTOR, BASELINE) + seen: dict[str, Any] = {} + + def _find(**kwargs: Any) -> list[dict[str, str]]: + seen.update(kwargs) + return [{"node": "/dev/bus/usb/001/007"}] + + opened: list[str] = [] + + def _open(node: str, **_kw: object) -> int: + opened.append(node) + return 4242 + + monkeypatch.setattr(array_cmd.usbctl, "find_devices", _find) + monkeypatch.setattr(array_cmd.usbctl, "open_device", _open) + chip = array_cmd._open_array(device, root=BASELINE) + assert isinstance(chip, Xvf3800) + assert opened == ["/dev/bus/usb/001/007"] + assert seen["vendor"] == "38fb" + assert seen["product"] == "1001" + assert seen["serial"] == "RM0001" + chip._fd = None # do not close the fake descriptor + + +def test_read_doa_helper_is_pure(firmware: FakeFirmware) -> None: + device = devices_mod.resolve(SELECTOR, BASELINE) + transfer: Callable[..., Any] = firmware + payload = array_cmd._read_doa(Xvf3800(transfer), device) + assert set(payload) == {"device", "azimuth_rad", "azimuth_deg", "speech", "source", "ts"} + + +# --------------------------------------------------------------------------- +# Seeed firmware: DoA comes from DOA_VALUE (degrees, speech) — found on hardware +# --------------------------------------------------------------------------- + + +def test_doa_on_seeed_firmware_reads_degrees( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import math + import struct + + from microphone_cli.xvf3800 import SEEED_VENDOR + + reads: list[tuple[int, int]] = [] + + def transfer(request_type, request, value, index, data_or_length): + assert request_type & 0x80, "doa is read-only" + reads.append((index, value & 0x7F)) + assert data_or_length == 5, "two uint16 plus the status byte" + return b"\x00" + struct.pack(" None: + import math + + rc = run(["array", "doa", SELECTOR, "--root", BASELINE, "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["source"] == "DOA_VALUE_RADIANS" + assert payload["azimuth_rad"] == 1.25 + assert payload["azimuth_deg"] == pytest.approx(math.degrees(1.25)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 470d158..84eaf28 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,14 +1,29 @@ -"""Smoke tests for the microphone-cli CLI entry point and its verbs.""" +"""Smoke tests for the microphone CLI entry point, plus the surface-parity gates. + +The parity block below is ported from +``../webcam-cli/tests/test_cli.py`` (lines 190-327): it walks the *live* +argparse tree and requires the hand-maintained duplicates of the surface — +the explain catalog, ``overview._VERBS`` and ``learn``'s command map — to +agree with it, and it forbids the two prose regressions this repo has +already had: presenting ``microphone-cli`` as a typable command, and the +scaffold's "clonable template" self-description. + +Hardware posture: nothing here opens a device. Every capture-surface probe +is a parse-level or dry-run call against a synthetic fixture tree. +""" from __future__ import annotations +import argparse import json +import re import pytest from microphone_cli import __version__ -from microphone_cli.cli import main +from microphone_cli.cli import _build_parser, main from microphone_cli.explain import known_paths +from microphone_cli.explain.catalog import ENTRIES def test_version_flag(capsys: pytest.CaptureFixture[str]) -> None: @@ -21,7 +36,7 @@ def test_version_flag(capsys: pytest.CaptureFixture[str]) -> None: def test_no_args_prints_help(capsys: pytest.CaptureFixture[str]) -> None: rc = main([]) assert rc == 0 - assert "usage: microphone-cli" in capsys.readouterr().out + assert "usage: microphone" in capsys.readouterr().out def test_unknown_command_errors(capsys: pytest.CaptureFixture[str]) -> None: @@ -33,6 +48,25 @@ def test_unknown_command_errors(capsys: pytest.CaptureFixture[str]) -> None: assert "hint:" in err +def test_startup_failure_in_parser_construction_is_a_structured_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A raise before `_dispatch`'s exception boundary must not leak a traceback.""" + import microphone_cli.cli as cli_module + + def _boom() -> argparse.ArgumentParser: + raise RuntimeError("registration exploded") + + monkeypatch.setattr(cli_module, "_build_parser", _boom) + + rc = cli_module.main(["list"]) + + assert rc == 1 + err = capsys.readouterr().err + assert "error:" in err + assert "Traceback" not in err + + # --- whoami --------------------------------------------------------------- @@ -62,7 +96,7 @@ def test_learn_text(capsys: pytest.CaptureFixture[str]) -> None: assert rc == 0 out = capsys.readouterr().out assert len(out) >= 200 - assert "microphone-cli" in out + assert "microphone" in out assert "Exit-code policy" in out assert "--json" in out assert "explain" in out @@ -83,7 +117,7 @@ def test_learn_json(capsys: pytest.CaptureFixture[str]) -> None: def test_explain_root(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["explain"]) assert rc == 0 - assert "# microphone-cli" in capsys.readouterr().out + assert "# microphone" in capsys.readouterr().out def test_explain_self(capsys: pytest.CaptureFixture[str]) -> None: @@ -97,7 +131,7 @@ def test_explain_json(capsys: pytest.CaptureFixture[str]) -> None: assert rc == 0 payload = json.loads(capsys.readouterr().out) assert payload["path"] == ["whoami"] - assert "microphone-cli whoami" in payload["markdown"] + assert "microphone whoami" in payload["markdown"] def test_explain_unknown_path_errors(capsys: pytest.CaptureFixture[str]) -> None: @@ -113,3 +147,217 @@ def test_every_catalog_path_resolves(capsys: pytest.CaptureFixture[str]) -> None rc = main(["explain", *path]) assert rc == 0, f"explain {' '.join(path)} failed" capsys.readouterr() + + +# --- surface wiring ------------------------------------------------------- +# +# Ported from ../webcam-cli/tests/test_cli.py:190-327. + + +def _registered_paths( + parser: argparse.ArgumentParser | None = None, + prefix: tuple[str, ...] = (), +) -> list[tuple[str, ...]]: + """Every command path the *live* parser tree exposes, depth-first.""" + parser = parser if parser is not None else _build_parser() + paths: list[tuple[str, ...]] = [] + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for name, subparser in action.choices.items(): + path = (*prefix, name) + paths.append(path) + paths.extend(_registered_paths(subparser, path)) + return paths + + +#: The surface this repo intends to expose, written out longhand. Kept as a +#: literal rather than derived from anything so that dropping a `register()` +#: call from `_build_parser` fails here instead of quietly shrinking the CLI. +KNOWN_PATHS: set[tuple[str, ...]] = { + ("list",), + ("inspect",), + ("gain",), + ("gain", "overview"), + ("gain", "get"), + ("gain", "set"), + ("array",), + ("array", "overview"), + ("array", "doa"), + ("array", "aec"), + ("array", "aec", "overview"), + ("array", "aec", "get"), + ("array", "aec", "set"), + ("param",), + ("param", "overview"), + ("param", "list"), + ("param", "get"), + ("param", "set"), + ("stream",), + ("stream", "overview"), + ("stream", "audio"), + ("record",), + ("whoami",), + ("learn",), + ("explain",), + ("overview",), + ("doctor",), + ("cli",), + ("cli", "overview"), +} + + +def test_the_registered_surface_is_exactly_the_known_surface() -> None: + assert set(_registered_paths()) == KNOWN_PATHS + + +def test_every_registered_path_has_a_catalog_entry() -> None: + """The converse of `test_every_catalog_path_resolves`. + + That test proves no catalog entry is dead; this one proves no registered + verb is undocumented, which is the direction that actually breaks an agent. + """ + undocumented = sorted(set(_registered_paths()) - set(known_paths())) + assert not undocumented, f"registered but not in the explain catalog: {undocumented}" + + +_WORD_RE = re.compile(r"[a-zA-Z]+") + + +def _word_tokens(text: str) -> set[str]: + return set(_WORD_RE.findall(text.lower())) + + +def test_every_registered_path_appears_in_overview_verbs() -> None: + """`overview._VERBS` is a hand-maintained duplicate of the surface. + + Walk the *live* parser tree (recursively, so noun-group sub-verbs like + ``array aec get`` are included) and require every path to be represented in + ``_VERBS``. "Represented" means some single entry's word-tokens are a + superset of the path's components — so a shorthand line like + ``gain get|set `` covers two leaf verbs at once. A path with no + such entry fails loudly instead of drifting silently. + """ + from microphone_cli.cli._commands.overview import _VERBS + + entry_tokens = [_word_tokens(entry) for entry in _VERBS] + for path in _registered_paths(): + wanted = set(path) + assert any( + wanted <= tokens for tokens in entry_tokens + ), f"`{' '.join(path)}` is registered but not represented in overview._VERBS" + + +def test_learn_json_command_map_matches_the_registered_surface() -> None: + """`learn --json` is the machine-readable command map; it must be complete.""" + from microphone_cli.cli._commands.learn import _as_json_payload + + listed = {tuple(entry["path"]) for entry in _as_json_payload()["commands"]} + assert listed == set(_registered_paths()) + + +# --- the command an agent is told to type --------------------------------- + +# `microphone-cli` presented as something typable: the string followed by a +# flag, a placeholder, or one of the registered top-level verbs. Bare mentions +# of the project, the PyPI distribution, the mesh nick, or the `microphone-cli/` +# state directory are correct and deliberately not matched. +_DEAD_COMMAND_RE = re.compile( + r"microphone-cli\s+(?:--?\w|<|list\b|inspect\b|gain\b|array\b|param\b|stream\b" + r"|record\b|whoami\b|learn\b|explain\b|overview\b|doctor\b|cli\b)" +) + + +def _help_texts(parser: argparse.ArgumentParser | None = None) -> list[str]: + parser = parser if parser is not None else _build_parser() + texts = [parser.format_help()] + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + texts.extend(_help_texts(subparser)) + return texts + + +def _agent_facing_texts(capsys: pytest.CaptureFixture[str]) -> dict[str, str]: + """Everything the CLI tells an agent about itself, keyed by where it came from.""" + texts = {f"--help #{i}": text for i, text in enumerate(_help_texts())} + texts.update({f"explain {' '.join(path) or ''}": body for path, body in ENTRIES.items()}) + + for argv in ( + ["learn"], + ["learn", "--json"], + ["overview"], + ["overview", "--json"], + ["cli", "overview"], + ["stream", "overview"], + ["array", "overview"], + ["array", "aec", "overview"], + ["param", "overview"], + ["gain", "overview"], + ["whoami"], + ["doctor"], + ): + main(argv) + captured = capsys.readouterr() + texts[" ".join(argv)] = captured.out + captured.err + return texts + + +def test_no_user_facing_string_presents_microphone_cli_as_a_command( + capsys: pytest.CaptureFixture[str], +) -> None: + """`microphone-cli explain …` is not an installed binary; instructing it is a dead end. + + The three-way split is deliberate — command ``microphone``, import package + ``microphone_cli``, distribution ``microphone-cli`` — so this asserts only + that the dist name is never presented as something to *type*, not that it + is absent. + """ + offenders = { + source: _DEAD_COMMAND_RE.findall(text) + for source, text in _agent_facing_texts(capsys).items() + if _DEAD_COMMAND_RE.search(text) + } + assert not offenders, f"`microphone-cli` presented as a typable command in: {offenders}" + + +def test_no_template_prose_survives(capsys: pytest.CaptureFixture[str]) -> None: + """The scaffold described this repo as a clonable template. It is a microphone agent.""" + banned = ("clonable", "template", "scaffold", "rename the package", "mint a new agent") + offenders = { + source: [phrase for phrase in banned if phrase in text.lower()] + for source, text in _agent_facing_texts(capsys).items() + } + offenders = {source: hits for source, hits in offenders.items() if hits} + assert not offenders, f"template prose still in the self-description: {offenders}" + + +# --- the microphone verbs, reached through main() ------------------------- +# +# Hardware posture: dry runs against a synthetic fixture tree, or parse-level +# failures. Nothing here opens a device — `--probe`/`--apply` belong to the +# on-host acceptance run. + + +def test_list_runs_through_main(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["list", "--json", "--root", "tests/fixtures/host-baseline"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + + +def test_param_list_runs_through_main(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["param", "list", "--json"]) + assert rc == 0 + assert json.loads(capsys.readouterr().out) + + +def test_unknown_device_is_a_structured_error_not_a_traceback( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["array", "doa", "no-such-device", "--root", "tests/fixtures/host-baseline"]) + assert rc == 1 + captured = capsys.readouterr() + assert "Traceback" not in captured.err + assert captured.err.startswith("error:") + assert "hint:" in captured.err + assert captured.out == "" diff --git a/tests/test_cli_introspection.py b/tests/test_cli_introspection.py index d1e26cf..5b7cf3f 100644 --- a/tests/test_cli_introspection.py +++ b/tests/test_cli_introspection.py @@ -15,7 +15,7 @@ def test_overview_text(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["overview"]) assert rc == 0 out = capsys.readouterr().out - assert "# microphone-cli" in out + assert "# microphone" in out assert "Identity" in out @@ -23,7 +23,7 @@ def test_overview_json_shape(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["overview", "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) - assert payload["subject"] == "microphone-cli" + assert payload["subject"] == "microphone" assert isinstance(payload["sections"], list) assert payload["sections"] @@ -41,14 +41,14 @@ def test_overview_graceful_on_bad_path(capsys: pytest.CaptureFixture[str]) -> No def test_cli_overview_text(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["cli", "overview"]) assert rc == 0 - assert "# microphone-cli cli" in capsys.readouterr().out + assert "# microphone cli" in capsys.readouterr().out def test_cli_overview_json_shape(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["cli", "overview", "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) - assert payload["subject"] == "microphone-cli cli" + assert payload["subject"] == "microphone cli" assert isinstance(payload["sections"], list) @@ -77,7 +77,7 @@ def test_cli_overview_unknown_flag_structured_error( def test_doctor_text(capsys: pytest.CaptureFixture[str]) -> None: rc = main(["doctor"]) assert rc in (0, 1) - assert "microphone-cli doctor" in capsys.readouterr().out + assert "microphone doctor" in capsys.readouterr().out def test_doctor_json_shape(capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..6dda3cb --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,215 @@ +"""Tests for :mod:`microphone_cli.devices`, driven entirely by fixture trees. + +Every test points ``root=`` at a synthetic ``tests/fixtures/`` directory +containing only text files and relative symlinks — no real capture hardware is +touched and no device node is opened. +""" + +from __future__ import annotations + +import os +import subprocess # nosec B404 - fixed argv, no shell, used for an import-isolation check +import sys + +import pytest + +from microphone_cli.cli._errors import EXIT_USER_ERROR, CliError +from microphone_cli.devices import ( + MicrophoneDevice, + enumerate_devices, + is_array_ids, + resolve, +) + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + +# Every key the acceptance criteria require of ``as_dict()``. +REQUIRED_KEYS = { + "stable_id", + "label", + "alsa_address", + "card_id", + "usb_path", + "usb_ids", + "serial", + "is_array", + "channels", + "pipewire_visible", +} + + +def tree(name: str) -> str: + return os.path.join(FIXTURES, name) + + +# --------------------------------------------------------------------------- +# enumeration +# --------------------------------------------------------------------------- + + +def test_baseline_enumerates_the_single_usb_capture_card() -> None: + devices = enumerate_devices(root=tree("host-baseline")) + assert len(devices) == 1 + device = devices[0] + assert isinstance(device, MicrophoneDevice) + assert device.stable_id == "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001" + assert device.label == "Reachy Mini Audio" + assert device.card_id == "Audio" + assert device.card_index == 1 + assert device.alsa_address == "hw:CARD=Audio" + assert device.usb_path == "5-1.1" + assert device.usb_ids.vendor == "38fb" + assert device.usb_ids.product == "1001" + assert device.serial == "RM0001" + assert device.is_array is True + assert device.channels == 6 + assert device.pipewire_visible is True + + +def test_playback_only_and_non_usb_cards_are_skipped() -> None: + devices = enumerate_devices(root=tree("host-baseline")) + # card 0 in the fixture is a playback-only, non-USB HDA controller. + assert [device.card_id for device in devices] == ["Audio"] + + +def test_stable_id_survives_renumbering_while_the_card_index_moves() -> None: + baseline = enumerate_devices(root=tree("host-baseline")) + renumbered = enumerate_devices(root=tree("host-renumbered")) + + assert [device.stable_id for device in baseline] == [device.stable_id for device in renumbered] + assert [device.card_index for device in baseline] != [ + device.card_index for device in renumbered + ] + # The ALSA address is keyed on the card id, so it survives too. + assert baseline[0].alsa_address == renumbered[0].alsa_address + # ...but the USB port genuinely moved, proving identity is not topological. + assert baseline[0].usb_path != renumbered[0].usb_path + + +def test_two_arrays_are_distinct_devices() -> None: + devices = enumerate_devices(root=tree("two-arrays")) + assert len(devices) == 2 + assert {device.serial for device in devices} == {"RM0001", "RM0002"} + assert len({device.stable_id for device in devices}) == 2 + assert all(device.is_array for device in devices) + + +def test_respeaker_falls_back_to_the_sysfs_path_when_no_serial_is_present() -> None: + (device,) = enumerate_devices(root=tree("respeaker")) + assert device.serial is None + assert device.stable_id == "usb-path-3-1" + assert device.is_array is True # 2886:001a + assert device.channels is None # no stream0 in the fixture + assert device.pipewire_visible is None # no PipeWire runtime socket + + +def test_is_array_ids_covers_both_xvf3800_pairs() -> None: + assert is_array_ids("38fb", "1001") is True + assert is_array_ids("2886", "001a") is True + assert is_array_ids("046d", "0825") is False + + +# --------------------------------------------------------------------------- +# as_dict shape +# --------------------------------------------------------------------------- + + +def test_as_dict_carries_every_required_key() -> None: + (device,) = enumerate_devices(root=tree("host-baseline")) + payload = device.as_dict() + assert REQUIRED_KEYS <= set(payload) + assert payload["usb_ids"] == {"vendor": "38fb", "product": "1001"} + assert payload["is_array"] is True + assert isinstance(payload["pipewire_visible"], bool) + + +def test_as_dict_is_json_serialisable() -> None: + import json + + for name in ("host-baseline", "two-arrays", "respeaker"): + for device in enumerate_devices(root=tree(name)): + json.loads(json.dumps(device.as_dict())) + + +# --------------------------------------------------------------------------- +# resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "selector, serial", + [ + ("usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001", "RM0001"), + ("usb-Pollen_Robotics_Reachy_Mini_Audio_RM0002", "RM0002"), + ("RM0001", "RM0001"), + ("RM0002", "RM0002"), + ("Audio_1", "RM0002"), + ], +) +def test_resolve_selects_by_stable_id_serial_and_card_id(selector: str, serial: str) -> None: + device = resolve(selector, root=tree("two-arrays")) + assert device.serial == serial + + +def test_resolve_by_label_is_unambiguous_on_a_single_device_host() -> None: + device = resolve("Reachy Mini Audio", root=tree("host-baseline")) + assert device.serial == "RM0001" + + +def test_resolve_ambiguous_label_lists_every_candidate() -> None: + root = tree("two-arrays") + with pytest.raises(CliError) as excinfo: + resolve("Reachy Mini Audio", root=root) + error = excinfo.value + assert error.code == EXIT_USER_ERROR + assert "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001" in error.message + assert "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0002" in error.message + assert error.remediation + + +def test_resolve_unknown_selector_is_a_user_error() -> None: + root = tree("two-arrays") + with pytest.raises(CliError) as excinfo: + resolve("no-such-microphone", root=root) + assert excinfo.value.code == EXIT_USER_ERROR + + +def test_resolve_empty_selector_is_a_user_error() -> None: + root = tree("two-arrays") + with pytest.raises(CliError) as excinfo: + resolve(" ", root=root) + assert excinfo.value.code == EXIT_USER_ERROR + + +@pytest.mark.parametrize("selector", ["hw:1", "hw:0,0", "1", "0", "plughw:1"]) +def test_resolve_refuses_raw_card_number_selectors(selector: str) -> None: + root = tree("two-arrays") + with pytest.raises(CliError) as excinfo: + resolve(selector, root=root) + error = excinfo.value + assert error.code == EXIT_USER_ERROR + assert "stable" in error.message.lower() or "stable" in error.remediation.lower() + assert "usb-" in error.remediation + + +def test_refusal_names_the_owning_device_when_the_card_number_exists() -> None: + root = tree("two-arrays") + with pytest.raises(CliError) as excinfo: + resolve("hw:1", root=root) + assert "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0002" in excinfo.value.remediation + + +# --------------------------------------------------------------------------- +# import surface +# --------------------------------------------------------------------------- + + +def test_module_imports_with_no_other_imports() -> None: + """``from microphone_cli.devices import enumerate_devices, resolve`` stands alone.""" + completed = subprocess.run( # nosec B603 - fixed argv, no shell + [sys.executable, "-c", "from microphone_cli.devices import enumerate_devices, resolve"], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..caa1faf --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,425 @@ +"""Tests for microphone_cli.engine: capability detection and argv builders. + +Audio subset cited from webcam-cli/webcam_cli/engine.py (see module +docstring in microphone_cli/engine.py for exact provenance). Never launches +gst-launch-1.0 against a real device: subprocess.run and shutil.which are +monkeypatched throughout. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from microphone_cli import engine +from microphone_cli.cli._errors import CliError + +# --- detect() / require_engine() / require_elements() ------------------------ + + +def test_detect_reports_unavailable_when_gst_launch_missing(monkeypatch): + monkeypatch.setattr(engine.shutil, "which", lambda _name: None) + + cap = engine.detect() + + assert cap == engine.Capability( + gst_launch=None, + gst_inspect=None, + plugins=dict.fromkeys(engine.ALL_ELEMENTS, False), + available=False, + ) + + +def test_detect_never_raises_when_gst_inspect_probe_errors(monkeypatch): + def fake_which(name): + return f"/usr/bin/{name}" if name in (engine.GST_LAUNCH, engine.GST_INSPECT) else None + + def fake_run(argv, **kwargs): + raise OSError("boom") + + monkeypatch.setattr(engine.shutil, "which", fake_which) + monkeypatch.setattr(engine.subprocess, "run", fake_run) + + cap = engine.detect() + + assert cap.available is False + assert all(present is False for present in cap.plugins.values()) + + +def test_detect_available_when_gst_launch_and_core_elements_present(monkeypatch): + def fake_which(name): + return f"/usr/bin/{name}" if name in (engine.GST_LAUNCH, engine.GST_INSPECT) else None + + def fake_run(argv, **kwargs): + if argv[1:] == ["--help"]: + return subprocess.CompletedProcess(argv, 0, stdout="--exists Check if element exists") + # argv = [gst_inspect, "--exists", element] + element = argv[2] + returncode = 0 if element in engine.CORE_ELEMENTS else 1 + return subprocess.CompletedProcess(argv, returncode) + + monkeypatch.setattr(engine.shutil, "which", fake_which) + monkeypatch.setattr(engine.subprocess, "run", fake_run) + + cap = engine.detect() + + assert cap.available is True + assert cap.gst_launch == "/usr/bin/gst-launch-1.0" + for element in engine.CORE_ELEMENTS: + assert cap.plugins[element] is True + + +def test_detect_uses_plain_form_when_exists_flag_unsupported(monkeypatch): + def fake_which(name): + return f"/usr/bin/{name}" if name in (engine.GST_LAUNCH, engine.GST_INSPECT) else None + + seen_argvs = [] + + def fake_run(argv, **kwargs): + seen_argvs.append(argv) + if argv[1:] == ["--help"]: + return subprocess.CompletedProcess(argv, 0, stdout="no exists flag here") + # plain form: [gst_inspect, element] + element = argv[1] + returncode = 0 if element in engine.CORE_ELEMENTS else 1 + return subprocess.CompletedProcess(argv, returncode) + + monkeypatch.setattr(engine.shutil, "which", fake_which) + monkeypatch.setattr(engine.subprocess, "run", fake_run) + + cap = engine.detect() + + assert cap.available is True + element_probe_argvs = [argv for argv in seen_argvs if argv[1:] != ["--help"]] + assert all(len(argv) == 2 for argv in element_probe_argvs) + + +def test_require_engine_raises_env_error_naming_apt_packages(monkeypatch): + monkeypatch.setattr(engine.shutil, "which", lambda _name: None) + + with pytest.raises(CliError) as excinfo: + engine.require_engine() + + err = excinfo.value + assert err.code == 2 + assert engine.GST_LAUNCH in err.message + assert "apt install" in err.remediation + for package in ( + engine.GST_TOOLS_PACKAGE, + engine.GST_PLUGINS_BASE_PACKAGE, + engine.GST_PLUGINS_GOOD_PACKAGE, + engine.GST_ALSA_PACKAGE, + ): + assert package in err.remediation + + +def test_require_engine_names_missing_elements_when_gst_launch_present(monkeypatch): + def fake_which(name): + return f"/usr/bin/{name}" if name in (engine.GST_LAUNCH, engine.GST_INSPECT) else None + + def fake_run(argv, **kwargs): + if argv[1:] == ["--help"]: + return subprocess.CompletedProcess(argv, 0, stdout="--exists") + element = argv[2] + returncode = 0 if element != "queue" else 1 + return subprocess.CompletedProcess(argv, returncode) + + monkeypatch.setattr(engine.shutil, "which", fake_which) + monkeypatch.setattr(engine.subprocess, "run", fake_run) + + with pytest.raises(CliError) as excinfo: + engine.require_engine() + + assert excinfo.value.code == 2 + assert "queue" in excinfo.value.message + + +def test_require_engine_returns_capability_when_available(monkeypatch): + def fake_which(name): + return f"/usr/bin/{name}" if name in (engine.GST_LAUNCH, engine.GST_INSPECT) else None + + def fake_run(argv, **kwargs): + if argv[1:] == ["--help"]: + return subprocess.CompletedProcess(argv, 0, stdout="--exists") + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(engine.shutil, "which", fake_which) + monkeypatch.setattr(engine.subprocess, "run", fake_run) + + cap = engine.require_engine() + + assert cap.available is True + + +def test_require_elements_passes_when_all_present(): + cap = engine.Capability( + gst_launch="/usr/bin/gst-launch-1.0", + gst_inspect="/usr/bin/gst-inspect-1.0", + plugins={"opusenc": True, "matroskamux": True}, + available=True, + ) + + engine.require_elements(cap, ["opusenc", "matroskamux"]) # no raise + + +def test_require_elements_raises_env_error_naming_missing(): + cap = engine.Capability( + gst_launch="/usr/bin/gst-launch-1.0", + gst_inspect="/usr/bin/gst-inspect-1.0", + plugins={"opusenc": False, "matroskamux": True}, + available=True, + ) + + with pytest.raises(CliError) as excinfo: + engine.require_elements(cap, ["opusenc", "matroskamux"]) + + assert excinfo.value.code == 2 + assert "opusenc" in excinfo.value.message + assert "matroskamux" not in excinfo.value.message.split(":", 1)[1] + + +# --- build_audio_stream_argv() ------------------------------------------------- + + +def test_build_audio_stream_argv_is_pure(monkeypatch): + calls = [] + monkeypatch.setattr(engine.subprocess, "run", lambda *a, **k: calls.append((a, k))) + + engine.build_audio_stream_argv( + "hw:CARD=Mic,DEV=0", engine.AudioFormat(rate=48000, channels=2), 5004 + ) + + assert calls == [] + + +def test_build_audio_stream_argv_passthrough_exact(): + fmt = engine.AudioFormat(rate=48000, channels=2, sample_format="S16LE") + + argv = engine.build_audio_stream_argv( + "hw:CARD=Mic,DEV=0", fmt, 5004, encode="passthrough", host="127.0.0.1" + ) + + assert argv == [ + "gst-launch-1.0", + "-e", + "alsasrc", + "device=hw:CARD=Mic,DEV=0", + "!", + "audio/x-raw,format=S16LE,rate=48000,channels=2", + "!", + "queue", + "!", + "audioconvert", + "!", + "audio/x-raw,format=S16BE", + "!", + "rtpL16pay", + "!", + "udpsink", + "host=127.0.0.1", + "port=5004", + ] + + +def test_build_audio_stream_argv_opus_exact(): + fmt = engine.AudioFormat(rate=48000, channels=1, sample_format="S16LE") + + argv = engine.build_audio_stream_argv( + "hw:CARD=Mic,DEV=0", fmt, 6000, encode="opus", host="192.168.1.5" + ) + + assert argv == [ + "gst-launch-1.0", + "-e", + "alsasrc", + "device=hw:CARD=Mic,DEV=0", + "!", + "audio/x-raw,format=S16LE,rate=48000,channels=1", + "!", + "queue", + "!", + "audioconvert", + "!", + "audioresample", + "!", + "opusenc", + "!", + "rtpopuspay", + "!", + "udpsink", + "host=192.168.1.5", + "port=6000", + ] + + +def test_build_audio_stream_argv_has_eos_flag_for_clean_shutdown(): + argv = engine.build_audio_stream_argv( + "hw:CARD=Mic,DEV=0", engine.AudioFormat(rate=48000, channels=2), 5004 + ) + + assert argv[0] == "gst-launch-1.0" + assert argv[1] == "-e" + + +def test_build_audio_stream_argv_contains_queue_element(): + argv = engine.build_audio_stream_argv( + "hw:CARD=Mic,DEV=0", engine.AudioFormat(rate=48000, channels=2), 5004 + ) + + assert "queue" in argv + + +def test_build_audio_stream_argv_rejects_unsupported_encode(): + fmt = engine.AudioFormat(rate=48000, channels=2) + with pytest.raises(CliError) as excinfo: + engine.build_audio_stream_argv("hw:CARD=Mic,DEV=0", fmt, 5004, encode="mp3") + + assert excinfo.value.code == 1 + + +def test_build_audio_stream_argv_rejects_invalid_format(): + fmt = engine.AudioFormat(rate=0, channels=2) + with pytest.raises(CliError) as excinfo: + engine.build_audio_stream_argv("hw:CARD=Mic,DEV=0", fmt, 5004) + + assert excinfo.value.code == 1 + + +def test_build_audio_stream_argv_rejects_invalid_port(): + fmt = engine.AudioFormat(rate=48000, channels=2) + with pytest.raises(CliError) as excinfo: + engine.build_audio_stream_argv("hw:CARD=Mic,DEV=0", fmt, 70000) + + assert excinfo.value.code == 1 + + +# --- build_audio_record_argv() ------------------------------------------------- + + +def test_build_audio_record_argv_is_pure(monkeypatch): + calls = [] + monkeypatch.setattr(engine.subprocess, "run", lambda *a, **k: calls.append((a, k))) + + engine.build_audio_record_argv( + "hw:CARD=Mic,DEV=0", engine.AudioFormat(rate=48000, channels=2), "/tmp/out.mka" + ) + + assert calls == [] + + +def test_build_audio_record_argv_mka_exact(): + fmt = engine.AudioFormat(rate=48000, channels=2, sample_format="S16LE") + + argv = engine.build_audio_record_argv("hw:CARD=Mic,DEV=0", fmt, "/tmp/out.mka", container="mka") + + assert argv == [ + "gst-launch-1.0", + "-e", + "alsasrc", + "device=hw:CARD=Mic,DEV=0", + "!", + "audio/x-raw,format=S16LE,rate=48000,channels=2", + "!", + "queue", + "!", + "audioconvert", + "!", + "audioresample", + "!", + "opusenc", + "!", + "matroskamux", + "!", + "filesink", + "location=/tmp/out.mka", + ] + + +def test_build_audio_record_argv_wav_exact(): + fmt = engine.AudioFormat(rate=16000, channels=1, sample_format="S16LE") + + argv = engine.build_audio_record_argv("hw:CARD=Mic,DEV=0", fmt, "/tmp/out.wav", container="wav") + + assert argv == [ + "gst-launch-1.0", + "-e", + "alsasrc", + "device=hw:CARD=Mic,DEV=0", + "!", + "audio/x-raw,format=S16LE,rate=16000,channels=1", + "!", + "queue", + "!", + "wavenc", + "!", + "filesink", + "location=/tmp/out.wav", + ] + + +def test_build_audio_record_argv_bounded_by_duration_exact(): + fmt = engine.AudioFormat(rate=48000, channels=2, sample_format="S16LE") + + argv = engine.build_audio_record_argv( + "hw:CARD=Mic,DEV=0", fmt, "/tmp/out.wav", container="wav", duration_s=5.0 + ) + + assert argv == [ + "gst-launch-1.0", + "-e", + "alsasrc", + "device=hw:CARD=Mic,DEV=0", + "num-buffers=500", + "latency-time=10000", + "!", + "audio/x-raw,format=S16LE,rate=48000,channels=2", + "!", + "queue", + "!", + "wavenc", + "!", + "filesink", + "location=/tmp/out.wav", + ] + + +def test_build_audio_record_argv_has_eos_flag_for_clean_finalize(): + argv = engine.build_audio_record_argv( + "hw:CARD=Mic,DEV=0", engine.AudioFormat(rate=48000, channels=2), "/tmp/out.mka" + ) + + assert argv[0] == "gst-launch-1.0" + assert argv[1] == "-e" + + +def test_build_audio_record_argv_contains_queue_element(): + argv = engine.build_audio_record_argv( + "hw:CARD=Mic,DEV=0", engine.AudioFormat(rate=48000, channels=2), "/tmp/out.mka" + ) + + assert "queue" in argv + + +def test_build_audio_record_argv_rejects_unsupported_container(): + fmt = engine.AudioFormat(rate=48000, channels=2) + with pytest.raises(CliError) as excinfo: + engine.build_audio_record_argv("hw:CARD=Mic,DEV=0", fmt, "/tmp/out.ogg", container="ogg") + + assert excinfo.value.code == 1 + + +def test_build_audio_record_argv_rejects_non_positive_duration(): + fmt = engine.AudioFormat(rate=48000, channels=2) + with pytest.raises(CliError) as excinfo: + engine.build_audio_record_argv("hw:CARD=Mic,DEV=0", fmt, "/tmp/out.wav", duration_s=0) + + assert excinfo.value.code == 1 + + +def test_build_audio_record_argv_rejects_invalid_format(): + fmt = engine.AudioFormat(rate=48000, channels=0) + with pytest.raises(CliError) as excinfo: + engine.build_audio_record_argv("hw:CARD=Mic,DEV=0", fmt, "/tmp/out.wav") + + assert excinfo.value.code == 1 diff --git a/tests/test_gain.py b/tests/test_gain.py new file mode 100644 index 0000000..189cfea --- /dev/null +++ b/tests/test_gain.py @@ -0,0 +1,480 @@ +"""Tests for the `gain` noun group: microphone_cli.cli._commands.gain + mixer.py. + +Hardware-free throughout: ``subprocess.run`` is always a fake (never a real +``amixer`` invocation) and the USB/firmware seam +(``gain._open_firmware``) is always monkeypatched to a fake +:class:`~microphone_cli.xvf3800.Xvf3800` built on a fake transfer callable — +no ``/dev`` node is ever opened. +""" + +from __future__ import annotations + +import json +import struct +import subprocess # nosec B404 - only used to build fake CompletedProcess objects +from pathlib import Path + +import pytest + +from microphone_cli import mixer, xvf3800 +from microphone_cli.cli import _CliArgumentParser +from microphone_cli.cli._commands import gain +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from microphone_cli.cli._output import emit_error +from microphone_cli.devices import MicrophoneDevice, UsbIds +from microphone_cli.xvf3800 import Xvf3800 + +CAPTURE_VOLUME_CONTROL = "Mic Capture Volume" + + +def run_gain(argv: list[str]) -> int: + """Parse/dispatch ``argv`` through a standalone parser carrying only the + ``gain`` noun group. + + The ``gain`` command is not yet wired into + :func:`microphone_cli.cli.main` (a separate task owns + ``cli/__init__.py``), so tests build the exact same parser shape + ``_build_parser()`` would once it registers ``gain`` — same + ``_CliArgumentParser`` class, same ``parser_class`` propagation — and + replicate ``_dispatch``'s ``CliError`` -> exit-code translation. + """ + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + gain.register(sub) + args = parser.parse_args(["gain", *argv]) + json_mode = bool(getattr(args, "json", False)) + try: + rc = args.func(args) + except CliError as err: + emit_error(err, json_mode=json_mode) + return err.code + return rc if rc is not None else 0 + + +# --------------------------------------------------------------------------- +# fixtures / fakes +# --------------------------------------------------------------------------- + + +class FakeAmixerRun: + """Fake ``subprocess.run`` serving ``amixer -c contents/cset``.""" + + def __init__(self, *, lo: int = 0, hi: int = 30, initial: int = 20, has_control: bool = True): + self.calls: list[list[str]] = [] + self.value = initial + self.lo = lo + self.hi = hi + self.has_control = has_control + + def __call__(self, argv, capture_output=True, text=True, check=False): + self.calls.append(list(argv)) + if "cset" in argv: + self.value = int(argv[-1]) + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + if "contents" in argv: + if not self.has_control: + stdout = ( + "numid=1,iface=MIXER,name='PCM Playback Switch'\n" + " ; type=BOOLEAN,access=rw------,values=1\n" + " : values=on\n" + ) + else: + stdout = ( + f"numid=3,iface=MIXER,name='{CAPTURE_VOLUME_CONTROL}'\n" + f" ; type=INTEGER,access=rw---R--,values=1," + f"min={self.lo},max={self.hi},step=0\n" + f" : values={self.value}\n" + ) + return subprocess.CompletedProcess(argv, 0, stdout=stdout, stderr="") + raise AssertionError(f"unexpected amixer invocation: {argv}") # pragma: no cover + + +def _missing_amixer_run(argv, capture_output=True, text=True, check=False): + raise FileNotFoundError("amixer") + + +class FakeFirmwareTransfer: + """Fake XVF3800 transfer callable serving AUDIO_MGR_MIC_GAIN (resid=35, cmdid=0).""" + + def __init__(self, gain_value: float = 0.42, *, fail: bool = False): + self.calls: list[tuple] = [] + self.gain_value = gain_value + self.fail = fail + + def __call__(self, request_type, request, value, index, data_or_length): + self.calls.append((request_type, request, value, index, data_or_length)) + if self.fail: + return bytes([1]) # non-zero, non-retry status -> CliError + if request_type == xvf3800.REQUEST_TYPE_IN: + return bytes([0]) + struct.pack(" MicrophoneDevice: + return MicrophoneDevice( + stable_id="usb-Generic_USB_Mic_ABC123", + label="Generic USB Mic", + alsa_address="hw:CARD=Mic", + card_id="Mic", + card_index=card_index, + usb_path="3-1", + usb_ids=UsbIds(vendor="046d", product="0825"), + serial="ABC123", + is_array=False, + channels=1, + pipewire_visible=None, + ) + + +def array_device(card_index: int = 2) -> MicrophoneDevice: + return MicrophoneDevice( + stable_id="usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001", + label="Reachy Mini Audio", + alsa_address="hw:CARD=Audio", + card_id="Audio", + card_index=card_index, + usb_path="5-1.1", + usb_ids=UsbIds(vendor="38fb", product="1001"), + serial="RM0001", + is_array=True, + channels=6, + pipewire_visible=True, + ) + + +@pytest.fixture +def fake_run(monkeypatch: pytest.MonkeyPatch) -> FakeAmixerRun: + run = FakeAmixerRun() + monkeypatch.setattr(subprocess, "run", run) + return run + + +def _patch_resolve(monkeypatch: pytest.MonkeyPatch, device: MicrophoneDevice) -> None: + monkeypatch.setattr(gain, "resolve", lambda selector, root="/": device) + + +def _patch_firmware(monkeypatch: pytest.MonkeyPatch, transfer: FakeFirmwareTransfer) -> None: + monkeypatch.setattr(gain, "_open_firmware", lambda device, root: Xvf3800(transfer)) + + +# --------------------------------------------------------------------------- +# mixer.py — parsing +# --------------------------------------------------------------------------- + + +def test_list_controls_parses_amixer_contents_blocks(fake_run: FakeAmixerRun) -> None: + controls = mixer.list_controls(1, run=fake_run) + assert len(controls) == 1 + control = controls[0] + assert control.numid == 3 + assert control.name == CAPTURE_VOLUME_CONTROL + assert control.control_type == "INTEGER" + assert control.min == 0 + assert control.max == 30 + assert control.values == (20,) + assert control.value == 20 + assert ["amixer", "-c", "1", "contents"] in fake_run.calls + + +def test_find_capture_volume_prefers_capture_volume_then_mic() -> None: + volume = mixer.MixerControl( + 1, "MIXER", "Mic Capture Volume", "INTEGER", "rw", 1, 0, 30, 0, (5,) + ) + switch = mixer.MixerControl( + 2, "MIXER", "Mic Capture Switch", "BOOLEAN", "rw", 1, None, None, None, ("on",) + ) + unrelated = mixer.MixerControl( + 3, "MIXER", "PCM Playback Switch", "BOOLEAN", "rw", 1, None, None, None, ("on",) + ) + + assert mixer.find_capture_volume([switch, unrelated, volume]) is volume + assert mixer.find_capture_volume([switch, unrelated]) is switch + assert mixer.find_capture_volume([unrelated]) is None + + +def test_get_gain_no_capture_control_is_a_user_error() -> None: + run = FakeAmixerRun(has_control=False) + with pytest.raises(CliError) as exc: + mixer.get_gain(1, run=run) + assert exc.value.code == EXIT_USER_ERROR + + +def test_amixer_missing_is_an_env_error_with_apt_hint() -> None: + with pytest.raises(CliError) as exc: + mixer.list_controls(1, run=_missing_amixer_run) + assert exc.value.code == EXIT_ENV_ERROR + assert "apt" in exc.value.remediation + + +def test_set_gain_argv_is_pure() -> None: + assert mixer.set_gain_argv(2, 3, 25) == ["amixer", "-c", "2", "cset", "numid=3", "25"] + + +def test_set_gain_issues_cset_then_rereads(fake_run: FakeAmixerRun) -> None: + control = mixer.get_gain(1, run=fake_run) + updated = mixer.set_gain(1, control, 25, run=fake_run) + assert updated.value == 25 + assert fake_run.calls[-2] == ["amixer", "-c", "1", "cset", "numid=3", "25"] + assert fake_run.calls[-1] == ["amixer", "-c", "1", "contents"] + + +# --------------------------------------------------------------------------- +# gain get +# --------------------------------------------------------------------------- + + +def test_gain_get_json_non_array_reports_alsa_only( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun, capsys: pytest.CaptureFixture[str] +) -> None: + device = non_array_device() + _patch_resolve(monkeypatch, device) + + rc = run_gain(["get", "usb-Generic_USB_Mic_ABC123", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["device"] == device.stable_id + assert payload["alsa"] == { + "control": CAPTURE_VOLUME_CONTROL, + "numid": 3, + "value": 20, + "min": 0, + "max": 30, + } + assert payload["firmware"] is None + + +def test_gain_get_json_array_adds_firmware( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun, capsys: pytest.CaptureFixture[str] +) -> None: + device = array_device() + _patch_resolve(monkeypatch, device) + _patch_firmware(monkeypatch, FakeFirmwareTransfer(gain_value=0.75)) + + rc = run_gain(["get", device.stable_id, "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["firmware"]["mic_gain"] == pytest.approx(0.75, rel=1e-5) + + +def test_gain_get_firmware_failure_still_returns_alsa_half( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun, capsys: pytest.CaptureFixture[str] +) -> None: + device = array_device() + _patch_resolve(monkeypatch, device) + _patch_firmware(monkeypatch, FakeFirmwareTransfer(fail=True)) + + rc = run_gain(["get", device.stable_id, "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["alsa"]["control"] == CAPTURE_VOLUME_CONTROL + assert "error" in payload["firmware"] + + +def test_gain_get_text_mode_smoke( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun, capsys: pytest.CaptureFixture[str] +) -> None: + device = non_array_device() + _patch_resolve(monkeypatch, device) + + rc = run_gain(["get", device.stable_id]) + + assert rc == 0 + out = capsys.readouterr().out + assert "device: " + device.stable_id in out + assert "firmware: n/a" in out + + +# --------------------------------------------------------------------------- +# gain set — dry run +# --------------------------------------------------------------------------- + + +def test_gain_set_dry_run_plans_without_touching_hardware( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun, capsys: pytest.CaptureFixture[str] +) -> None: + device = array_device() + _patch_resolve(monkeypatch, device) + transfer = FakeFirmwareTransfer() + _patch_firmware(monkeypatch, transfer) + + rc = run_gain(["set", device.stable_id, "0.5", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == "dry-run" + assert payload["applied"] is False + assert payload["hardware_touched"] is False + assert payload["device"] == device.stable_id + # ALSA plan: 0.5 mapped onto [0, 30] -> 15, via the real control's numid. + assert payload["planned"]["alsa"]["argv"] == [ + "amixer", + "-c", + str(device.card_index), + "cset", + "numid=3", + "15", + ] + assert payload["planned"]["firmware"] == {"param": "AUDIO_MGR_MIC_GAIN", "values": [0.5]} + # A read of the current control is fine (needed to know numid/min/max); + # no cset was ever issued. + assert all("cset" not in call for call in fake_run.calls) + # Dry run never opens the firmware device at all. + assert transfer.calls == [] + + +@pytest.mark.parametrize("bad_value", ["1.5", "-0.1", "nan", "inf"]) +def test_gain_set_rejects_out_of_range_or_non_finite_values( + monkeypatch: pytest.MonkeyPatch, + fake_run: FakeAmixerRun, + bad_value: str, +) -> None: + device = array_device() + _patch_resolve(monkeypatch, device) + transfer = FakeFirmwareTransfer() + _patch_firmware(monkeypatch, transfer) + + rc = run_gain(["set", device.stable_id, bad_value, "--apply"]) + + assert rc == EXIT_USER_ERROR + assert all("cset" not in call for call in fake_run.calls) + assert transfer.calls == [] + + +def test_gain_set_dry_run_target_firmware_on_non_array_is_user_error( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun +) -> None: + device = non_array_device() + _patch_resolve(monkeypatch, device) + + rc = run_gain(["set", device.stable_id, "0.5", "--target", "firmware"]) + + assert rc == EXIT_USER_ERROR + + +def test_gain_set_dry_run_both_on_non_array_only_plans_alsa( + monkeypatch: pytest.MonkeyPatch, fake_run: FakeAmixerRun, capsys: pytest.CaptureFixture[str] +) -> None: + device = non_array_device() + _patch_resolve(monkeypatch, device) + + rc = run_gain(["set", device.stable_id, "1.0", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "firmware" not in payload["planned"] + assert payload["planned"]["alsa"]["argv"][-1] == "30" # 1.0 -> max + + +# --------------------------------------------------------------------------- +# gain set — apply +# --------------------------------------------------------------------------- + + +def test_gain_set_apply_issues_both_writes_and_logs_one_activation_line( + monkeypatch: pytest.MonkeyPatch, + fake_run: FakeAmixerRun, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + log_path = tmp_path / "activation.jsonl" + monkeypatch.setenv("MICROPHONE_ACTIVATION_LOG", str(log_path)) + + device = array_device() + _patch_resolve(monkeypatch, device) + transfer = FakeFirmwareTransfer(gain_value=0.1) + _patch_firmware(monkeypatch, transfer) + + rc = run_gain(["set", device.stable_id, "0.5", "--apply", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == "apply" + assert payload["applied"] is True + assert payload["hardware_touched"] is True + assert payload["alsa"]["value"] == 15 + assert payload["firmware"]["mic_gain"] == pytest.approx(0.5, rel=1e-5) + + # amixer: one read + one cset (+ the re-read inside mixer.set_gain). + assert any("cset" in call for call in fake_run.calls) + # firmware: one read (initial not needed) + one write + one read-back. + assert transfer.calls, "firmware transfer was never invoked" + + lines = log_path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["verb"] == "gain set" + assert record["device"] == device.stable_id + assert record["ended_at"] is not None + + +def test_gain_set_apply_alsa_only_on_non_array( + monkeypatch: pytest.MonkeyPatch, + fake_run: FakeAmixerRun, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + monkeypatch.setenv("MICROPHONE_ACTIVATION_LOG", str(tmp_path / "activation.jsonl")) + device = non_array_device() + _patch_resolve(monkeypatch, device) + + rc = run_gain(["set", device.stable_id, "0.0", "--apply", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["alsa"]["value"] == 0 + assert "firmware" not in payload + + +# --------------------------------------------------------------------------- +# overview / bare noun +# --------------------------------------------------------------------------- + + +def test_gain_overview_json() -> None: + rc = run_gain(["overview", "--json"]) + assert rc == 0 + + +def test_bare_gain_prints_overview(capsys: pytest.CaptureFixture[str]) -> None: + rc = run_gain([]) + assert rc == 0 + assert "microphone gain" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# Two same-named controls (found on hardware: XVF3800 'Headset Capture Volume' +# appears twice, the second with ",index=1"). The parser must keep them apart. +# --------------------------------------------------------------------------- + +_TWO_CONTROLS = """numid=10,iface=MIXER,name='Headset Capture Volume' + ; type=INTEGER,access=rw---R--,values=2,min=0,max=60,step=0 + : values=30,30 + | dBminmax-min=-60.00dB,max=0.00dB +numid=11,iface=MIXER,name='Headset Capture Volume',index=1 + ; type=INTEGER,access=rw---R--,values=1,min=0,max=60,step=0 + : values=60 +""" + + +def test_list_controls_keeps_indexed_duplicate_apart() -> None: + from microphone_cli import mixer + + def run(argv, **_kw): # noqa: ANN001 + class R: + returncode = 0 + stdout = _TWO_CONTROLS + stderr = "" + + return R() + + controls = mixer.list_controls(1, run=run) + by_numid = {c.numid: c for c in controls} + assert set(by_numid) == {10, 11} + assert by_numid[10].values == (30, 30) + assert by_numid[11].values == (60,) + assert mixer.find_capture_volume(controls).numid == 10 diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 0000000..fa90a50 --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,246 @@ +"""Tests for ``microphone inspect`` (:mod:`microphone_cli.cli._commands.inspect`). + +Firmware reads never touch a real ``/dev/bus/usb`` node: ``usbctl.find_devices`` +and ``usbctl.open_device`` are monkeypatched to hand back a fake fd, and +``usbctl._ioctl`` (the same seam :mod:`tests.test_xvf3800` uses) is replaced +with a fake that serves status-0 replies for VERSION/BLD_MSG/BLD_HOST/ +BLD_REPO_HASH. No test in this file opens a real device. +""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import os +import shutil + +from microphone_cli import usbctl, xvf3800 +from microphone_cli.cli._commands import inspect as inspect_cmd +from microphone_cli.cli._errors import EXIT_ENV_ERROR, CliError +from microphone_cli.devices import enumerate_devices + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + + +def tree(name: str) -> str: + return os.path.join(FIXTURES, name) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + inspect_cmd.register(sub) + return parser + + +class FakeIoctl: + """Serves queued IN replies in order; records nothing else needed here.""" + + def __init__(self, replies: list[bytes]) -> None: + self.replies = list(replies) + self.calls: list[int] = [] + + def __call__(self, fd: int, request: int, arg: object) -> int: + xfer = arg + self.calls.append(fd) + if xfer.bRequestType & 0x80: + reply = self.replies.pop(0) + ctypes.memmove(xfer.data, reply, min(len(reply), xfer.wLength)) + return xfer.wLength + + +def _firmware_replies() -> list[bytes]: + return [ + b"\x00\x01\x02\x03", + b"\x00" + b"build-msg".ljust(50, b"\x00"), + b"\x00" + b"buildhost".ljust(30, b"\x00"), + b"\x00" + b"deadbeef".ljust(40, b"\x00"), + ] + + +def _patch_firmware_transport(monkeypatch, node: str = "/dev/bus/usb/005/007") -> FakeIoctl: + """Install a fully fake USB transport: find -> open -> ioctl replies.""" + + def fake_find_devices(root, vendor=None, product=None, serial=None): + return [{"node": node, "vendor": vendor, "product": product, "serial": serial}] + + monkeypatch.setattr(usbctl, "find_devices", fake_find_devices) + monkeypatch.setattr(usbctl, "open_device", lambda path, **kw: 42) + monkeypatch.setattr(xvf3800.os, "close", lambda fd: None) + fake = FakeIoctl(_firmware_replies()) + monkeypatch.setattr(usbctl, "_ioctl", fake) + return fake + + +# --------------------------------------------------------------------------- +# register() +# --------------------------------------------------------------------------- + + +def test_register_exposes_inspect_with_positional_device_json_and_root() -> None: + parser = _parser() + args = parser.parse_args( + ["inspect", "usb-something", "--json", "--root", tree("host-baseline")] + ) + assert args.func is inspect_cmd.cmd_inspect + assert args.device == "usb-something" + assert args.json is True + assert args.root == tree("host-baseline") + + +def test_inspect_default_root_is_slash() -> None: + parser = _parser() + args = parser.parse_args(["inspect", "usb-something"]) + assert args.root == "/" + assert args.json is False + + +# --------------------------------------------------------------------------- +# array fixture: formats/rates/channels + firmware +# --------------------------------------------------------------------------- + + +def test_inspect_array_reports_formats_rates_channels_and_firmware(monkeypatch, capsys) -> None: + root = tree("host-baseline") + (device,) = enumerate_devices(root=root) + assert device.is_array is True + + _patch_firmware_transport(monkeypatch) + + parser = _parser() + args = parser.parse_args(["inspect", device.stable_id, "--json", "--root", root]) + rc = args.func(args) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["formats"] == ["S32_LE"] + assert payload["rates"] == [48000] + assert payload["channels"] == 6 + assert payload["firmware"] == { + "version": "1.2.3", + "build": "build-msg", + "host": "buildhost", + "repo_hash": "deadbeef", + } + assert payload["device"]["stable_id"] == device.stable_id + + +def test_inspect_build_report_matches_cli_json(monkeypatch) -> None: + root = tree("host-baseline") + (device,) = enumerate_devices(root=root) + _patch_firmware_transport(monkeypatch) + report = inspect_cmd.build_report(device.stable_id, root) + assert set(report) == {"device", "formats", "rates", "channels", "firmware"} + + +def test_inspect_text_mode_includes_firmware(monkeypatch, capsys) -> None: + root = tree("host-baseline") + (device,) = enumerate_devices(root=root) + _patch_firmware_transport(monkeypatch) + + parser = _parser() + args = parser.parse_args(["inspect", device.stable_id, "--root", root]) + rc = args.func(args) + assert rc == 0 + out = capsys.readouterr().out + assert "channels: 6" in out + assert "S32_LE" in out + assert "version=1.2.3" in out + + +# --------------------------------------------------------------------------- +# non-array: firmware is null +# --------------------------------------------------------------------------- + + +def test_inspect_non_array_firmware_is_null(tmp_path, capsys) -> None: + dest = tmp_path / "nonarray" + shutil.copytree(tree("host-baseline"), dest, symlinks=True) + sysfs_dev = dest / "sys/devices/platform/NVDA8000:02/usb5/5-1/5-1.1" + (sysfs_dev / "idVendor").write_text("046d\n") + (sysfs_dev / "idProduct").write_text("0825\n") + + root = str(dest) + (device,) = enumerate_devices(root=root) + assert device.is_array is False + + parser = _parser() + args = parser.parse_args(["inspect", device.stable_id, "--json", "--root", root]) + rc = args.func(args) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["firmware"] is None + # stream0 still describes the same capture interface either way. + assert payload["channels"] == 6 + assert payload["formats"] == ["S32_LE"] + assert payload["rates"] == [48000] + + +# --------------------------------------------------------------------------- +# missing stream0 +# --------------------------------------------------------------------------- + + +def test_inspect_missing_stream0_falls_back_to_device_channels(capsys) -> None: + root = tree("respeaker") + (device,) = enumerate_devices(root=root) + assert device.channels is None # no stream0 in this fixture (see test_devices.py) + + parser = _parser() + args = parser.parse_args(["inspect", device.stable_id, "--json", "--root", root]) + rc = args.func(args) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["formats"] == [] + assert payload["rates"] == [] + assert payload["channels"] is None + + +# --------------------------------------------------------------------------- +# firmware descriptive failures (never hard-fail) +# --------------------------------------------------------------------------- + + +def test_inspect_array_no_usb_node_found_reports_error_but_succeeds(monkeypatch, capsys) -> None: + root = tree("host-baseline") + (device,) = enumerate_devices(root=root) + + monkeypatch.setattr( + usbctl, "find_devices", lambda root, vendor=None, product=None, serial=None: [] + ) + + parser = _parser() + args = parser.parse_args(["inspect", device.stable_id, "--json", "--root", root]) + rc = args.func(args) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "error" in payload["firmware"] + assert device.stable_id in payload["firmware"]["error"] + + +def test_inspect_array_open_permission_error_reports_but_succeeds(monkeypatch, capsys) -> None: + root = tree("host-baseline") + (device,) = enumerate_devices(root=root) + + monkeypatch.setattr( + usbctl, + "find_devices", + lambda root, vendor=None, product=None, serial=None: [{"node": "/dev/bus/usb/005/007"}], + ) + + def deny(node: str, **_kw: object) -> int: + raise CliError( + code=EXIT_ENV_ERROR, + message=f"permission denied opening {node}", + remediation="add a udev rule", + ) + + monkeypatch.setattr(usbctl, "open_device", deny) + + parser = _parser() + args = parser.parse_args(["inspect", device.stable_id, "--json", "--root", root]) + rc = args.func(args) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["firmware"] == {"error": "permission denied opening /dev/bus/usb/005/007"} diff --git a/tests/test_list.py b/tests/test_list.py new file mode 100644 index 0000000..9429acd --- /dev/null +++ b/tests/test_list.py @@ -0,0 +1,117 @@ +"""Tests for ``microphone list`` (:mod:`microphone_cli.cli._commands.list_devices`). + +Everything runs against fixture trees under ``tests/fixtures`` (or an empty +``tmp_path``) — no real ``/dev`` node is ever opened; ``check_access`` still +runs its single non-blocking ``open()``/``close()`` probe, but the probed path +never exists under a fixture root, so the worst it can do is report +``state: "absent"``. +""" + +from __future__ import annotations + +import argparse +import json +import os + +from microphone_cli.cli._commands import list_devices +from microphone_cli.devices import enumerate_devices + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + + +def tree(name: str) -> str: + return os.path.join(FIXTURES, name) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + list_devices.register(sub) + return parser + + +def test_register_exposes_list_with_json_and_root() -> None: + parser = _parser() + args = parser.parse_args(["list", "--json", "--root", tree("host-baseline")]) + assert args.func is list_devices.cmd_list + assert args.json is True + assert args.root == tree("host-baseline") + + +def test_list_default_root_is_slash() -> None: + parser = _parser() + args = parser.parse_args(["list"]) + assert args.root == "/" + assert args.json is False + + +def test_list_json_payload_shape() -> None: + report = list_devices.build_report(tree("host-baseline")) + assert set(report) == {"devices", "count"} + assert report["count"] == 1 + (payload,) = report["devices"] + assert set(payload["audio_access"]) == {"state", "path", "remediation"} + + +def test_list_devices_equal_enumerate_devices_as_dict() -> None: + root = tree("host-baseline") + report = list_devices.build_report(root) + expected = [device.as_dict() for device in enumerate_devices(root=root)] + assert report["count"] == len(expected) + for got, want in zip(report["devices"], expected): + got_without_access = {k: v for k, v in got.items() if k != "audio_access"} + assert got_without_access == want + + +def test_list_audio_access_absent_when_no_dev_node_in_fixture() -> None: + report = list_devices.build_report(tree("host-baseline")) + (payload,) = report["devices"] + assert payload["audio_access"]["state"] == "absent" + # Root-joined: a fixture never probes the host's real /dev/snd node. + assert payload["audio_access"]["path"].endswith("/dev/snd/pcmC1D0c") + assert payload["audio_access"]["path"].startswith(tree("host-baseline")) + + +def test_list_two_arrays_reports_both_devices() -> None: + root = tree("two-arrays") + report = list_devices.build_report(root) + assert report["count"] == 2 + assert {d["serial"] for d in report["devices"]} == {"RM0001", "RM0002"} + + +def test_list_json_cli_matches_build_report(capsys) -> None: + root = tree("host-baseline") + parser = _parser() + args = parser.parse_args(["list", "--json", "--root", root]) + rc = args.func(args) + assert rc == 0 + out = json.loads(capsys.readouterr().out) + assert out == list_devices.build_report(root) + + +def test_list_text_mode_prints_stable_id_label_address_and_array_marker(capsys) -> None: + parser = _parser() + args = parser.parse_args(["list", "--root", tree("host-baseline")]) + rc = args.func(args) + assert rc == 0 + out = capsys.readouterr().out + assert "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001" in out + assert "Reachy Mini Audio" in out + assert "hw:CARD=Audio" in out + assert "[array]" in out + + +def test_list_empty_root_reports_zero_devices(tmp_path, capsys) -> None: + parser = _parser() + args = parser.parse_args(["list", "--json", "--root", str(tmp_path)]) + rc = args.func(args) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == {"devices": [], "count": 0} + + +def test_list_empty_root_text_mode(tmp_path, capsys) -> None: + parser = _parser() + args = parser.parse_args(["list", "--root", str(tmp_path)]) + args.func(args) + assert capsys.readouterr().out.strip() == "no microphones found" diff --git a/tests/test_param.py b/tests/test_param.py new file mode 100644 index 0000000..d66d81b --- /dev/null +++ b/tests/test_param.py @@ -0,0 +1,428 @@ +"""Tests for the ``param`` noun (`microphone_cli.cli._commands.param`). + +The noun is not wired into the top-level CLI yet (that is a separate wiring +task), so tests build a standalone parser with ``param.register()`` directly, +reusing the real ``_CliArgumentParser``/``_dispatch`` error plumbing from +:mod:`microphone_cli.cli`. Every device/transfer touchpoint is monkeypatched: +``param.resolve`` returns a fixed fake :class:`MicrophoneDevice`, and +``param._open_array`` returns ``Xvf3800(FakeTransfer())`` — no test here ever +opens ``/dev``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from microphone_cli.cli import _argv_has_json, _CliArgumentParser, _dispatch +from microphone_cli.cli._commands import param +from microphone_cli.devices import MicrophoneDevice, UsbIds +from microphone_cli.xvf3800 import PARAMETERS, Xvf3800 + +FAKE_DEVICE = MicrophoneDevice( + stable_id="usb-test-array", + label="Test Array", + alsa_address="hw:CARD=Test", + card_id="Test", + card_index=1, + usb_path="1-1", + usb_ids=UsbIds(vendor="38fb", product="1001"), + serial="SN1", + is_array=True, + channels=6, + pipewire_visible=None, +) + +FAKE_NON_ARRAY_DEVICE = MicrophoneDevice( + stable_id="usb-test-plain", + label="Plain Mic", + alsa_address="hw:CARD=Plain", + card_id="Plain", + card_index=2, + usb_path="1-2", + usb_ids=UsbIds(vendor="dead", product="beef"), + serial="SN2", + is_array=False, + channels=1, + pipewire_visible=None, +) + + +class FakeTransfer: + """Records every control-transfer call; round-trips OUT writes into IN replies.""" + + def __init__(self) -> None: + self.calls: list[tuple[object, ...]] = [] + self._written: dict[tuple[int, int], bytes] = {} + + def __call__( + self, + request_type: int, + request: int, + value: int, + index: int, + data_or_length: object, + ) -> bytes: + self.calls.append((request_type, request, value, index, data_or_length)) + if request_type == 0xC0: # IN + length = int(data_or_length) # type: ignore[arg-type] + cmdid = value & 0x7F + body = self._written.get((index, cmdid), b"") + body = body[: length - 1].ljust(length - 1, b"\x00") + return bytes([0]) + body + # OUT + self._written[(index, value)] = bytes(bytearray(data_or_length)) # type: ignore[arg-type] + return b"" + + +def _run( + monkeypatch: pytest.MonkeyPatch, + argv: list[str], + *, + device: MicrophoneDevice = FAKE_DEVICE, + transfer: FakeTransfer | None = None, + forbid_open: bool = False, +) -> int: + monkeypatch.setattr(param, "resolve", lambda selector, root="/": device) + if forbid_open: + + def _no_open(device: MicrophoneDevice, root: str) -> Xvf3800: + raise AssertionError("_open_array must not be called") + + monkeypatch.setattr(param, "_open_array", _no_open) + else: + fake = transfer if transfer is not None else FakeTransfer() + monkeypatch.setattr(param, "_open_array", lambda device, root: Xvf3800(fake)) + + _CliArgumentParser._json_hint = _argv_has_json(argv) + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + param.register(sub) + args = parser.parse_args(argv) + return _dispatch(args) + + +# --- overview --------------------------------------------------------------- + + +def test_param_bare_prints_overview( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run(monkeypatch, ["param"]) + assert rc == 0 + assert "# microphone param" in capsys.readouterr().out + + +def test_param_overview_json( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run(monkeypatch, ["param", "overview", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["subject"] == "microphone param" + + +# --- list --------------------------------------------------------------- + + +def test_param_list_json_every_entry( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run(monkeypatch, ["param", "list", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["count"] == len(PARAMETERS) == len(payload["params"]) + by_name = {row["name"]: row for row in payload["params"]} + assert set(by_name) == set(PARAMETERS) + for name, (resid, cmdid, count, access, type_) in PARAMETERS.items(): + row = by_name[name] + assert row == { + "name": name, + "resid": resid, + "cmdid": cmdid, + "count": count, + "access": access, + "type": type_, + "persistent": row["persistent"], + } + assert isinstance(row["persistent"], bool) + assert by_name["REBOOT"]["persistent"] is True + assert by_name["AUDIO_MGR_MIC_GAIN"]["persistent"] is False + # sorted by name + names = [row["name"] for row in payload["params"]] + assert names == sorted(names) + + +def test_param_list_text( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run(monkeypatch, ["param", "list"]) + assert rc == 0 + out = capsys.readouterr().out + assert "REBOOT" in out + assert "persistent=True" in out + + +# --- get ------------------------------------------------------------------ + + +def test_param_get_unknown_name( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run(monkeypatch, ["param", "get", "usb-test-array", "NOT_A_PARAM"], forbid_open=True) + assert rc == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_param_get_wo_name_exits_1( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert PARAMETERS["REBOOT"][3] == "wo" + rc = _run(monkeypatch, ["param", "get", "usb-test-array", "reboot"], forbid_open=True) + assert rc == 1 + err = capsys.readouterr().err + assert "write-only" in err + + +def test_param_get_reads_and_echoes_upper( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + fake = FakeTransfer() + rc = _run( + monkeypatch, + ["param", "get", "usb-test-array", "audio_mgr_mic_gain", "--json"], + transfer=fake, + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["device"] == "usb-test-array" + assert payload["param"]["name"] == "AUDIO_MGR_MIC_GAIN" + assert payload["values"] == [0.0] + + +def test_param_get_non_array_device_exits_1( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run( + monkeypatch, + ["param", "get", "usb-test-plain", "VERSION"], + device=FAKE_NON_ARRAY_DEVICE, + forbid_open=True, + ) + assert rc == 1 + assert "not an XVF3800" in capsys.readouterr().err + + +# --- set: validation (no transfer) ------------------------------------------ + + +def test_param_set_unknown_name_no_transfer( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "NOT_A_PARAM", "1"], + forbid_open=True, + ) + assert rc == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_param_set_ro_name_no_transfer( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert PARAMETERS["VERSION"][3] == "ro" + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "VERSION", "1", "2", "3"], + forbid_open=True, + ) + assert rc == 1 + assert "read-only" in capsys.readouterr().err + + +def test_param_set_wrong_count_no_transfer( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert PARAMETERS["AUDIO_MGR_MIC_GAIN"][2] == 1 + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "AUDIO_MGR_MIC_GAIN", "1.0", "2.0"], + forbid_open=True, + ) + assert rc == 1 + assert "takes 1 value" in capsys.readouterr().err + + +def test_param_set_wrong_count_no_transfer_even_with_apply( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "AUDIO_MGR_MIC_GAIN", "1.0", "2.0", "--apply"], + forbid_open=True, + ) + assert rc == 1 + + +# --- set: dry-run ----------------------------------------------------------- + + +def test_param_set_dry_run_payload( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "audio_mgr_mic_gain", "0.5", "--json"], + forbid_open=True, + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "mode": "dry-run", + "applied": False, + "hardware_touched": False, + "device": "usb-test-array", + "param": { + "name": "AUDIO_MGR_MIC_GAIN", + "resid": 35, + "cmdid": 0, + "count": 1, + "access": "rw", + "type": "float", + "persistent": False, + }, + "values": [0.5], + } + + +# --- set: persistent gate ---------------------------------------------------- + + +def test_param_set_reboot_apply_without_allow_persistent( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "REBOOT", "1", "--apply"], + forbid_open=True, + ) + assert rc == 1 + err = capsys.readouterr().err + assert "error:" in err + assert "--allow-persistent" in err + assert "volatile" in err.lower() + + +@pytest.mark.parametrize( + "name,values", + [ + ("REBOOT", ["1"]), + ("SAVE_CONFIGURATION", ["1"]), + ("CLEAR_CONFIGURATION", ["1"]), + ("TEST_CORE_BURN", ["1"]), + ("TEST_AEC_DISABLE_CONTROL", ["1"]), + ("SPECIAL_CMD_AEC_FAR_MIC_INDEX", ["1", "2"]), + ], +) +def test_persistent_tier_names_require_allow_persistent( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + name: str, + values: list[str], +) -> None: + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", name, *values, "--apply"], + forbid_open=True, + ) + assert rc == 1 + assert "--allow-persistent" in capsys.readouterr().err + + +def test_param_set_reboot_apply_with_allow_persistent_sends_and_logs( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + log_file = tmp_path / "activation.jsonl" + monkeypatch.setenv("MICROPHONE_ACTIVATION_LOG", str(log_file)) + fake = FakeTransfer() + rc = _run( + monkeypatch, + [ + "param", + "set", + "usb-test-array", + "REBOOT", + "1", + "--apply", + "--allow-persistent", + "--json", + ], + transfer=fake, + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["applied"] is True + assert payload["hardware_touched"] is True + assert payload["mode"] == "apply" + assert payload["param"]["name"] == "REBOOT" + assert "readback" not in payload # REBOOT is write-only + + # exactly one OUT transfer, and it happened. + out_calls = [c for c in fake.calls if c[0] == 0x40] + assert len(out_calls) == 1 + + lines = log_file.read_text().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["verb"] == "param set" + assert record["device"] == "usb-test-array" + assert record["params"]["param"] == "REBOOT" + assert record["params"]["persistent"] is True + assert record["ended_at"] is not None + + +# --- set: apply on an ordinary rw param, with readback ----------------------- + + +def test_param_set_rw_apply_includes_readback( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + monkeypatch.setenv("MICROPHONE_ACTIVATION_LOG", str(tmp_path / "activation.jsonl")) + fake = FakeTransfer() + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "AUDIO_MGR_MIC_GAIN", "0.75", "--apply", "--json"], + transfer=fake, + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["applied"] is True + assert payload["readback"] == pytest.approx([0.75]) + + +def test_param_set_case_insensitive_echoes_upper( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + monkeypatch.setenv("MICROPHONE_ACTIVATION_LOG", str(tmp_path / "activation.jsonl")) + fake = FakeTransfer() + rc = _run( + monkeypatch, + ["param", "set", "usb-test-array", "shf_bypass", "1", "--apply", "--json"], + transfer=fake, + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["param"]["name"] == "SHF_BYPASS" diff --git a/tests/test_record.py b/tests/test_record.py new file mode 100644 index 0000000..987d1f7 --- /dev/null +++ b/tests/test_record.py @@ -0,0 +1,589 @@ +"""Tests for ``microphone-cli record`` — hardware-free by construction. + +Same posture as ``tests/test_stream.py``: a synthetic device tree, an autouse +booby trap on :func:`subprocess.Popen` / :func:`subprocess.run`, the activation +log redirected into ``tmp_path``, and the ``--apply`` path driven entirely +through this module's ``_spawn`` / ``_sleep`` / ``_monotonic`` seams so the +duration and max-bytes bounds are exercised without a real recorder. +""" + +from __future__ import annotations + +import json +import os +import subprocess # nosec B404 - only ever monkeypatched into a booby trap here + +import pytest + +from microphone_cli import access, activation, engine +from microphone_cli.cli import _CliArgumentParser, _dispatch +from microphone_cli.cli._commands import record + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") +BASELINE = os.path.join(FIXTURES, "host-baseline") +SELECTOR = "Audio" + + +# --------------------------------------------------------------------------- +# harness +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _no_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: + def _trap(*args: object, **kwargs: object) -> None: + raise AssertionError(f"subprocess spawned in a test: {args!r} {kwargs!r}") + + monkeypatch.setattr(subprocess, "Popen", _trap) + monkeypatch.setattr(subprocess, "run", _trap) + + +@pytest.fixture(autouse=True) +def _activation_log(monkeypatch: pytest.MonkeyPatch, tmp_path) -> str: + path = str(tmp_path / "activation.jsonl") + monkeypatch.setenv(activation.ENV_LOG_PATH, path) + return path + + +def run(argv: list[str]) -> int: + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + record.register(sub) + args = parser.parse_args(argv) + return _dispatch(args) + + +def payload(capsys: pytest.CaptureFixture[str]) -> dict: + return json.loads(capsys.readouterr().out) + + +def available_capability() -> engine.Capability: + return engine.Capability( + gst_launch="/usr/bin/gst-launch-1.0", + gst_inspect="/usr/bin/gst-inspect-1.0", + plugins=dict.fromkeys(engine.ALL_ELEMENTS, True), + available=True, + ) + + +def ok_report(path: str) -> access.AccessReport: + return access.AccessReport(path=path, kind="audio", state=access.AccessState.OK, remediation="") + + +class FakeProc: + """Popen-like: writes ``chunk`` bytes to ``path`` on each poll, then exits. + + ``exit_after`` polls of ``None`` are returned before ``returncode`` is + reported; ``None`` means "never exits on its own", which is how the + duration and max-bytes bounds get exercised. + """ + + def __init__( + self, + path: str, + *, + chunk: int = 16, + exit_after: int | None = 2, + rc: int = 0, + ) -> None: + self.pid = 4242 + self.path = path + self.chunk = chunk + self.exit_after = exit_after + self.rc = rc + self.polls = 0 + self.terminated = False + self.waited = False + self._returncode: int | None = None + + def _write(self) -> None: + with open(self.path, "ab") as handle: + handle.write(b"\0" * self.chunk) + + def poll(self) -> int | None: + self.polls += 1 + self._write() + if self.exit_after is not None and self.polls > self.exit_after: + self._returncode = self.rc + return self._returncode + + def terminate(self) -> None: + self.terminated = True + self._returncode = 0 + + def wait(self, timeout: float | None = None) -> int: + self.waited = True + self._returncode = 0 if self._returncode is None else self._returncode + return self._returncode + + +class StubbornProc: + """A child that ignores SIGTERM: only ``kill()`` ever ends it. + + ``killable=False`` models the worse case — a process wedged in + uninterruptible I/O on the capture device, which SIGKILL cannot reap + either. ``wait()`` raises :class:`subprocess.TimeoutExpired` for as long as + the process is alive, exactly as :class:`subprocess.Popen` does. + """ + + def __init__(self, path: str, *, chunk: int = 16, killable: bool = True) -> None: + self.pid = 9191 + self.path = path + self.chunk = chunk + self.killable = killable + self.terminated = False + self.killed = False + self._returncode: int | None = None + + def poll(self) -> int | None: + with open(self.path, "ab") as handle: + handle.write(b"\0" * self.chunk) + return self._returncode + + def terminate(self) -> None: + self.terminated = True # ... and nothing else: SIGTERM is ignored. + + def kill(self) -> None: + self.killed = True + if self.killable: + self._returncode = -9 + + def wait(self, timeout: float | None = None) -> int: + if self._returncode is None: + raise subprocess.TimeoutExpired(cmd="gst-launch-1.0", timeout=timeout or 0) + return self._returncode + + +class OversizeProc: + """Writes past the size cap and exits 0 within a single poll interval. + + The bug this guards: the process-exit check ran before the artifact's size + was compared with the cap, so a finished-but-oversized recording was + reported as a clean ``eos``. + """ + + def __init__(self, path: str, *, total: int) -> None: + self.pid = 5150 + self.path = path + self.total = total + self.terminated = False + + def poll(self) -> int | None: + with open(self.path, "ab") as handle: + handle.write(b"\0" * self.total) + return 0 + + def terminate(self) -> None: # pragma: no cover - it exited on its own + self.terminated = True + + def wait(self, timeout: float | None = None) -> int: # pragma: no cover + return 0 + + +def arm_apply(monkeypatch: pytest.MonkeyPatch, proc: object, *, ticks: float = 1.0) -> list[float]: + """Wire every seam --apply uses; return the list sleeps were recorded into.""" + slept: list[float] = [] + clock = {"t": 0.0} + + def _monotonic() -> float: + return clock["t"] + + def _sleep(seconds: float) -> None: + slept.append(seconds) + clock["t"] += ticks + + monkeypatch.setattr(engine, "detect", available_capability) + monkeypatch.setattr(access, "check_access", lambda path, kind: ok_report(path)) + monkeypatch.setattr(record, "_spawn", lambda argv: proc) + monkeypatch.setattr(record, "_monotonic", _monotonic) + monkeypatch.setattr(record, "_sleep", _sleep) + return slept + + +def base_argv(output: str, *extra: str) -> list[str]: + return ["record", SELECTOR, output, "--root", BASELINE, *extra] + + +# --------------------------------------------------------------------------- +# dry run +# --------------------------------------------------------------------------- + + +def test_dry_run_prints_the_pipeline_and_writes_nothing( + tmp_path, capsys: pytest.CaptureFixture[str], _activation_log: str +) -> None: + out_path = str(tmp_path / "clip.mka") + assert run(base_argv(out_path)) == 0 + out = capsys.readouterr().out + assert "gst-launch-1.0" in out + assert not os.path.exists(out_path) + assert not os.path.exists(_activation_log) + + +def test_dry_run_json_reports_no_hardware_and_no_engine_check( + tmp_path, capsys: pytest.CaptureFixture[str] +) -> None: + out_path = str(tmp_path / "clip.mka") + assert run(base_argv(out_path, "--json")) == 0 + data = payload(capsys) + assert set(data) == set(record.PAYLOAD_KEYS) + assert data["mode"] == "dry-run" + assert data["apply"] is False + assert data["kind"] == "audio" + assert data["hardware_touched"] is False + assert data["engine_checked"] is False + assert data["output_path"] == out_path + assert data["would_write"] == [out_path] + assert data["audio_address"] == "hw:CARD=Audio" + assert data["capture_node"].endswith("dev/snd/pcmC1D0c") + assert data["pipeline_preview"][:2] == ["gst-launch-1.0", "-e"] + assert "queue" in data["pipeline_preview"] + assert "matroskamux" in data["pipeline_preview"] + assert data["engine"]["checked"] is False + assert data["access"]["checked"] is False + assert data["access"]["state"] == "absent" + assert data["bound"] == { + "duration_s": 30.0, + "max_bytes": record.DEFAULT_MAX_BYTES, + "unbounded_is_impossible": True, + } + assert data["warmup_s"] == 0.0 + assert data["warmup_basis"] + assert data["timestamps"]["resolved_at"] + + +def test_wav_extension_selects_the_wav_container( + tmp_path, capsys: pytest.CaptureFixture[str] +) -> None: + assert run(base_argv(str(tmp_path / "clip.wav"), "--json")) == 0 + data = payload(capsys) + assert data["container"] == "wav" + assert "wavenc" in data["pipeline_preview"] + assert "matroskamux" not in data["pipeline_preview"] + + +def test_unknown_extension_is_a_user_error(tmp_path, capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv(str(tmp_path / "clip.mp3"))) == 1 + assert "error:" in capsys.readouterr().err + + +def test_missing_parent_directory_is_a_user_error( + tmp_path, capsys: pytest.CaptureFixture[str] +) -> None: + assert run(base_argv(str(tmp_path / "nope" / "clip.mka"))) == 1 + assert "error:" in capsys.readouterr().err + + +def test_existing_file_is_refused_unless_overwrite( + tmp_path, capsys: pytest.CaptureFixture[str] +) -> None: + out_path = tmp_path / "clip.mka" + out_path.write_bytes(b"old") + assert run(base_argv(str(out_path))) == 1 + assert "error:" in capsys.readouterr().err + assert run(base_argv(str(out_path), "--json", "--overwrite")) == 0 + assert payload(capsys)["mode"] == "dry-run" + # A dry run never touches the existing artifact, --overwrite or not. + assert out_path.read_bytes() == b"old" + + +@pytest.mark.parametrize("duration", ["0", "-1", "3601"]) +def test_out_of_range_duration_is_a_user_error( + tmp_path, capsys: pytest.CaptureFixture[str], duration: str +) -> None: + assert run(base_argv(str(tmp_path / "clip.mka"), "--duration", duration)) == 1 + assert "error:" in capsys.readouterr().err + + +@pytest.mark.parametrize("max_bytes", ["0", "4294967297"]) +def test_out_of_range_max_bytes_is_a_user_error( + tmp_path, capsys: pytest.CaptureFixture[str], max_bytes: str +) -> None: + assert run(base_argv(str(tmp_path / "clip.mka"), "--max-bytes", max_bytes)) == 1 + assert "error:" in capsys.readouterr().err + + +def test_duration_bounds_the_pipeline_itself(tmp_path, capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv(str(tmp_path / "clip.mka"), "--json", "--duration", "5")) == 0 + data = payload(capsys) + assert data["bound"]["duration_s"] == 5.0 + assert any(token.startswith("num-buffers=") for token in data["pipeline_preview"]) + + +# --------------------------------------------------------------------------- +# probe +# --------------------------------------------------------------------------- + + +def test_probe_checks_the_engine_but_spawns_nothing( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + _activation_log: str, +) -> None: + monkeypatch.setattr(engine, "detect", available_capability) + out_path = str(tmp_path / "clip.mka") + assert run(base_argv(out_path, "--json", "--probe")) == 0 + data = payload(capsys) + assert data["mode"] == "probe" + assert data["apply"] is False + assert data["hardware_touched"] is False + assert data["engine_checked"] is True + assert data["engine"]["checked"] is True + assert data["engine"]["available"] is True + assert data["access"]["checked"] is True + assert data["access"]["state"] == "absent" + assert not os.path.exists(out_path) + assert not os.path.exists(_activation_log) + + +def test_probe_without_the_engine_exits_two( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr( + engine, + "detect", + lambda: engine.Capability( + gst_launch=None, + gst_inspect=None, + plugins=dict.fromkeys(engine.ALL_ELEMENTS, False), + available=False, + ), + ) + assert run(base_argv(str(tmp_path / "clip.mka"), "--probe")) == 2 + assert "error:" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# apply +# --------------------------------------------------------------------------- + + +def test_apply_records_until_eos_and_logs_the_activation( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + _activation_log: str, +) -> None: + out_path = str(tmp_path / "clip.mka") + proc = FakeProc(out_path, chunk=8, exit_after=2) + arm_apply(monkeypatch, proc) + + assert run(base_argv(out_path, "--json", "--apply", "--duration", "5")) == 0 + data = payload(capsys) + assert data["mode"] == "apply" + assert data["apply"] is True + assert data["hardware_touched"] is True + assert data["engine_checked"] is True + assert data["stopped_reason"] == "eos" + assert data["bytes_written"] == os.stat(out_path).st_size + assert data["bytes_written"] > 0 + assert data["pipeline"][:2] == ["gst-launch-1.0", "-e"] + assert data["timestamps"]["started_at"] + assert data["timestamps"]["ended_at"] + + # It wrote to the named path and nowhere else in the directory. + assert sorted(os.listdir(tmp_path)) == ["activation.jsonl", "clip.mka"] + + lines = open(_activation_log, encoding="utf-8").read().strip().splitlines() + assert len(lines) == 1 + entry = json.loads(lines[0]) + assert entry["verb"] == "record" + assert entry["device"] == "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001" + assert entry["params"]["output_path"] == out_path + assert entry["params"]["stopped_reason"] == "eos" + assert entry["params"]["bytes_written"] == data["bytes_written"] + assert entry["ended_at"] + + +def test_apply_stops_on_the_duration_bound( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + out_path = str(tmp_path / "clip.wav") + proc = FakeProc(out_path, chunk=4, exit_after=None) + arm_apply(monkeypatch, proc, ticks=1.0) + + assert run(base_argv(out_path, "--json", "--apply", "--duration", "3")) == 0 + data = payload(capsys) + assert data["stopped_reason"] == "duration" + assert proc.terminated is True + assert proc.waited is True + assert data["bytes_written"] > 0 + + +def test_apply_stops_on_the_max_bytes_bound( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + _activation_log: str, +) -> None: + out_path = str(tmp_path / "clip.wav") + # 64-byte chunks overshoot a 100-byte cap by one poll interval: the loop + # stops the child at the bound, and that overshoot is reported honestly as + # bytes_written rather than failing the run. An artifact that ends up over + # the cap because the pipeline finished *on its own* is an error — see the + # test below. + proc = FakeProc(out_path, chunk=64, exit_after=None) + arm_apply(monkeypatch, proc) + + argv = base_argv(out_path, "--json", "--apply", "--duration", "600", "--max-bytes", "100") + assert run(argv) == 0 + data = payload(capsys) + assert data["stopped_reason"] == "max_bytes" + assert proc.terminated is True + assert data["bytes_written"] == 128 + entry = json.loads(open(_activation_log, encoding="utf-8").read().strip()) + assert entry["params"]["stopped_reason"] == "max_bytes" + + +def test_a_child_that_ignores_sigterm_is_killed_before_the_bound_is_reported( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """SIGTERM ignored -> SIGKILL, waited for, and only then the bound reported.""" + out_path = str(tmp_path / "clip.wav") + proc = StubbornProc(out_path, chunk=8) + arm_apply(monkeypatch, proc, ticks=1.0) + + assert run(base_argv(out_path, "--json", "--apply", "--duration", "3")) == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert proc.terminated is True + assert proc.killed is True + assert data["stopped_reason"] == "duration" + assert data["bytes_written"] > 0 + # The escalation is announced on stderr, and never mixed into stdout. + assert "SIGKILL" in captured.err + + +def test_a_child_that_survives_sigkill_is_an_environment_error( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + _activation_log: str, +) -> None: + out_path = str(tmp_path / "clip.wav") + proc = StubbornProc(out_path, chunk=8, killable=False) + arm_apply(monkeypatch, proc, ticks=1.0) + + assert run(base_argv(out_path, "--apply", "--duration", "3")) == 2 + err = capsys.readouterr().err + assert proc.killed is True + assert "SIGKILL" in err + assert "error:" in err + # The failed run still leaves a completed activation record behind. + entry = json.loads(open(_activation_log, encoding="utf-8").read().strip()) + assert entry["params"]["error"] + assert entry["ended_at"] + + +def test_an_artifact_over_the_size_cap_is_an_environment_error( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + _activation_log: str, +) -> None: + """A pipeline that blows the cap and exits between polls is not a success.""" + out_path = str(tmp_path / "clip.wav") + proc = OversizeProc(out_path, total=200) + arm_apply(monkeypatch, proc) + + argv = base_argv(out_path, "--apply", "--duration", "600", "--max-bytes", "100") + assert run(argv) == 2 + err = capsys.readouterr().err + assert "200" in err + assert "100" in err + # The oversized file is kept, not deleted: that call is the caller's. + assert os.stat(out_path).st_size == 200 + assert out_path in err + + entry = json.loads(open(_activation_log, encoding="utf-8").read().strip()) + assert entry["params"]["bytes_written"] == 200 + assert entry["params"]["stopped_reason"] == "eos" + assert entry["params"]["error"] + assert entry["ended_at"] + + +def test_apply_reports_a_failed_pipeline_as_an_environment_error( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + out_path = str(tmp_path / "clip.mka") + proc = FakeProc(out_path, chunk=4, exit_after=1, rc=1) + arm_apply(monkeypatch, proc) + assert run(base_argv(out_path, "--apply", "--duration", "5")) == 2 + assert "error:" in capsys.readouterr().err + + +def test_apply_on_a_busy_device_exits_three( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + out_path = str(tmp_path / "clip.mka") + monkeypatch.setattr(engine, "detect", available_capability) + monkeypatch.setattr( + access, + "check_access", + lambda path, kind: access.AccessReport( + path=path, + kind=kind, + state=access.AccessState.BUSY, + remediation="stop the holder", + holder=access.Holder(pid=7, command="arecord"), + ), + ) + monkeypatch.setattr(record, "_spawn", lambda argv: pytest.fail("spawned despite busy")) + assert run(base_argv(out_path, "--apply")) == 3 + assert "busy" in capsys.readouterr().err + assert not os.path.exists(out_path) + + +def test_apply_without_the_engine_exits_two( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr( + engine, + "detect", + lambda: engine.Capability( + gst_launch=None, + gst_inspect=None, + plugins=dict.fromkeys(engine.ALL_ELEMENTS, False), + available=False, + ), + ) + monkeypatch.setattr(record, "_spawn", lambda argv: pytest.fail("spawned without an engine")) + assert run(base_argv(str(tmp_path / "clip.mka"), "--apply")) == 2 + assert "error:" in capsys.readouterr().err + + +def test_apply_requires_the_container_elements( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + plugins = dict.fromkeys(engine.ALL_ELEMENTS, True) + plugins["matroskamux"] = False + monkeypatch.setattr( + engine, + "detect", + lambda: engine.Capability( + gst_launch="/usr/bin/gst-launch-1.0", + gst_inspect="/usr/bin/gst-inspect-1.0", + plugins=plugins, + available=True, + ), + ) + monkeypatch.setattr(access, "check_access", lambda path, kind: ok_report(path)) + monkeypatch.setattr(record, "_spawn", lambda argv: pytest.fail("spawned without matroskamux")) + assert run(base_argv(str(tmp_path / "clip.mka"), "--apply")) == 2 + assert "matroskamux" in capsys.readouterr().err + + +def test_apply_that_produces_no_artifact_is_an_environment_error( + tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + out_path = str(tmp_path / "clip.mka") + proc = FakeProc(out_path, chunk=0, exit_after=1) + + def _no_write() -> None: + return None + + monkeypatch.setattr(proc, "_write", _no_write) + arm_apply(monkeypatch, proc) + assert run(base_argv(out_path, "--apply", "--duration", "5")) == 2 + assert "error:" in capsys.readouterr().err diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py new file mode 100644 index 0000000..bb4ad3a --- /dev/null +++ b/tests/test_repo_hygiene.py @@ -0,0 +1,47 @@ +"""Repository hygiene checks that CI enforces on every tracked source file. + +These are deliberately simple and run without hardware: they read the files +`git ls-files` reports, nothing else. +""" + +from __future__ import annotations + +import os +import subprocess + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +#: Hard ceiling on the length of any tracked Python source file. A module that +#: grows past this is a module that wants splitting, and reviewers (human or +#: automated) stop reading before the end of it. +MAX_LINES = 1000 + + +def _tracked_python_files() -> list[str]: + out = subprocess.run( + ["git", "-C", REPO, "ls-files", "--", "*.py"], + check=True, + capture_output=True, + text=True, + ).stdout + return sorted(line for line in out.splitlines() if line.strip()) + + +def _line_count(path: str) -> int: + with open(os.path.join(REPO, path), encoding="utf-8") as handle: + return sum(1 for _ in handle) + + +def test_the_repo_tracks_python_files() -> None: + assert _tracked_python_files(), "git ls-files returned no Python files" + + +@pytest.mark.parametrize("path", _tracked_python_files()) +def test_no_python_file_exceeds_the_line_ceiling(path: str) -> None: + lines = _line_count(path) + assert lines <= MAX_LINES, ( + f"{path} is {lines} lines; the ceiling is {MAX_LINES}. Split the module " + "instead of raising the limit." + ) diff --git a/tests/test_stream.py b/tests/test_stream.py new file mode 100644 index 0000000..e709395 --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,511 @@ +"""Tests for ``microphone-cli stream audio`` — hardware-free by construction. + +Every test runs against the synthetic ``tests/fixtures/host-baseline`` tree, +an autouse fixture booby-traps :func:`subprocess.Popen` / :func:`subprocess.run` +so a stray real spawn is an immediate failure, and the activation log is +redirected into ``tmp_path``. No ALSA node is ever opened: the capture node +under a fixture root simply does not exist, which is exactly the ``absent`` +state the descriptive payloads report. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess # nosec B404 - only ever monkeypatched into a booby trap here + +import pytest + +from microphone_cli import access, activation, engine +from microphone_cli.cli import _CliArgumentParser, _dispatch +from microphone_cli.cli._commands import stream + +FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") +BASELINE = os.path.join(FIXTURES, "host-baseline") +SELECTOR = "Audio" + + +# --------------------------------------------------------------------------- +# harness +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _no_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: + """Any real subprocess spawn from a test is a bug, not a slow test.""" + + def _trap(*args: object, **kwargs: object) -> None: + raise AssertionError(f"subprocess spawned in a test: {args!r} {kwargs!r}") + + monkeypatch.setattr(subprocess, "Popen", _trap) + monkeypatch.setattr(subprocess, "run", _trap) + + +@pytest.fixture(autouse=True) +def _activation_log(monkeypatch: pytest.MonkeyPatch, tmp_path) -> str: + path = str(tmp_path / "activation.jsonl") + monkeypatch.setenv(activation.ENV_LOG_PATH, path) + return path + + +def run(argv: list[str]) -> int: + """Parse and dispatch ``argv`` through this noun's own parser. + + The noun is not wired into ``cli._build_parser()`` yet (that is a separate + task's file), so the test builds the same parser shape by hand — same + ``parser_class``, same ``_dispatch``, therefore the same error contract. + """ + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + stream.register(sub) + args = parser.parse_args(argv) + return _dispatch(args) + + +def payload(capsys: pytest.CaptureFixture[str]) -> dict: + return json.loads(capsys.readouterr().out) + + +def available_capability() -> engine.Capability: + return engine.Capability( + gst_launch="/usr/bin/gst-launch-1.0", + gst_inspect="/usr/bin/gst-inspect-1.0", + plugins=dict.fromkeys(engine.ALL_ELEMENTS, True), + available=True, + ) + + +def ok_report(path: str) -> access.AccessReport: + return access.AccessReport(path=path, kind="audio", state=access.AccessState.OK, remediation="") + + +class FakeProc: + """A Popen-like stand-in: it has a pid and can be stopped, nothing else. + + ``returncode`` stays ``None`` unless a test passes one, so the default fake + is a *live* child — which is what the post-spawn settle check polls for. A + fake constructed with a return code is a pipeline that died at startup. + """ + + def __init__(self, returncode: int | None = None) -> None: + self.pid = 4242 + self.terminated = False + self.returncode: int | None = returncode + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + self.terminated = True + self.returncode = 0 + + def wait(self, timeout: float | None = None) -> int: + self.returncode = 0 + return 0 + + +def base_argv(*extra: str) -> list[str]: + return ["stream", "audio", SELECTOR, "--root", BASELINE, *extra] + + +# --------------------------------------------------------------------------- +# dry run +# --------------------------------------------------------------------------- + + +def test_dry_run_prints_the_pipeline_and_touches_nothing( + capsys: pytest.CaptureFixture[str], _activation_log: str +) -> None: + assert run(base_argv()) == 0 + out = capsys.readouterr().out + assert "gst-launch-1.0" in out + assert "udpsink" in out + assert not os.path.exists(_activation_log) + + +def test_dry_run_json_reports_no_hardware_and_no_engine_check( + capsys: pytest.CaptureFixture[str], +) -> None: + assert run(base_argv("--json")) == 0 + data = payload(capsys) + assert data["verb"] == "stream audio" + assert data["medium"] == "audio" + assert data["mode"] == "dry-run" + assert data["applied"] is False + assert data["probed"] is False + assert data["hardware_touched"] is False + assert data["engine_checked"] is False + assert data["pid"] is None + assert data["started_at"] is None + assert data["pipeline"][:2] == ["gst-launch-1.0", "-e"] + assert "queue" in data["pipeline"] + assert data["pipeline_str"].startswith("gst-launch-1.0 -e ") + assert data["bounded"] is False + + +def test_dry_run_payload_carries_every_documented_key( + capsys: pytest.CaptureFixture[str], +) -> None: + assert run(base_argv("--json")) == 0 + data = payload(capsys) + assert set(data) == set(stream.PAYLOAD_KEYS) + + +def test_dry_run_request_and_defaults(capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv("--json")) == 0 + request = payload(capsys)["request"] + # No --rate/--channels/--format given: the request is filled from what the + # fixture device advertises in stream0 (found on hardware: a fixed 48 kHz + # mono default could not open a 16 kHz stereo array). + assert request == { + "rate": 48000, + "channels": 6, + "sample_format": "S32LE", + "format_source": { + "rate": "advertised", + "channels": "advertised", + "sample_format": "advertised", + }, + "encode": "passthrough", + "host": "127.0.0.1", + "port": 5000, + } + + +def test_explicit_format_flags_override_the_advertised_ones( + capsys: pytest.CaptureFixture[str], +) -> None: + assert run(base_argv("--json", "--rate", "16000", "--channels", "1")) == 0 + request = payload(capsys)["request"] + assert (request["rate"], request["channels"], request["sample_format"]) == ( + 16000, + 1, + "S32LE", + ) + assert request["format_source"] == { + "rate": "explicit", + "channels": "explicit", + "sample_format": "advertised", + } + + +def test_dry_run_honours_the_audio_flags(capsys: pytest.CaptureFixture[str]) -> None: + argv = base_argv( + "--json", "--rate", "16000", "--channels", "6", "--format", "S32LE", "--port", "6001" + ) + assert run(argv) == 0 + data = payload(capsys) + assert data["request"]["rate"] == 16000 + assert data["request"]["channels"] == 6 + assert "audio/x-raw,format=S32LE,rate=16000,channels=6" in data["pipeline"] + assert "port=6001" in data["pipeline"] + + +def test_opus_encode_changes_the_payloader(capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv("--json", "--encode", "opus")) == 0 + data = payload(capsys) + assert "rtpopuspay" in data["pipeline"] + assert "opusenc" in data["pipeline"] + assert data["attach"]["encode"] == "opus" + + +def test_attach_announces_both_receive_pipelines(capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv("--json")) == 0 + attach = payload(capsys)["attach"] + assert attach["transport"] == "udp" + assert attach["port"] == 5000 + consumer = attach["consumer"] + assert "udpsrc" in consumer["passthrough"] + assert "rtpL16depay" in consumer["passthrough"] + assert "rtpopusdepay" in consumer["opus"] + + +def test_dry_run_access_is_reported_not_enforced(capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv("--json")) == 0 + data = payload(capsys) + assert data["access"]["checked"] is False + assert data["access"]["state"] == "absent" + assert data["source"]["capture_node"].endswith("dev/snd/pcmC1D0c") + + +def test_unknown_device_is_a_user_error(capsys: pytest.CaptureFixture[str]) -> None: + assert run(["stream", "audio", "nope-not-here", "--root", BASELINE]) == 1 + assert "error:" in capsys.readouterr().err + + +def test_bad_port_is_a_user_error(capsys: pytest.CaptureFixture[str]) -> None: + assert run(base_argv("--port", "0")) == 1 + assert "error:" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# probe +# --------------------------------------------------------------------------- + + +def test_probe_checks_the_engine_but_spawns_nothing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], _activation_log: str +) -> None: + monkeypatch.setattr(engine, "detect", available_capability) + assert run(base_argv("--json", "--probe")) == 0 + data = payload(capsys) + assert data["mode"] == "probe" + assert data["probed"] is True + assert data["applied"] is False + assert data["hardware_touched"] is False + assert data["engine_checked"] is True + assert data["pid"] is None + assert data["access"]["checked"] is True + assert data["access"]["state"] == "absent" + assert not os.path.exists(_activation_log) + + +def test_probe_without_the_engine_is_an_environment_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr( + engine, + "detect", + lambda: engine.Capability( + gst_launch=None, + gst_inspect=None, + plugins=dict.fromkeys(engine.ALL_ELEMENTS, False), + available=False, + ), + ) + assert run(base_argv("--probe")) == 2 + assert "error:" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# apply +# --------------------------------------------------------------------------- + + +def _arm_apply( + monkeypatch: pytest.MonkeyPatch, + proc: FakeProc | None = None, + *, + stderr: bytes = b"", + slept: list[float] | None = None, +) -> FakeProc: + """Wire the engine, access and spawn seams; return the child --apply gets. + + ``stderr`` is written into the capture file the command hands the child, so + a dead fake child can be given the diagnostics a real gst-launch-1.0 would + have left behind. + """ + child = proc if proc is not None else FakeProc() + child_stderr = stderr + + def _fake_spawn(argv: list[str], stderr: object = None) -> FakeProc: + if child_stderr and stderr is not None: + stderr.write(child_stderr) # type: ignore[attr-defined] + stderr.flush() # type: ignore[attr-defined] + return child + + monkeypatch.setattr(engine, "detect", available_capability) + monkeypatch.setattr(access, "check_access", lambda path, kind: ok_report(path)) + monkeypatch.setattr(stream, "_spawn", _fake_spawn) + # The settle wait is a seam so the check costs no wall-clock time in tests. + recorded = slept if slept is not None else [] + monkeypatch.setattr(stream, "_sleep", lambda seconds: recorded.append(seconds)) + return child + + +def test_apply_spawns_and_reports_the_pid( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], _activation_log: str +) -> None: + slept: list[float] = [] + proc = _arm_apply(monkeypatch, slept=slept) + assert run(base_argv("--json", "--apply")) == 0 + data = payload(capsys) + assert data["mode"] == "apply" + assert data["applied"] is True + assert data["hardware_touched"] is True + assert data["engine_checked"] is True + assert data["pid"] == proc.pid + assert data["started_at"] + assert data["access"]["state"] == "ok" + # The child was given a settle interval and then polled before being + # called live. + assert slept == [stream.STARTUP_SETTLE_S] + + lines = open(_activation_log, encoding="utf-8").read().strip().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["verb"] == "stream audio" + assert record["device"] == "usb-Pollen_Robotics_Reachy_Mini_Audio_RM0001" + assert record["params"]["port"] == 5000 + assert record["params"]["pid"] == proc.pid + + +def test_a_live_stream_is_logged_open_ended( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], _activation_log: str +) -> None: + """The stream outlives the command, so its log line must stay open.""" + proc = _arm_apply(monkeypatch) + assert run(base_argv("--json", "--apply")) == 0 + data = payload(capsys) + + record = json.loads(open(_activation_log, encoding="utf-8").read().strip()) + assert record["ended_at"] is None + assert record["params"]["pid"] == proc.pid + assert record["params"]["lifetime"] == "unbounded" + assert "error" not in record["params"] + # And the payload says so, rather than leaving a reader to guess. + assert "ended_at: null" in data["lifetime"] + assert "ended_at: null" in data["consent"]["log_line"] + + +def test_a_pipeline_that_dies_at_startup_is_an_environment_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], _activation_log: str +) -> None: + """A dead child is never reported as a live stream — and it says why.""" + _arm_apply( + monkeypatch, + FakeProc(returncode=1), + stderr=( + b"Setting pipeline to PAUSED ...\n" + b"ERROR: from element /GstPipeline:pipeline0/GstAlsaSrc:alsasrc0: " + b"Internal data stream error.\n" + b"streaming stopped, reason not-negotiated (-4)\n" + ), + ) + assert run(base_argv("--apply")) == 2 + err = capsys.readouterr().err + assert "stream pipeline exited 1 during startup" in err + assert "not-negotiated" in err + assert "Traceback" not in err + + record = json.loads(open(_activation_log, encoding="utf-8").read().strip()) + assert record["ended_at"] + assert record["params"]["error"] == "stream pipeline exited 1 during startup" + assert any("not-negotiated" in line for line in record["params"]["pipeline_stderr"]) + + +def test_a_pipeline_that_dies_silently_still_fails_cleanly( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _arm_apply(monkeypatch, FakeProc(returncode=255)) + assert run(base_argv("--apply")) == 2 + err = capsys.readouterr().err + assert "stream pipeline exited 255 during startup" in err + assert "no diagnostics" in err + + +def test_apply_passes_the_built_argv_to_the_spawn_seam( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + seen: list[list[str]] = [] + proc = FakeProc() + monkeypatch.setattr(engine, "detect", available_capability) + monkeypatch.setattr(access, "check_access", lambda path, kind: ok_report(path)) + monkeypatch.setattr(stream, "_sleep", lambda seconds: None) + monkeypatch.setattr(stream, "_spawn", lambda argv, stderr=None: seen.append(list(argv)) or proc) + assert run(base_argv("--json", "--apply")) == 0 + assert seen + assert seen[0][:2] == ["gst-launch-1.0", "-e"] + assert seen[0] == payload(capsys)["pipeline"] + + +def test_apply_on_a_busy_device_exits_three( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(engine, "detect", available_capability) + monkeypatch.setattr( + access, + "check_access", + lambda path, kind: access.AccessReport( + path=path, + kind=kind, + state=access.AccessState.BUSY, + remediation="stop the holder", + holder=access.Holder(pid=11, command="arecord"), + ), + ) + monkeypatch.setattr( + stream, "_spawn", lambda argv, stderr=None: pytest.fail("spawned despite busy") + ) + assert run(base_argv("--apply")) == 3 + assert "busy" in capsys.readouterr().err + + +def test_apply_without_the_engine_exits_two( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr( + engine, + "detect", + lambda: engine.Capability( + gst_launch=None, + gst_inspect=None, + plugins=dict.fromkeys(engine.ALL_ELEMENTS, False), + available=False, + ), + ) + monkeypatch.setattr( + stream, "_spawn", lambda argv, stderr=None: pytest.fail("spawned without an engine") + ) + assert run(base_argv("--apply")) == 2 + assert "error:" in capsys.readouterr().err + + +def test_apply_with_opus_requires_the_optional_elements( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + plugins = dict.fromkeys(engine.ALL_ELEMENTS, True) + plugins["opusenc"] = False + monkeypatch.setattr( + engine, + "detect", + lambda: engine.Capability( + gst_launch="/usr/bin/gst-launch-1.0", + gst_inspect="/usr/bin/gst-inspect-1.0", + plugins=plugins, + available=True, + ), + ) + monkeypatch.setattr(access, "check_access", lambda path, kind: ok_report(path)) + monkeypatch.setattr( + stream, "_spawn", lambda argv, stderr=None: pytest.fail("spawned without opusenc") + ) + assert run(base_argv("--apply", "--encode", "opus")) == 2 + assert "opusenc" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# overview +# --------------------------------------------------------------------------- + + +def test_bare_noun_prints_its_overview(capsys: pytest.CaptureFixture[str]) -> None: + assert run(["stream"]) == 0 + assert "stream" in capsys.readouterr().out + + +def test_overview_json_has_sections(capsys: pytest.CaptureFixture[str]) -> None: + assert run(["stream", "overview", "--json"]) == 0 + data = payload(capsys) + assert data["subject"] == "microphone stream" + assert data["sections"] + + +def test_register_is_argparse_shaped() -> None: + parser = _CliArgumentParser(prog="microphone-cli") + sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser) + stream.register(sub) + assert isinstance(sub, argparse._SubParsersAction) + + +def test_passthrough_consumer_caps_follow_the_negotiated_format( + capsys: pytest.CaptureFixture[str], +) -> None: + # Found on hardware: the announced L16 consumer said clock-rate=48000 for a + # 16 kHz stereo stream and decoded at the wrong speed. + assert run(base_argv("--json", "--rate", "16000", "--channels", "2")) == 0 + consumer = payload(capsys)["attach"]["consumer"] + assert "clock-rate=16000" in consumer["passthrough"] + assert "encoding-params=2" in consumer["passthrough"] + assert "clock-rate=48000" in consumer["opus"] diff --git a/tests/test_usbctl.py b/tests/test_usbctl.py new file mode 100644 index 0000000..f233bec --- /dev/null +++ b/tests/test_usbctl.py @@ -0,0 +1,215 @@ +"""Tests for the stdlib usbdevfs control-transfer layer. + +No test in this file may open a real ``/dev/bus/usb`` node or issue a real +USB transfer: the ioctl is injected through ``usbctl._ioctl``. +""" + +from __future__ import annotations + +import ctypes +import os +import struct + +import pytest + +from microphone_cli import usbctl +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError + + +class FakeIoctl: + """Records every ioctl call and optionally fills the IN data buffer.""" + + def __init__(self, replies: list[bytes] | None = None) -> None: + self.calls: list[dict[str, object]] = [] + self.replies = list(replies or []) + + def __call__(self, fd: int, request: int, arg: object) -> int: + xfer = arg + assert isinstance(xfer, usbctl.UsbdevfsCtrlTransfer) + payload = b"" + if xfer.wLength and xfer.data: + payload = ctypes.string_at(xfer.data, xfer.wLength) + self.calls.append( + { + "fd": fd, + "request": request, + "bRequestType": xfer.bRequestType, + "bRequest": xfer.bRequest, + "wValue": xfer.wValue, + "wIndex": xfer.wIndex, + "wLength": xfer.wLength, + "timeout": xfer.timeout, + "payload": payload, + } + ) + if self.replies and xfer.bRequestType & 0x80: + reply = self.replies.pop(0) + ctypes.memmove(xfer.data, reply, min(len(reply), xfer.wLength)) + return min(len(reply), xfer.wLength) + return xfer.wLength + + +@pytest.fixture +def fake_ioctl(monkeypatch: pytest.MonkeyPatch) -> FakeIoctl: + fake = FakeIoctl() + monkeypatch.setattr(usbctl, "_ioctl", fake) + return fake + + +def test_struct_layout_matches_kernel() -> None: + # struct usbdevfs_ctrltransfer: u8 u8 u16 u16 u16 u32 void* + assert ctypes.sizeof(usbctl.UsbdevfsCtrlTransfer) == struct.calcsize("BBHHHIP") + + +def test_usbdevfs_control_constant() -> None: + if ctypes.sizeof(ctypes.c_void_p) != 8: # pragma: no cover - 32-bit hosts + pytest.skip("constant asserted for 64-bit layout only") + assert usbctl.USBDEVFS_CONTROL == 0xC0185500 + + +def test_iowr_is_derived_not_hardcoded() -> None: + assert usbctl._iowr(ord("U"), 0, ctypes.sizeof(usbctl.UsbdevfsCtrlTransfer)) == ( + usbctl.USBDEVFS_CONTROL + ) + + +def test_control_transfer_in_returns_buffer(fake_ioctl: FakeIoctl) -> None: + fake_ioctl.replies.append(b"\x00\x01\x02\x03") + got = usbctl.control_transfer(7, 0xC0, 0, 0x93, 20, 4, timeout_ms=1234) + assert got == b"\x00\x01\x02\x03" + call = fake_ioctl.calls[0] + assert call["fd"] == 7 + assert call["request"] == usbctl.USBDEVFS_CONTROL + assert call["bRequestType"] == 0xC0 + assert call["wValue"] == 0x93 + assert call["wIndex"] == 20 + assert call["wLength"] == 4 + assert call["timeout"] == 1234 + + +def test_control_transfer_out_sends_payload(fake_ioctl: FakeIoctl) -> None: + n = usbctl.control_transfer(7, 0x40, 0, 0, 35, b"\x00\x00\x00\x3f") + assert n == 4 + call = fake_ioctl.calls[0] + assert call["bRequestType"] == 0x40 + assert call["payload"] == b"\x00\x00\x00\x3f" + + +def test_control_transfer_oserror_becomes_clierror(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*_args: object) -> int: + raise OSError(16, "Device or resource busy") + + monkeypatch.setattr(usbctl, "_ioctl", boom) + with pytest.raises(CliError) as exc: + usbctl.control_transfer(7, 0xC0, 0, 0, 20, 4) + assert exc.value.code == EXIT_ENV_ERROR + assert exc.value.remediation + + +def _make_sysfs(tmp_path, name: str, attrs: dict[str, str]) -> None: + devdir = tmp_path / "sys" / "bus" / "usb" / "devices" / name + devdir.mkdir(parents=True) + for key, value in attrs.items(): + (devdir / key).write_text(value + "\n") + + +def test_find_devices_builds_node_path(tmp_path) -> None: + _make_sysfs( + tmp_path, + "1-3", + { + "idVendor": "38fb", + "idProduct": "1001", + "serial": "ABC123", + "busnum": "1", + "devnum": "7", + }, + ) + found = usbctl.find_devices(root=str(tmp_path)) + assert len(found) == 1 + assert found[0]["node"] == "/dev/bus/usb/001/007" + assert found[0]["vendor"] == "38fb" + assert found[0]["product"] == "1001" + assert found[0]["serial"] == "ABC123" + + +def test_find_devices_filters(tmp_path) -> None: + _make_sysfs( + tmp_path, + "1-3", + {"idVendor": "38fb", "idProduct": "1001", "serial": "A", "busnum": "1", "devnum": "7"}, + ) + _make_sysfs( + tmp_path, + "2-1", + {"idVendor": "2886", "idProduct": "001a", "serial": "B", "busnum": "2", "devnum": "3"}, + ) + assert len(usbctl.find_devices(root=str(tmp_path))) == 2 + assert [d["serial"] for d in usbctl.find_devices(root=str(tmp_path), vendor="2886")] == ["B"] + assert [d["node"] for d in usbctl.find_devices(root=str(tmp_path), product="1001")] == [ + "/dev/bus/usb/001/007" + ] + assert usbctl.find_devices(root=str(tmp_path), serial="nope") == [] + + +def test_find_devices_skips_interfaces_and_missing_attrs(tmp_path) -> None: + _make_sysfs(tmp_path, "1-3:1.0", {"idVendor": "38fb"}) + _make_sysfs(tmp_path, "usb1", {"busnum": "1", "devnum": "1"}) + assert usbctl.find_devices(root=str(tmp_path)) == [] + + +def test_find_devices_missing_root_is_empty(tmp_path) -> None: + assert usbctl.find_devices(root=str(tmp_path / "absent")) == [] + + +def test_open_device_permission_error(monkeypatch: pytest.MonkeyPatch) -> None: + def denied(*_args: object, **_kw: object) -> int: + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(usbctl.os, "open", denied) + with pytest.raises(CliError) as exc: + usbctl.open_device("/dev/bus/usb/001/007", vendor="2886", product="001A") + assert exc.value.code == EXIT_ENV_ERROR + assert 'SUBSYSTEM=="usb"' in exc.value.remediation + # The hint names the ids of the device that was actually refused (found on + # hardware: a ReSpeaker 2886:001a was told to add a rule for 38fb:1001). + assert 'ATTR{idVendor}=="2886"' in exc.value.remediation + assert 'ATTR{idProduct}=="001a"' in exc.value.remediation + assert 'MODE="0666"' in exc.value.remediation + + +def test_open_device_permission_error_without_ids_uses_placeholders( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def denied(*_args: object, **_kw: object) -> int: + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(usbctl.os, "open", denied) + with pytest.raises(CliError) as exc: + usbctl.open_device("/dev/bus/usb/001/007") + assert 'ATTR{idVendor}=="XXXX"' in exc.value.remediation + assert "38fb" not in exc.value.remediation + + +def test_open_device_missing_node(monkeypatch: pytest.MonkeyPatch) -> None: + def missing(*_args: object, **_kw: object) -> int: + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(usbctl.os, "open", missing) + with pytest.raises(CliError) as exc: + usbctl.open_device("/dev/bus/usb/001/007") + assert exc.value.code == EXIT_USER_ERROR + + +def test_open_device_uses_rdwr(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + + def fake_open(path: str, flags: int) -> int: + seen["path"] = path + seen["flags"] = flags + return 42 + + monkeypatch.setattr(usbctl.os, "open", fake_open) + assert usbctl.open_device("/dev/bus/usb/001/007") == 42 + assert seen["path"] == "/dev/bus/usb/001/007" + assert seen["flags"] == os.O_RDWR diff --git a/tests/test_xvf3800.py b/tests/test_xvf3800.py new file mode 100644 index 0000000..c5966cb --- /dev/null +++ b/tests/test_xvf3800.py @@ -0,0 +1,396 @@ +"""Tests for the XVF3800 parameter protocol layer. + +Every transfer is served by a fake ioctl injected into +:mod:`microphone_cli.usbctl`; no real device is ever opened. +""" + +from __future__ import annotations + +import ctypes +import struct + +import pytest + +from microphone_cli import usbctl, xvf3800 +from microphone_cli.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError + + +class FakeIoctl: + """Records calls; serves queued IN replies (status byte first).""" + + def __init__(self, replies: list[bytes] | None = None) -> None: + self.calls: list[dict[str, object]] = [] + self.replies = list(replies or []) + self.default_reply: bytes | None = None + + def __call__(self, fd: int, request: int, arg: object) -> int: + xfer = arg + payload = b"" + if xfer.wLength and xfer.data: + payload = ctypes.string_at(xfer.data, xfer.wLength) + self.calls.append( + { + "bRequestType": xfer.bRequestType, + "bRequest": xfer.bRequest, + "wValue": xfer.wValue, + "wIndex": xfer.wIndex, + "wLength": xfer.wLength, + "payload": payload, + } + ) + if xfer.bRequestType & 0x80: + if self.replies: + reply = self.replies.pop(0) + elif self.default_reply is not None: + reply = self.default_reply + else: # pragma: no cover - defensive + raise AssertionError("unexpected IN transfer") + ctypes.memmove(xfer.data, reply, min(len(reply), xfer.wLength)) + return xfer.wLength + + +@pytest.fixture +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]: + slept: list[float] = [] + monkeypatch.setattr(xvf3800, "_sleep", slept.append) + return slept + + +def install( + monkeypatch: pytest.MonkeyPatch, replies: list[bytes] | None = None +) -> tuple[xvf3800.Xvf3800, FakeIoctl]: + fake = FakeIoctl(replies) + monkeypatch.setattr(usbctl, "_ioctl", fake) + return xvf3800.Xvf3800(3), fake + + +# --- table provenance ------------------------------------------------------- + + +def test_parameters_table_is_vendored_verbatim() -> None: + assert xvf3800.PARAMETERS["DOA_VALUE_RADIANS"] == (20, 19, 2, "ro", "radians") + assert xvf3800.PARAMETERS["AUDIO_MGR_MIC_GAIN"] == (35, 0, 1, "rw", "float") + assert xvf3800.PARAMETERS["VERSION"] == (48, 0, 3, "ro", "uint8") + assert xvf3800.PARAMETERS["REBOOT"] == (48, 7, 1, "wo", "uint8") + assert len(xvf3800.PARAMETERS) == 126 + + +def test_module_cites_source_and_license() -> None: + doc = xvf3800.__doc__ or "" + assert "reachy_mini/media/audio_control_utils.py" in doc + assert "Apache-2.0" in doc + + +# --- read ------------------------------------------------------------------- + + +def test_read_doa_radians_wire_format(monkeypatch: pytest.MonkeyPatch) -> None: + reply = b"\x00" + struct.pack(" None: + dev, _ = install(monkeypatch, [b"\x00" + struct.pack(" None: + busy = b"\x40" + b"\x00" * 8 + good = b"\x00" + struct.pack(" None: + dev, fake = install(monkeypatch) + fake.default_reply = b"\x40" + b"\x00" * 8 + with pytest.raises(CliError) as exc: + dev.read("DOA_VALUE_RADIANS") + assert exc.value.code == EXIT_ENV_ERROR + assert "reachy-mini-daemon" in exc.value.remediation + assert len(fake.calls) == 100 + + +def test_read_unknown_status_raises_env_error(monkeypatch: pytest.MonkeyPatch) -> None: + dev, _ = install(monkeypatch, [b"\x07" + b"\x00" * 8]) + with pytest.raises(CliError) as exc: + dev.read("DOA_VALUE_RADIANS") + assert exc.value.code == EXIT_ENV_ERROR + assert "7" in exc.value.message + + +def test_read_uint8_returns_ints(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch, [b"\x00\x01\x02\x03"]) + assert dev.read("VERSION") == [1, 2, 3] + assert fake.calls[0]["wLength"] == 4 + + +def test_read_char_strips_nulls(monkeypatch: pytest.MonkeyPatch) -> None: + reply = b"\x00" + b"hello".ljust(50, b"\x00") + dev, _ = install(monkeypatch, [reply]) + assert dev.read("BLD_MSG") == "hello" + + +def test_read_int32_and_uint32(monkeypatch: pytest.MonkeyPatch) -> None: + dev, _ = install(monkeypatch, [b"\x00" + struct.pack(" None: + from microphone_cli.xvf3800 import Xvf3800 + + def transfer(request_type, request, value, index, data_or_length): + # VERSION wants 3 uint8 values + status byte; only send status + 1 byte. + return b"\x00\x01" + + chip = Xvf3800(transfer) + with pytest.raises(CliError) as exc: + chip.read("VERSION") + assert exc.value.code == EXIT_ENV_ERROR + assert "short reply reading VERSION" in exc.value.message + assert "1 of 3 bytes" in exc.value.message + + +def test_read_write_only_is_user_error(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.read("REBOOT") + assert exc.value.code == EXIT_USER_ERROR + assert fake.calls == [] + + +def test_read_unknown_name_is_user_error(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.read("NOT_A_PARAM") + assert exc.value.code == EXIT_USER_ERROR + assert fake.calls == [] + + +# --- write ------------------------------------------------------------------ + + +def test_write_float_wire_format(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch) + dev.write("AUDIO_MGR_MIC_GAIN", [0.5]) + call = fake.calls[0] + assert call["bRequestType"] == 0x40 + assert call["bRequest"] == 0 + assert call["wValue"] == 0 + assert call["wIndex"] == 35 + assert call["payload"] == struct.pack(" None: + dev, fake = install(monkeypatch) + dev.write("AUDIO_MGR_OP_L", [3, 0]) + assert fake.calls[0]["payload"] == b"\x03\x00" + dev.write("AEC_FILTER_CMD_ABORT", [1]) + assert fake.calls[1]["payload"] == struct.pack(" None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.write("LED_BRIGHTNESS", [256]) + assert exc.value.code == EXIT_USER_ERROR + assert "256" in exc.value.message + assert fake.calls == [] + + dev2, fake2 = install(monkeypatch) + with pytest.raises(CliError) as exc2: + dev2.write("LED_BRIGHTNESS", [-1]) + assert exc2.value.code == EXIT_USER_ERROR + assert "-1" in exc2.value.message + assert fake2.calls == [] + + +def test_write_read_only_is_user_error_and_sends_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.write("AEC_NUM_MICS", [1]) + assert exc.value.code == EXIT_USER_ERROR + assert "read-only" in exc.value.message + assert fake.calls == [] + + +def test_write_wrong_count_is_user_error(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.write("AUDIO_MGR_MIC_GAIN", [0.1, 0.2]) + assert exc.value.code == EXIT_USER_ERROR + assert fake.calls == [] + + +def test_write_non_numeric_is_user_error(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.write("AUDIO_MGR_MIC_GAIN", ["loud"]) + assert exc.value.code == EXIT_USER_ERROR + assert fake.calls == [] + + +def test_write_unknown_name_is_user_error(monkeypatch: pytest.MonkeyPatch) -> None: + dev, fake = install(monkeypatch) + with pytest.raises(CliError) as exc: + dev.write("NOT_A_PARAM", [1]) + assert exc.value.code == EXIT_USER_ERROR + assert fake.calls == [] + + +# --- firmware / metadata ---------------------------------------------------- + + +def test_firmware_info(monkeypatch: pytest.MonkeyPatch) -> None: + replies = [ + b"\x00\x01\x02\x03", + b"\x00" + b"build-msg".ljust(50, b"\x00"), + b"\x00" + b"buildhost".ljust(30, b"\x00"), + b"\x00" + b"deadbeef".ljust(40, b"\x00"), + ] + dev, _ = install(monkeypatch, replies) + assert dev.firmware_info() == { + "version": "1.2.3", + "build": "build-msg", + "host": "buildhost", + "repo_hash": "deadbeef", + } + + +def test_known_ids() -> None: + assert xvf3800.KNOWN_IDS[("38fb", "1001")] == "Reachy Mini Audio" + assert xvf3800.KNOWN_IDS[("2886", "001a")] == "ReSpeaker XVF3800 (Seeed USB firmware)" + + +def test_persistent_set() -> None: + for name in ( + "SAVE_CONFIGURATION", + "CLEAR_CONFIGURATION", + "REBOOT", + "TEST_CORE_BURN", + "TEST_AEC_DISABLE_CONTROL", + "USB_BIT_DEPTH", + ): + assert name in xvf3800.PERSISTENT + specials = {n for n in xvf3800.PARAMETERS if n.startswith("SPECIAL_CMD_")} + assert specials + assert specials <= xvf3800.PERSISTENT + assert "AUDIO_MGR_MIC_GAIN" not in xvf3800.PERSISTENT + assert xvf3800.PERSISTENT <= set(xvf3800.PARAMETERS) + + +def test_param_info() -> None: + info = xvf3800.param_info("audio_mgr_mic_gain") + assert info.name == "AUDIO_MGR_MIC_GAIN" + assert (info.resid, info.cmdid, info.count) == (35, 0, 1) + assert info.access == "rw" + assert info.type == "float" + assert info.persistent is False + assert xvf3800.param_info("REBOOT").persistent is True + assert xvf3800.param_info("REBOOT").to_dict()["access"] == "wo" + + +def test_param_info_unknown_is_user_error() -> None: + with pytest.raises(CliError) as exc: + xvf3800.param_info("NOPE") + assert exc.value.code == EXIT_USER_ERROR + + +def test_accepts_callable_transfer() -> None: + calls: list[tuple[object, ...]] = [] + + # type: ignore[no-untyped-def] + def transfer(request_type, request, value, index, data_or_length): + calls.append((request_type, request, value, index, data_or_length)) + return b"\x00" + struct.pack(" None: + closed: list[int] = [] + monkeypatch.setattr(xvf3800.os, "close", closed.append) + dev = xvf3800.Xvf3800(11) + dev.close() + dev.close() + assert closed == [11] + + +# --------------------------------------------------------------------------- +# Firmware overlays (found on hardware: Seeed USB firmware v2.1.0, 2026-09-06) +# --------------------------------------------------------------------------- + + +def test_seeed_overlay_changes_doa_and_drops_radians() -> None: + from microphone_cli.xvf3800 import SEEED_VENDOR, parameters_for + + seeed = parameters_for(SEEED_VENDOR) + assert seeed["DOA_VALUE"] == (20, 18, 2, "ro", "uint16") + assert "DOA_VALUE_RADIANS" not in seeed + assert seeed["LED_RING_COLOR"] == (20, 19, 12, "rw", "uint32") + # The base (Reachy) table is untouched. + assert parameters_for(None)["DOA_VALUE"][4] == "uint32" + assert "DOA_VALUE_RADIANS" in parameters_for("38fb") + + +def test_param_info_honours_vendor() -> None: + from microphone_cli.xvf3800 import SEEED_VENDOR, param_info + + assert param_info("doa_value", SEEED_VENDOR).type == "uint16" + with pytest.raises(CliError) as exc: + param_info("DOA_VALUE_RADIANS", SEEED_VENDOR) + assert exc.value.code == EXIT_USER_ERROR + assert "2886" in exc.value.message + + +def test_uint16_read_requests_five_bytes_and_decodes_degrees() -> None: + import struct + + from microphone_cli.xvf3800 import SEEED_VENDOR, Xvf3800 + + calls: list[tuple[int, int, int, int, int | bytes]] = [] + + def transfer(request_type, request, value, index, data_or_length): + calls.append((request_type, request, value, index, data_or_length)) + return b"\x00" + struct.pack(" None: + from microphone_cli.xvf3800 import FIRMWARE_OVERLAYS, Xvf3800 + + # No writable uint16 exists in either firmware; use a synthetic overlay entry. + FIRMWARE_OVERLAYS["ffff"] = {"TEST_U16": (99, 1, 2, "rw", "uint16")} + try: + sent: list[bytes] = [] + chip = Xvf3800(lambda *a: sent.append(bytes(a[4])) or len(a[4]), vendor="ffff") + chip.write("TEST_U16", [1, 65535]) + assert sent == [b"\x01\x00\xff\xff"] + finally: + del FIRMWARE_OVERLAYS["ffff"] diff --git a/uv.lock b/uv.lock index 837656e..c369f1e 100644 --- a/uv.lock +++ b/uv.lock @@ -227,7 +227,7 @@ wheels = [ [[package]] name = "microphone-cli" -version = "0.8.2" +version = "0.9.0" source = { editable = "." } [package.dev-dependencies]