aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/2026-07-10-audio-doctor-input-side-spec.org
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-10 10:42:58 -0500
committerCraig Jennings <c@cjennings.net>2026-07-10 10:42:58 -0500
commite3a479ec73ca69aa020c4ec86b71ee88a19d6bec (patch)
tree53ab065bc3a0eb020fc803c192db6d8df894070a /docs/specs/2026-07-10-audio-doctor-input-side-spec.org
parent3e3e9885b87cdccb8c831a8cacf8d2a97a12d62c (diff)
downloadarchsetup-main.tar.gz
archsetup-main.zip
docs: spec the audio doctor's input side, one doctor per directionHEADmain
The doctor never examines the microphone. diag collects default_source and default_source_present, the classifier reads neither, so a muted mic, a stale default source, and a mic the sound server can't see all classify as healthy. Chrome losing the mic this morning surfaced it: the stack was genuinely fine and the doctor had nothing useful to say. The design grew past the gap in one conversation. An empty source list is normal on a mic-less desktop and a failure on a machine that should have one, and no probe can tell those apart. The missing fact comes from the user's finger: a doctor key on each of the OUTPUTS and INPUTS section headers, where pressing the input one asserts a mic should exist. That retires the DOCTOR header key I shipped hours earlier, and it dissolves the precedence question a single classifier would have faced. A new probe tier sits below PipeWire. /proc/asound lists capture-capable cards, needs no package, spawns no process, and can't hang, so it separates "the software lost a microphone the kernel sees" from "nothing is plugged in". Push-to-talk stays as it is. It mutes the source, the one state where the server guarantees nothing is captured. A link-based push-to-talk fails open, and a crash mid-hold is a hot mic reading as muted. The doctor reads ptt.read_state() instead of guessing. Four decisions are open. Three are closed.
Diffstat (limited to 'docs/specs/2026-07-10-audio-doctor-input-side-spec.org')
-rw-r--r--docs/specs/2026-07-10-audio-doctor-input-side-spec.org328
1 files changed, 328 insertions, 0 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
new file mode 100644
index 0000000..11f61b8
--- /dev/null
+++ b/docs/specs/2026-07-10-audio-doctor-input-side-spec.org
@@ -0,0 +1,328 @@
+#+TITLE: Audio Doctor, Input Side — a doctor per direction
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Audio Doctor, Input Side
+:PROPERTIES:
+:ID: 45efdd66-6d22-45c0-a633-ec41079e38ec
+:END:
+- [2026-07-10 Fri] DRAFT — drafted. Extends the audio doctor ([[file:2026-07-09-audio-doctor-spec.org][2026-07-09-audio-doctor-spec.org]], IMPLEMENTED) to the microphone, and replaces its single DOCTOR header key with one doctor per direction. Grounded in a live survey of ratio, not memory.
+
+* Metadata
+
+| Field | Value |
+|----------+----------------------------------------------------------------------------|
+| Status | draft |
+|----------+----------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org]] : "The audio doctor never checks the microphone" |
+|----------+----------------------------------------------------------------------------|
+| Parent | [[file:2026-07-09-audio-doctor-spec.org][2026-07-09-audio-doctor-spec.org]] — this supersedes its DOCTOR-key decision |
+|----------+----------------------------------------------------------------------------|
+
+* Summary
+
+The audio doctor is output-only. It never examines the microphone, so a muted mic, a default source naming an unplugged device, and a mic the sound server cannot see all classify as =healthy=.
+
+This adds the input side, and in doing so answers a question no probe can: whether a machine with no microphone is broken or merely mic-less. The answer comes from the user's finger. A doctor key on the INPUTS section header means "there should be a mic here", so an empty source list under that press is a failure rather than a shrug.
+
+* Problem / Context
+
+Three separate failures, all currently invisible.
+
+** The classifier does not look at the source
+
+=diag.probe_semantic= already collects =default_source= and =default_source_present=. =classify.py= reads neither. The word "source" appears once in that module, in the graph row that counts how many exist. Every semantic rule the classifier applies to the sink (is a default set, is it present, is it muted, is it at zero volume) has no source counterpart.
+
+So the =healthy= verdict prints "Units are up, the graph answers, the Pulse layer answers, and the default output is present and audible." Output. Only. A user reading "the sound stack is healthy" concludes the mic is fine, and the doctor never checked.
+
+** Nobody can tell an absent microphone from a broken one
+
+A desktop with no microphone is a normal machine. So an empty source list cannot be a fault the way =no-output-devices= is, and the classifier has no way to know which machine it is on. This is not a probe problem. No amount of reading the graph settles it, because the missing fact lives in the user's head.
+
+** The doctor cannot see below PipeWire
+
+When Chrome stopped recognizing the microphone on 2026-07-10, the stack was genuinely healthy: the Shure MV7+ was the default source, unmuted, at 82%, with push-to-talk off. The doctor would have said =healthy= and been right about the stack and useless to the user.
+
+The layer that would have helped is the one the doctor never reads. The kernel's own capture list distinguishes "the sound server lost a microphone the kernel can see" from "nothing is plugged in", and those two faults have nothing in common.
+
+** Live survey (ratio, 2026-07-10)
+
+Facts this spec is built on, verified rather than remembered:
+
+- =/proc/asound/card*/pcm*c= lists capture-capable ALSA devices. On ratio: =Generic_1= (ALC623 analog), =MV7= (Shure), =BRIO= (Logitech). A =pcm*c= node means the card has a capture PCM; =pcm*p= would be playback.
+- That path is a kernel filesystem read. It needs no package, spawns no process, and *cannot hang* — unlike every existing probe tier. =arecord -l= reports the same set but is owned by =alsa-utils=, which is a dependency the engine does not otherwise carry.
+- =diag= already tags each stream =input= or =output= (=_STREAM_CLASSES=, mapping =Stream/Input/Audio= and =Stream/Output/Audio=). =active_streams()= returns both without distinction.
+- Push-to-talk mutes the default source and leaves it muted between holds (=ptt.toggle_plan=). =ptt.read_state()= persists =armed=, so the deliberate mute is already knowable.
+- A monitor source is a legitimate default source. =probe_semantic= passes =include_monitors=True= on purpose.
+
+* Goals and Non-Goals
+
+** Goals
+
+- Classify the input side with the same rigor as the output side: default set, default present, muted, at zero volume.
+- Never report an armed push-to-talk mic as a fault.
+- Distinguish "the sound server does not see a microphone the kernel does" from "no capture hardware is attached", and say which.
+- Let the user assert that a microphone should exist, without a dialog.
+- One doctor at a time.
+
+** Non-Goals
+
+- Rebuilding push-to-talk. See the decision below; the mute is the safety property, not the bug.
+- Per-application capture routing. If Chrome cannot see a source the graph plainly has, that is Chrome's fault, and Helvum and qpwgraph exist. This spec makes the doctor able to *say* the stack is fine, which is the sentence that was missing. It does not chase the client.
+- Diagnosing hardware below the kernel. If =/proc/asound= shows no capture device, this spec reports that fact. It does not test cables, USB ports, or drivers.
+- Sample-rate, latency, or xrun concerns. Still out, as in the parent spec.
+- Any use of =sudo=. Unchanged: the whole stack is user-scope.
+
+** Scope tiers
+
+- *v1:* the kernel capture tier; PTT-aware source classification; input verdicts and their remedies; the two section-header doctor keys with mutual exclusion; =audio doctor --input= in the CLI.
+- *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.
+
+* Design
+
+** For the user
+
+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 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.
+
+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
+ ok pipewire.service active
+ ok wireplumber.service active
+ ok pipewire-pulse.service active
+ ok graph 4 sinks, 3 sources (via pw-dump)
+ ok pulse compat answering
+ FAIL capture hardware kernel sees MV7, BRIO, Generic_1 — the graph has none
+ -- default input not checked (no input devices)
+
+ verdict: The sound server does not see your microphone.
+ The kernel has 3 capture devices and PipeWire has none, so the
+ hardware is attached and the software lost it. FIX reloads the
+ device profiles.
+#+end_example
+
+And when the kernel sees nothing either, the doctor stops rather than offering a remedy for a fault that is not there:
+
+#+begin_example
+ FAIL capture hardware no capture device is attached
+
+ verdict: No microphone is attached.
+ The kernel sees no capture hardware at all, so this is a cable, a
+ port, or a device that is off. Nothing in the sound stack can fix it.
+#+end_example
+
+** For the implementer
+
+*** A tier below the graph
+
+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.
+
+*** The classifier takes a direction
+
+=classify(ctx)= becomes =classify(ctx, side)= where side is =output= or =input=. The terminal-fault ladder is shared and unchanged: PipeWire down, WirePlumber down, Pulse hung, Pulse dead, tooling missing. All of them break both directions, and the first one that fires wins regardless of side.
+
+Below that the ladder forks. The output side keeps its rules exactly as they are. The input side gets, in order: no capture hardware; hardware present but no source in the graph; a default source that names an absent device; a mic muted or at zero. Every input rule is gated on the user having pressed the input doctor, which is what makes an empty source list a fault instead of a fact.
+
+Push-to-talk sits inside that last rule. =diag.gather()= reads =ptt.read_state()= into the context, and a muted default source with PTT armed is not a finding. It is the mic doing exactly what the user asked. The wall reports it as an informational row rather than saying nothing, because a user who has forgotten PTT is armed deserves to be told why the mic is silent.
+
+*** The guard learns direction
+
+=active_streams()= returns running streams of both directions, and =repair.guard()= refuses the audible remedies whenever any of them is running. That is already slightly wrong: a playback remedy is refused today when the only thing running is a recording. Filtering by direction fixes that and is what the mic remedy needs.
+
+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.
+
+* Alternatives Considered
+
+** Rebuild push-to-talk so it does not mute the mic
+
+The idea: keep the source unmuted, and instead unlink its nodes from capturing applications (=pw-link=), or stand up a virtual source that applications connect to and link the real mic into it only while the key is held.
+
+- Good, because the doctor would see an unmuted mic and need no special case, and because an application's own level meter would keep working.
+- Bad, because the microphone stays live at the server and silence becomes a property of graph topology. A crash mid-hold, or a link that fails to tear down, is a hot mic while every indicator reads muted. Source-mute is the one state where the server itself guarantees nothing is captured.
+- Bad, because the virtual-source variant requires every application to select the virtual device, which breaks per-application device choice — the thing the panel exists to give.
+- Neutral, because the doctor does not need it: =ptt.read_state()= already persists the fact that the mute is deliberate.
+
+Rejected. The mute is the safety property.
+
+** One doctor that classifies both directions at once
+
+- Good, because one press and one verdict.
+- Bad, because =classify()= returns exactly one verdict, so a machine with a stale sink default and a muted mic forces the classifier to rank two unrelated faults. Any ranking is a guess about what the user came to fix.
+- Bad, because it cannot resolve the absent-microphone ambiguity at all. No press means no assertion, and the doctor is back to shrugging.
+
+Rejected in favor of one doctor per direction, which supplies the missing intent as a side effect of where the user pressed.
+
+** Use =arecord -l= for the kernel tier
+
+- Good, because it is the obvious tool and its output is human-readable.
+- Bad, because it is owned by =alsa-utils=, a dependency the audio engine does not otherwise carry, so its absence would have to be modelled as a fourth =tooling-missing= case.
+- Bad, because it spawns a process, which means a timeout, which means another way for the bottom tier to fail.
+- Neutral, because it reads the same kernel data =/proc/asound= exposes directly.
+
+Rejected. The bottom tier should be the one thing in the doctor that cannot fail.
+
+* Decisions [3/7]
+
+** DONE A doctor key per direction, on the section headers
+CLOSED: [2026-07-10 Fri]
+Context: an empty source list is normal on a mic-less desktop and a failure on a machine that should have one. No probe can tell them apart.
+
+Decision: we will put a doctor key on each of the OUTPUTS and INPUTS engraved section headers, and retire the standalone DOCTOR header and its DIAGNOSE key. Pressing the input doctor asserts that a microphone should exist.
+
+Consequences: the ambiguity is resolved by the user without a dialog, and the check sits beside the devices it judges. Harder: this supersedes a decision in the parent spec that shipped hours earlier, so the parent's history must say so. The panel also grows a third and fourth console key, and the 400px width has to absorb them.
+
+** DONE One doctor at a time
+CLOSED: [2026-07-10 Fri]
+Context: the wall holds one verdict, and a run clears the wall when it begins.
+
+Decision: we will allow only one doctor run in flight. OUTPUT must finish before INPUT can be pressed, and both keys go insensitive for the duration.
+
+Consequences: the wall is never a mix of two runs, and "which verdict is this" never needs asking. Harder: nothing, since the model already clears on begin and locks on =doctor_running=. The keys simply join the lock.
+
+** DONE An absent microphone is not a fault unless the user says so
+CLOSED: [2026-07-10 Fri]
+Context: =no-output-devices= is a fault because a machine with no way to play sound is broken. The mirror is false: a machine with no way to record is ordinary.
+
+Decision: we will not add a source-side =no-input-devices= fault. The input rules fire only under a press of the input doctor, which supplies the assertion that a mic should exist.
+
+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?
+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.
+
+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.
+
+*** 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=
+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.
+
+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.
+
+** 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.
+
+Decision (proposed): we will make the guard direction-aware. Playback remedies (3, 4, 5) refuse on running *output* streams; the mic-unmute remedy refuses on running *input* streams. Both take the same press-again override, and both abstain on an unreadable graph, as today.
+
+Consequences: the guard stops over-refusing, and unmuting a mic mid-recording takes deliberate consent. Harder: it changes the behavior of shipped remedies, so the existing guard tests move with it rather than being deleted.
+
+Owner: Craig. By: before Phase 2 lands.
+
+** TODO The input verdicts and their remedies
+Context: the input side needs its own verdict vocabulary and its own remedy table entries.
+
+Decision (proposed): four new verdicts, and two new remedies.
+
+| Verdict | Means | Remedy | Tier |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+| =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-stale-default= | the default source names a device that | 7: set a present default source | Auto |
+| | is absent | | |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+| =mic-silenced= | the default source is muted or at zero, | 8: unmute and restore the mic volume | Confirm |
+| | and PTT is not armed | | |
+|---------------------+------------------------------------------+-------------------------------------------+---------|
+
+=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.
+
+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.
+
+** TODO Does the CLI grow a flag or a subcommand?
+Context: the GUI's two keys need a CLI counterpart, since the doctor is CLI-first and the GUI is a face over it.
+
+Decision (proposed): =audio doctor= keeps its current meaning (the output side) and gains =--input=. Both accept =--fix= and =--force=.
+
+Consequences: no existing invocation changes meaning, and a script that branches on the exit code keeps working. Harder: =audio doctor --input --fix --force= is a long line, and "doctor with no flag means output" is a default a reader has to learn. The alternative, =audio doctor input=, reads better and breaks every existing call.
+
+Owner: Craig. By: before Phase 3 lands.
+
+* Implementation phases
+
+Each phase leaves the tree green and independently useful, as the parent spec's phases did.
+
+** TODO Phase 0 — the kernel tier, PTT in the context, direction-aware streams
+Pure engine, no classifier changes, no UI. =diag.probe_kernel()= reads =/proc/asound=; =diag.gather()= adds =kernel= and =ptt= to the context; =active_streams(graph, direction=None)= filters. =audio diag --json= shows the new context. Fakes: a temp =/proc/asound=-shaped tree via an injectable root, since the real one cannot be faked on PATH.
+
+** TODO Phase 1 — =classify(ctx, side)= and the input verdicts
+Pure, fixture-driven. The shared terminal ladder, then the fork. Pairwise over (kernel capture set × graph sources × default source state × PTT armed). =audio doctor --input= read-only prints findings and a verdict.
+
+** TODO Phase 2 — the input remedies and the direction-aware guard
+Remedies 6, 7, 8, each re-probing its own claim. The guard splits by direction. =audio doctor --input --fix=. The existing guard tests move rather than being deleted.
+
+** TODO Phase 3 — two section-header doctor keys
+Retire the DOCTOR header row and its DIAGNOSE key. OUTPUTS and INPUTS each gain one. Mutual exclusion on =doctor_running=. The wall header names the run. AT-SPI smoke over both keys and the fix path, on fixtures.
+
+** TODO Phase 4 — flip this spec to IMPLEMENTED
+And amend the parent spec's history to record that its DOCTOR-key decision was superseded here.
+
+* Acceptance criteria
+
+- A machine with no capture hardware, under a press of the input doctor, reports =no-mic-hardware= and offers nothing to press.
+- A machine whose kernel lists a capture device the graph has no source for reports =mic-unrecognized=, and FIX reloads the device profiles and re-probes.
+- An armed-PTT muted mic classifies as =healthy=, with an informational row saying push-to-talk is armed.
+- A muted mic with PTT disarmed classifies as =mic-silenced= and FIX unmutes it.
+- FIX on the mic while something is recording is refused, and a second press proceeds.
+- Restarting a playback remedy while only a microphone is recording is no longer refused.
+- Pressing OUTPUT while INPUT is running does nothing.
+- =audio doctor= with no flag behaves exactly as it does today.
+
+* Readiness dimensions
+
+- *Data model & ownership* — the context dict gains =kernel= (generated, per-probe) and =ptt= (read from the runtime state file that =ptt.py= owns; the doctor never writes it).
+- *Errors, empty states & failure* — an unreadable =/proc/asound= yields =unknown=, never "no hardware". An unreadable PTT state reads as disarmed, which over-reports a muted mic rather than under-reporting a hot one. Both are the safe direction.
+- *Security & privacy* — the mic-unmute remedy is the only privacy-relevant action in the feature, and it is guarded against running capture streams with an explicit second-press override. No credentials, no logs.
+- *Observability* — the wall is the observability surface, and it names which doctor ran. The =--json= output carries the full context including the kernel tier.
+- *Performance & scale* — the kernel tier is a directory listing. Everything else is unchanged.
+- *Reuse & lost opportunities* — =classify.findings()= remains the single row-builder for the CLI and the GUI. =ptt.read_state()= is reused rather than reimplemented. =diag.unit_active= stays the single source of truth for unit state.
+- *Architecture fit* — the fork lives in =classify()=, which is pure and already fixture-tested. The GUI change is confined to the section-header rows and the model's key-sensitivity logic.
+- *Config surface* — none. N/A.
+- *Documentation plan* — the module docstrings carry the design, as in the parent. No user docs; the wall is the documentation.
+- *Dev tooling* — =make test= and =make test-panel-audio= cover it. The kernel tier needs an injectable root rather than a PATH fake, which is a new fixture shape for this package.
+- *Rollout, compatibility & rollback* — =audio doctor= with no flag is unchanged, so nothing that scripts it breaks. The GUI change is not reversible by config; it is a redesign of a key that shipped the same day.
+- *External APIs & deps* — =/proc/asound= layout verified live on ratio, 2026-07-10. No new packages.
+
+* Risks, rabbit holes, and drawbacks
+
+The kernel tier reads an interface rather than an API. If a future kernel reorganizes =/proc/asound=, the probe reports =unknown= and the doctor abstains, which is the failure mode we want, but nothing will announce the breakage. A test against a recorded tree pins the shape we expect.
+
+Remedy 6 restarts WirePlumber to reload device profiles, which is what remedy 1 already does. The temptation is to collapse them. Do not: the two verdicts describe different faults, and a shared remedy is not a shared diagnosis. The parent spec made exactly this call for =pulse-hung= and =pulse-down=.
+
+The panel is 400px wide and gains two console keys while losing one row. The layout needs eyes on it before Phase 3 closes, and no automated check will catch an ugly one.
+
+This spec supersedes a decision from a spec that reached IMPLEMENTED the same day. That is a smell worth naming: the DOCTOR-key placement was decided without the input side in view, and one conversation with the user overturned it. The lesson is not that the first decision was careless. It is that the input side should have been in scope from the start, and the parent's Non-Goals never said it was not.
+
+* Review and iteration history
+
+** 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.
+Artifacts: live survey of ratio (=/proc/asound= capture nodes, =_STREAM_CLASSES=, =ptt.read_state()=), and the Chrome incident of the same morning.