diff options
Diffstat (limited to 'docs/specs')
| -rw-r--r-- | docs/specs/2026-07-10-audio-doctor-input-side-spec.org | 119 |
1 files changed, 105 insertions, 14 deletions
diff --git a/docs/specs/2026-07-10-audio-doctor-input-side-spec.org b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org index b3bab7f..9ed3be7 100644 --- a/docs/specs/2026-07-10-audio-doctor-input-side-spec.org +++ b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org @@ -83,7 +83,7 @@ Facts this spec is built on, verified rather than remembered: - *v1:* the kernel capture tier; PTT-aware source classification; input verdicts and their remedies; the two section-header doctor keys with mutual exclusion; explicit =audio doctor --output= / =--input= flags, output aliased as the default. - *Out of scope:* PTT rebuild; per-application routing; hardware diagnosis. -- *vNext:* a third sighting of "the graph is fine and one client cannot use it" turns into its own design question, namely whether the doctor should name the application layer explicitly. Logged to =todo.org=, not built here. +- *vNext:* per-device correlation between the kernel capture set and graph sources (v1 uses the coarse set-emptiness rule instead); and a third sighting of "the graph is fine and one client cannot use it", which turns into its own design question about whether the doctor should name the application layer. Both logged to =todo.org=, not built here. * Design @@ -91,14 +91,16 @@ Facts this spec is built on, verified rather than remembered: The DOCTOR section header we shipped on 2026-07-10 goes away. In its place, the OUTPUTS and INPUTS engraved section headers each grow a doctor key, beside the device counts they already carry. The keys diagnose the direction they sit above, and both write to the same results wall below. -Pressing the key over INPUTS says something a probe cannot: that this machine is supposed to have a microphone. That single bit resolves the ambiguity the classifier cannot. An empty source list is normal on a desktop; an empty source list *under a press of the INPUT doctor* is a failure, and the doctor now has license to say so. +The keys do not repeat the direction words. The CONTROLS section already carries INPUT and OUTPUT keys — the mute toggles that read LIVE / MUTE — so a doctor key labelled OUTPUT a few rows up would read as a fourth mute control. The doctor keys are named for what they examine instead: SPEAKERS on the OUTPUTS header, MICROPHONE on the INPUTS. Each carries a distinct diagnostic treatment so a user reads it as a different kind of key, not a device control. See the decision below; that treatment is the same on every panel's doctor. -The wall names which doctor ran, and only one runs at a time. Pressing OUTPUT while an INPUT run is in flight does nothing, because the wall holds one verdict and a second run would overwrite the first mid-read. +Pressing MICROPHONE says something a probe cannot: that this machine is supposed to have a microphone. That single bit resolves the ambiguity the classifier cannot. An empty source list is normal on a desktop; an empty source list *under a press of the MICROPHONE doctor* is a failure, and the doctor now has license to say so. + +The wall names which doctor ran, and only one runs at a time. Pressing SPEAKERS while a MICROPHONE run is in flight does nothing, because the wall holds one verdict and a second run would overwrite the first mid-read. Diagnosis remains free and read-only. The escalation the user asked for — "we tried reloading all the devices and there is still no microphone" — is a *repair*, not a diagnosis, because reloading devices means restarting WirePlumber. So the diagnose verdict names what is wrong, and FIX is what tries the reload and re-probes. The wall then carries a claim the doctor earned rather than one it assumed: #+begin_example -DOCTOR · INPUT diagnose · read-only +DOCTOR · MICROPHONE diagnose · read-only ok pipewire.service active ok wireplumber.service active ok pipewire-pulse.service active @@ -129,7 +131,9 @@ And when the kernel sees nothing either, the doctor stops rather than offering a The probe ladder gains a tier at the bottom, and its position is the point. Today's order is =systemctl= (cannot hang), =pw-dump= (alive when Pulse is dead), =pactl= (may hang). The kernel tier reads =/proc/asound=, which is a filesystem read that cannot hang, cannot fail for want of a package, and answers when every other layer is dead. It is the only probe in the doctor that carries no timeout, because it cannot block. -It answers one question: which cards expose a capture PCM. A card directory containing a =pcm*c= entry has one. That set, compared against the graph's source list, is what separates a lost device from an absent one. +It answers one question: which cards expose a capture PCM. A card directory containing a =pcm*c= entry has one. That set is what separates a lost device from an absent one. + +The comparison is deliberately coarse in v1, because the two layers speak different namespaces. The kernel tier names cards by ALSA id (=MV7=, =BRIO=); the graph names sources by PipeWire =node.name=. Mapping one to the other device-by-device is a rabbit hole, and the doctor does not need it. The v1 rule is a set-emptiness test, not a per-device match: =mic-unrecognized= fires when the kernel capture set is non-empty *and* the graph has zero non-monitor hardware sources. A monitor source does not count as a microphone (it is desktop-audio capture), so a graph carrying only monitors still reads as "no real input the kernel's hardware could be feeding." That coarse rule catches the whole-mic-lost case this feature exists for without ever having to prove that card X is the same device as source Y. Per-device correlation is a vNext refinement, logged, not built. *** The classifier takes a direction @@ -145,6 +149,66 @@ Push-to-talk sits inside that last rule. =diag.gather()= reads =ptt.read_state() Unmuting a microphone is not audible. It is a privacy event. If something is capturing when the remedy lands, the user's voice goes out. So the mic-unmute remedy is guarded against *input* streams with the same press-again override the audible remedies use, and the playback remedies are guarded against *output* streams. +* Failure modes and remedies + +The whole point of a doctor is that every fault it can name has a stated fix, and the reader can see the two together. This is that map: what goes wrong at each probe tier, the verdict the classifier reaches, and the remedy FIX runs. The tiers are the probe ladder, top to bottom, so a fault lower in the table is one the doctor reaches only after the tiers above it answered clean. + +The first four tiers are shared: a broken unit, a dead graph, or a dead Pulse layer takes out both directions at once, and the same verdict serves whichever doctor the user pressed. The kernel tier and the source-side semantic rules are what this spec adds. + +| Tier | What goes wrong | Verdict | Remedy | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 1 units (=systemctl=) | =pipewire= not active, or | =graph-down= | 5: restart =pipewire= (Arm) | +| | the graph will not answer | | | +| | though the unit claims up | | | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 1 units | =wireplumber= not active | =no-session-manager= | 1: restart =wireplumber= | +| | | | (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 1 units | =pipewire-pulse= not active | =pulse-down= | 4: restart =pipewire-pulse= | +| | | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 2 graph (=pw-dump=) | the graph is up but has no | =no-output-devices= | 1: restart =wireplumber=, | +| | sink | | which re-creates devices | +| | | | (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 3 pulse (=pactl=) | the socket accepts and never | =pulse-hung= | 4: restart =pipewire-pulse= | +| | answers | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 3 pulse | the socket refuses | =pulse-down= | 4: restart =pipewire-pulse= | +| | | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 3 pulse | a probe's own tool is | =tooling-missing= | none: the stack is | +| | missing | | unobserved, not broken | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, output | the default sink names a | =stale-default= | 2: set a present default | +| | device that is absent | | sink (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, output | the default sink is muted or | =silenced= | 3: unmute and restore volume | +| | at zero volume | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, input | the default source names a | =mic-stale-default= | 7: set a present default | +| | device that is absent | | source (Auto) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, input | the default source is muted | =mic-silenced= | 8: unmute and restore volume | +| | or at zero, PTT off | | (Confirm) | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 4 semantic, input | the default source is muted, | healthy, informational row | none | +| | PTT armed | | | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 5 kernel (=/proc/asound=) | kernel capture set non- | =mic-unrecognized= | 6: reload profiles, restart | +| | empty, graph has no non- | | =wireplumber= (Confirm) | +| | monitor hardware source | | | +|---------------------------+------------------------------+----------------------------+------------------------------| +| 5 kernel | no capture device is | =no-mic-hardware= | none: a cable, a port, or a | +| | attached at all | | device that is off | +|---------------------------+------------------------------+----------------------------+------------------------------| +| all clean | nothing is wrong | =healthy= | none | +|---------------------------+------------------------------+----------------------------+------------------------------| + +Three entries carry no remedy on purpose, and they are not the same kind of no. =tooling-missing= means the doctor could not observe the stack, =no-mic-hardware= means there is nothing in the stack to fix, and =healthy= means there is nothing wrong. A doctor that offered a button for any of the three would teach the user to distrust the wall. + +Remedies 1 and 6 both restart WirePlumber, and that is deliberate: =no-output-devices= and =mic-unrecognized= are different diagnoses that happen to share a fix. Collapsing them would couple two faults that should read apart, the same call the parent spec made for =pulse-hung= and =pulse-down=. + * Alternatives Considered ** Rebuild push-to-talk so it does not mute the mic @@ -175,7 +239,16 @@ Rejected in favor of one doctor per direction, which supplies the missing intent Rejected. The bottom tier should be the one thing in the doctor that cannot fail. -* Decisions [4/7] +* Review findings [1/2] + +** TODO Open Decisions still gate implementation :blocking: +The workflow treats unresolved decisions as implementation blockers, because a builder would be accepting product and safety tradeoffs mid-build. Partly addressed as of 2026-07-10: Craig closed the push-to-talk and kernel-tier decisions, and the CLI-flag decision closed with his direction, so the cookie is now =[6/9]=. Three decisions remain open — the direction-aware capture guard, the input verdict and remedy table, and the doctor-key naming and cross-panel style. Close or explicitly risk-accept each before implementation starts. (blocking) + +** DONE Kernel capture devices are not mapped to graph sources :blocking: +CLOSED: [2026-07-10 Fri] +The finding: the kernel names cards by ALSA id and the graph names sources by PipeWire =node.name=, so "compare the kernel capture set against the graph's source list" was undefined, and monitor sources muddied it further. Resolved 2026-07-10 by adopting the reviewer's coarse rule as the v1 definition: =mic-unrecognized= fires when the kernel capture set is non-empty and the graph has zero non-monitor hardware sources. No per-device correlation, no namespace mapping — that whole-mic-lost case is the one this feature exists for, and per-device matching is logged as vNext. The rule now appears in the "For the implementer" kernel-tier note, the failure table, and the input-verdicts decision. + +* Decisions [6/9] ** DONE A doctor key per direction, on the section headers CLOSED: [2026-07-10 Fri] @@ -201,26 +274,28 @@ Decision: we will not add a source-side =no-input-devices= fault. The input rule Consequences: the doctor never cries wolf on a mic-less desktop. Harder: the classifier now takes a side parameter, so it is no longer a pure function of the context alone. It stays pure; the side is an input. -** TODO Does push-to-talk get rebuilt? +** DONE Does push-to-talk get rebuilt? +CLOSED: [2026-07-10 Fri] Context: PTT mutes the default source and leaves it muted between holds, so a naive mirror of the sink rules would classify an armed mic as "silenced, FIX unmutes it". Pressing FIX would break PTT and leave a hot mic the user believes is muted. Craig asked whether PTT could avoid muting. -Decision (proposed): we will not rebuild PTT. =diag.gather()= will read =ptt.read_state()= into the context, and an armed-PTT muted mic will be reported as an informational row rather than a finding. +Decision: we will not rebuild PTT. =diag.gather()= will read =ptt.read_state()= into the context, and an armed-PTT muted mic will be reported as an informational row rather than a finding. Consequences: a few lines rather than a rewrite, and the safety property is preserved — source-mute is the only state where the server guarantees no capture. Harder: the doctor now depends on a runtime state file, so a stale or unreadable file must degrade to "not armed" (which =read_state()= already does) and the mic then reads as a genuine finding. That is the safe direction: it over-reports a muted mic rather than under-reporting a hot one. -Owner: Craig. By: before Phase 0 lands. +Craig ratified 2026-07-10: not rebuilding is fine. *** Discussion The alternatives are recorded above under "Rebuild push-to-talk so it does not mute the mic". The load-bearing argument is that link-based silence fails open. -** TODO The kernel tier reads =/proc/asound=, not =arecord= +** DONE The kernel tier reads =/proc/asound=, not =arecord= +CLOSED: [2026-07-10 Fri] Context: the doctor needs a layer below PipeWire to tell a lost microphone from an absent one. -Decision (proposed): we will read =/proc/asound/card*/pcm*c= directly. A card exposing a capture PCM has capture hardware. +Decision: we will read =/proc/asound/card*/pcm*c= directly. A card exposing a capture PCM has capture hardware. Consequences: the bottom tier cannot hang, cannot be absent, and needs no timeout — the only probe in the doctor with that property. Harder: it is Linux-specific and reads a kernel interface rather than a supported API, so a kernel that reorganizes =/proc/asound= would break it silently. The fallback is that an unreadable =/proc/asound= yields "unknown", never "no hardware", so the doctor abstains rather than lying. -Owner: Craig. By: before Phase 0 lands. +Craig ratified 2026-07-10: all okay. ** TODO The capture guard, and what it refuses Context: unmuting a microphone is not audible, but it is a privacy event. =active_streams()= currently ignores stream direction, so a playback remedy is refused today when only a recording is running. @@ -240,8 +315,8 @@ Decision (proposed): four new verdicts, and two new remedies. |---------------------+------------------------------------------+-------------------------------------------+---------| | =no-mic-hardware= | the kernel sees no capture device | none | — | |---------------------+------------------------------------------+-------------------------------------------+---------| -| =mic-unrecognized= | kernel sees capture hardware, the graph | 6: reload device profiles (restart | Confirm | -| | has no source for it | WirePlumber) | | +| =mic-unrecognized= | kernel capture set non-empty, graph has | 6: reload device profiles (restart | Confirm | +| | no non-monitor hardware source | WirePlumber) | | |---------------------+------------------------------------------+-------------------------------------------+---------| | =mic-stale-default= | the default source names a device that | 7: set a present default source | Auto | | | is absent | | | @@ -252,6 +327,8 @@ Decision (proposed): four new verdicts, and two new remedies. =no-mic-hardware= carries no remedy for the same reason =tooling-missing= carries none: offering a fix for a fault that is not in the stack teaches the user to distrust the wall. +=mic-unrecognized= uses the coarse set-emptiness rule from the "For the implementer" kernel-tier note, not a per-device match. The kernel names cards by ALSA id and the graph names sources by PipeWire =node.name=; those are different namespaces, and correlating them device-by-device is a vNext refinement. v1 fires =mic-unrecognized= when the kernel capture set is non-empty and the graph has zero non-monitor hardware sources. This resolves the second spec-review finding, which flagged the matching rule as undefined. + Consequences: the verdict table grows from ten to fourteen, and =classify.findings()= grows a capture-hardware row and a default-input row. Harder: remedy 6 restarts WirePlumber, which remedy 1 already does for a different verdict. Two remedies invoking one action is fine; collapsing them would couple two diagnoses that should stay apart. Owner: Craig. By: before Phase 1 lands. @@ -266,6 +343,15 @@ Consequences: the two directions are named symmetrically, and =--input= has a si Craig's call, 2026-07-10: "alias the command and have =audio doctor --output= be the one that is run by default. That way it's symmetrical, and =audio doctor --input= has a place." +** TODO Name and style the doctor keys, distinct from the device controls +Context: the CONTROLS section already carries INPUT and OUTPUT keys — the mute toggles that read LIVE / MUTE. A doctor key labelled OUTPUT or INPUT on the section header a few rows up would read as a fourth mute control. Craig flagged the collision and asked for a name and a look that mark the doctor keys as a different function, held consistent across every panel. + +Decision (proposed): the doctor keys are named for what they diagnose — SPEAKERS on the OUTPUTS header, MICROPHONE on the INPUTS. Craig delegated the naming ("I'll let you choose"); these are the choice. Each key carries a distinct diagnostic treatment: a small health glyph (a stethoscope, =nf-md-stethoscope=) ahead of the word, plus a subtly distinct outline, so the key stays inside the dupré aesthetic while reading as diagnostic rather than a device control. The same treatment applies to the doctor keys already shipped in the other panels — net's DOCTOR, maint's CLEAN UP / REVIEW & FIX — so a doctor key looks like a doctor key everywhere. + +Consequences: no word collides with the CONTROLS keys; the two doctor keys carry distinct AT-SPI names the smoke can target; the diagnostic affordance is consistent across the panel family. Harder: it touches every panel's =gui.py= and the shared panel CSS, so the cross-module restyle is its own task rather than part of this build, and it sits beside the already-filed "copy + close on every wall" convergence. The exact glyph and outline are an aesthetic call that wants Craig's eye on a screenshot before it lands — which is why this stays open while the naming is settled. + +Owner: Craig (the visual). By: before Phase 3, where the keys are built. + * Implementation phases Each phase leaves the tree green and independently useful, as the parent spec's phases did. @@ -323,6 +409,11 @@ This spec supersedes a decision from a spec that reached IMPLEMENTED the same da * Review and iteration history +** 2026-07-10 Fri @ 10:55:19 -0500 — Codex — Reviewer +What changed or was recommended: applied the spec-review workflow and recorded two blocking findings: open Decisions still gate implementation, and kernel-capture-to-graph-source matching is undefined. +Why: the current implementation confirms the microphone gap and output-only classifier, but these unresolved choices would force the builder to decide safety and classification behavior during implementation. +Artifacts: [[*Review findings][Review findings]]; current code read in =~/.dotfiles/audio/src/audio/diag.py=, =classify.py=, =repair.py=, =cli.py=; related tracking in [[file:../../todo.org][todo.org]] under "The audio doctor never checks the microphone". + ** 2026-07-10 Fri @ 10:38:12 -0500 — Craig Jennings — Author What: drafted the spec. Four probe tiers become five, the classifier takes a direction, the panel gets a doctor per direction, and push-to-talk stays as it is. Why: the doctor calls a stack healthy while the microphone is dead, and the ambiguity of an absent mic cannot be resolved by any probe. |
