aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-06 10:15:49 -0500
committerCraig Jennings <c@cjennings.net>2026-07-06 10:15:49 -0500
commit3cec838868c9cd34cca085574d7a17c3d70d9d3d (patch)
treee5439b60f0e275f64db357c4c3cac48f43c7bcee /docs
parent1a057373daf14a514b1bfa4a62d743788aa37296 (diff)
downloadarchsetup-3cec838868c9cd34cca085574d7a17c3d70d9d3d.tar.gz
archsetup-3cec838868c9cd34cca085574d7a17c3d70d9d3d.zip
docs: audio-panel signal-metering spec (implemented)
Records the signal-metering layer for the audio panel: a three-state activity lamp from the pactl state field, and a windowed peak meter feeding the VU needles, keyed per device so per-device metering stays a later GUI-only change. Two review rounds before build (the first caught a parec sample-rate-vs-update-rate bug verified live); marked implemented across four dotfiles commits. Live-eyeball checks filed as a manual-testing checklist.
Diffstat (limited to 'docs')
-rw-r--r--docs/specs/2026-07-06-audio-panel-signal-metering-spec.org226
1 files changed, 226 insertions, 0 deletions
diff --git a/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org
new file mode 100644
index 0000000..21444cf
--- /dev/null
+++ b/docs/specs/2026-07-06-audio-panel-signal-metering-spec.org
@@ -0,0 +1,226 @@
+#+TITLE: Audio Panel — signal metering (which inputs/outputs actually have sound)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-06
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Audio panel signal metering
+:PROPERTIES:
+:ID: baef9e7b-d255-4e80-9d10-68a7a3dd04fd
+:END:
+- 2026-07-06 Mon @ 10:40:00 -0500 — IMPLEMENTED — all 4 phases built TDD in dotfiles (6054d3d activity-state engine, 174ce14 three-state lamp, 55ab9f9 peak engine, 21437b4 live needles + lifecycle). Audio suite 124→161; full dotfiles make test green (49 suites). One residual: live eyeball checks (needle deflects under real audio, PTT INPUT registration, three-state lamp, no-CPU-when-closed) filed as a manual-testing task in todo.org.
+- 2026-07-06 Mon @ 10:02:00 -0500 — DOING — decomposing the four phases into the build (no-approvals speedrun).
+- 2026-07-06 Mon @ 10:00:00 -0500 — READY — spec-review round 2 passed (all 4 round-1 findings resolved DONE; both cookies complete; no blockers).
+- 2026-07-06 Mon @ 09:37:52 -0500 — DRAFT — drafted. Scopes "option 2" from the 2026-07-06 design discussion: an activity/peak metering layer on the audio panel, with a per-device-keyed engine so per-device rendering (option 3) is a later GUI-only change.
+
+* Metadata
+
+| Field | Value |
+|----------+--------------------------------------------------------------|
+| Status | implemented |
+|----------+--------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+--------------------------------------------------------------|
+| Reviewer | Claude Code (archsetup) |
+|----------+--------------------------------------------------------------|
+| Repo | dotfiles (=audio/= package); archsetup owns the lifecycle |
+|----------+--------------------------------------------------------------|
+| Related | [[file:2026-07-03-audio-panel-spec.org][audio-panel-spec (parent, IMPLEMENTED)]]; net/bt panels (kin) |
+|----------+--------------------------------------------------------------|
+
+* Summary
+
+The audio panel shows volume and mute per device, but nothing tells you which output or input actually has sound moving through it right now. The "twin VU needles" today are fed by =dev.get("volume")= (=gui.py:522=) — they track the fader, not the signal, so a muted-at-source-but-fader-up device reads full and an actively-playing device at low volume reads low. This spec adds a real signal-presence layer: a cheap activity state (is audio flowing?) driving a three-state lamp, plus a windowed peak meter (is there actual level?) feeding the needles, both keyed per device in the engine so the aggregate v1 view and a future per-device view (option 3) share one metering core.
+
+* Problem / Context
+
+Craig's ask, verbatim: "I would like an indication on the audio panel which outputs or inputs actually have sound coming out of them." The panel (parent spec =2026-07-03-audio-panel-spec.org=, IMPLEMENTED) presents each sink/source with volume + mute and an aggregate INPUT/OUTPUT console-key pair whose lamp is green-when-unmuted / red-when-muted (=panel.py:133= =control_lamp=). Neither surface answers "is this device carrying sound." Two things are conflated today:
+
+- *Mute state* — is the device armed? (Have now.)
+- *Signal presence* — is audio actually flowing / audible through it? (Missing.)
+
+"Which have sound coming out" is really two questions with two mechanisms:
+
+1. *Audio is flowing* (routing level) — the device is =RUNNING= with an uncorked stream. Cheap, already 90% available: =pactl -f json list sinks|sources= returns a per-device =state= field the engine currently drops in =pactl._device()= (=pactl.py:94=). Answers "is this device in use." Won't distinguish a stream playing silence from one making real sound.
+2. *Audible signal* (peak level) — real PCM peak above a floor, read from the sink's =.monitor= source (outputs) or the source directly (inputs). The only thing that truly answers "sound coming out," and what pavucontrol's moving bars show. Costs a live capture stream.
+
+The parent panel already carries the widgets to express both (the INPUT/OUTPUT lamps and the twin VU needles); they just have no signal behind them.
+
+* Goals and Non-Goals
+
+** Goals
+1. A per-device *activity* flag in the engine (device is carrying audio now), derived from the =pactl= =state= field, exposed through the status envelope.
+2. A *three-state* INPUT/OUTPUT lamp: muted, live-idle (unmuted, no signal), live-active (unmuted, audio flowing).
+3. A *windowed peak meter* feeding the twin VU needles from real level on the default sink's monitor and the default source — replacing the current volume-fed needle.
+4. Build the metering *engine keyed by device name* so a later per-device view (option 3) runs the same core over N devices with no engine rework.
+5. Zero cost when the panel is closed — the live meter runs only while the panel is visible.
+
+** Non-Goals
+- *Per-device rows with their own meters* (option 3). The engine is built to support it; the v1 GUI renders aggregate INPUT/OUTPUT only. Deferred to vNext.
+- *Live peak on the bar indicator.* The bar glyph stays state-based (=indicator.py=); a bar that meters continuously would run a capture stream forever. Out of scope.
+- *Per-application / per-stream metering* (which app is making the sound). Device-level only.
+- *Changing volume/mute/default/PTT behavior.* This is an additive read-only display layer.
+- *Configurable meter ballistics beyond a rate + a floor.* No user-facing VU calibration.
+
+** Scope tiers
+- v1: activity flag in engine; three-state aggregate INPUT/OUTPUT lamps; peak-fed twin VU needles for default sink + default source; panel-open-gated metering; per-device-keyed engine.
+- Out of scope: bar-indicator live peak; per-app metering; VU calibration UI.
+- vNext (log to todo.org): per-device rows each with an activity lamp + peak meter (option 3) — a GUI change over the same engine; uncorked-stream refinement of the activity flag (see Decisions).
+
+* Design
+
+** For the user
+
+Open the panel. Each of the INPUT and OUTPUT console keys now carries a lamp with three readings instead of two: dark-red when that side is muted, a dim/idle green when it's live but nothing is playing, and a bright green when audio is actually flowing. The twin VU needles below move with the *real* signal — the OUTPUT needle rises when sound is genuinely coming out of the speakers (not merely because the fader is up), and the INPUT needle rises when the mic is picking up sound. Hold PTT and watch your own voice register on the INPUT needle — a live confirmation the mic works. Close the panel and nothing keeps running.
+
+The distinction is honest: the lamp says "this device is carrying audio"; the needle says "and here's how loud." A device that's RUNNING but playing silence shows a live-active lamp with a flat needle — which is the truth.
+
+** For the implementer
+
+Two independent signals, both per-device, layered onto the existing humble-object stack (=pactl.py= engine → =status.py= envelope → =panel.py= PanelModel → =viewmodel.py= pure helpers → =gui.py= thin view).
+
+*Activity (cheap, no new process).* Extend =pactl._device()= to carry =state= (=entry.get("state")=, normalized lower-case: =running= / =idle= / =suspended= / =None=). It rides through =build_status()= on each device dict already flagged =default=. In =panel.py=, add =control_active(control)= (the control's default device has =state == "running"=) and a three-state =control_lamp3(control)= → =muted= / =live-idle= / =live-active=. =gui.py= maps those to CSS classes on the existing lamp. Fully unit-testable — feed a status dict, assert the lamp.
+
+*Peak (richer, one managed process per metered device).* A new GTK-free =peak.py=: a =PeakReader(device_name, kind)= that spawns a native-rate mono float capture in its own process group (=parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, =start_new_session=True=; for a sink =<dev>= is its monitor resolved from =pactl.list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= as the default-sink fallback, for a source it's the source name), reads each ~40 ms flush on a worker thread, and reduces it to a window-peak in =[0.0, 1.0]=. A =PeakMeter= manager holds readers keyed by device name and kills each reader's process group on stop — v1 starts two (default sink's monitor, default source); option 3 starts N. Pure ballistics (decimate window-peaks to the display rate =AUDIO_METER_RATE=, normalize, attack/decay smoothing, noise floor) live in =viewmodel.peak_fraction()=, replacing the =volume=-fed =vu_fraction()= call at =gui.py:523=. =gui.py= starts the meter when the panel is shown and tears it down from a =close-request= handler on the window and =do_shutdown= on the application — the real close/quit path, since the app has no =hide= signal and quits when its last window closes — pushing peaks to the needle via =GLib.idle_add= (the existing =bg()= worker pattern). The lamp and the needle are independent: the lamp works with no peak stream at all, so a metering failure degrades to activity-only, never to a dead panel.
+
+Integration points (named): =pactl._device()= / =pactl.py:94=; =status.build_status()=; =panel.PanelModel.control_lamp= / =:133=; =viewmodel.vu_fraction= / =:72= and its caller =gui._set_vu= / =:520-523=; the panel =close-request= / =do_shutdown= lifecycle in =gui.py=; =tests/audio/= (fake-binary-on-PATH harness, mirroring the =pactl= fakes).
+
+* Alternatives Considered
+
+** Peak tool: parec vs pw-cat/pw-record vs a PA peak-detect stream
+- Good, because =parec= ships with =pipewire-pulse= (already the whole stack — =pactl= is the same layer), needs no new dependency, and float32 raw output reduces to a peak with trivial code. The classic VU-from-a-pipe approach.
+- Bad, because it's a managed subprocess per device — start/stop discipline matters (orphan =parec= = silent CPU leak).
+- Neutral, because =pw-cat --record= would work equally and =pw-dump= can't stream peaks; the choice is ergonomic, not capability-bound.
+
+** Activity source: state field alone vs state + uncorked sink-input check
+- Good (state alone), because it's one field already in the JSON, zero extra calls, and =RUNNING= is a strong "audio flowing" signal.
+- Bad, because PipeWire lingers a sink in =RUNNING= briefly after playback stops and can show =IDLE= with a corked stream attached — so state alone is slightly coarse.
+- Neutral, because the uncorked =sink-input=/=source-output= cross-check is a strictly-additive refinement (vNext) that doesn't change the v1 shape.
+
+** Metering lifecycle: always-on vs panel-open-only
+- Good (open-only), because it's zero cost when closed and the panel is the only place the meter renders.
+- Bad, because the bar indicator can't show live peak (acceptable — it stays state-based).
+- Neutral, because always-on would only matter if the bar wanted a live meter, which is a non-goal.
+
+** Aggregate v1 vs jump straight to per-device (option 3)
+- Good (aggregate first), because it de-risks the live-metering runtime (does peak read cleanly, is CPU acceptable, does the needle feel right) against one lamp before investing in per-device row layout + hotplug; and the parent panel's "aggregate only" decision stands.
+- Bad, because a second GUI pass is needed later for option 3.
+- Neutral, because the engine is keyed per-device now, so option 3 is GUI-only — no throwaway.
+
+* Decisions [/]
+
+** DONE Meter only while the panel is open — reap on app-quit, not just a hide signal
+CLOSED: [2026-07-06 Mon]
+- Context: a live peak stream is a running =parec= subprocess; leaving it on continuously (e.g. to feed the bar) burns CPU forever. *Round-1 review (code fact):* =gui.py= connects no =show=/=map=/=hide= signals, the window has no =set_hide_on_close(True)=, and the app takes no =hold()= — so closing the window (=do_activate= toggle =gui.py:218=, close button =:280=, Escape/q =:456=) destroys the last window and the =Gtk.Application= quits, exiting the process. Children spawned via =subprocess= are *not* reaped on interpreter exit; they reparent to init and keep running — exactly the orphan-CPU leak this feature names as its top risk.
+- Decision: We will (a) spawn each =parec= in its own process group (=start_new_session=True=) and kill the group on teardown, and (b) tear down from the real lifecycle — a =close-request= handler on the window *and* =do_shutdown= on the application — not a =hide= signal this app doesn't emit. Start metering when the panel is realized/shown. Resolve the sink's monitor from the graph (=pactl.list_sources(include_monitors=True)=, since =list_sources= drops monitors via =_is_monitor= at =pactl.py:83=), falling back to =@DEFAULT_MONITOR@= for the default sink. The bar indicator stays state-based; only the open panel meters.
+- Consequences: easier — near-zero cost when closed, bounded process lifetime, no orphans even on a straight quit; harder — teardown must hook the actual close/quit path, verified by a test that drives a real window-close / app-quit (not a synthetic hide) and asserts no surviving =parec= children.
+
+** DONE Peak via parec — capture at native rate, window in Python to the display rate
+CLOSED: [2026-07-06 Mon]
+- Context: need a per-device level stream; the stack is pipewire-pulse (no PulseAudio daemon), and =pactl= gives no peak. *Round-1 review verified live on ratio (2026-07-06):* =parec --rate= is the *sample* rate, not an update rate — there is no window flag — and at =--rate=25= with default latency parec flushed ~0 bytes in 3s; only an explicit =--latency-msec= produced timely output. parec has no peak-detect resampler (unlike pavucontrol's PA_RESAMPLER_PEAK), so a low =--rate= just decimates/aliases the transients a meter should show.
+- Decision: We will *capture at a real rate with a low flush latency and window in Python*: =parec -d <dev> --format=float32le --channels=1 --rate=44100 --latency-msec=40 --raw=, reading each ~40 ms flush and reducing it to =max(abs(sample))=, then decimating those window-peaks to the *display* rate =AUDIO_METER_RATE= (default 25 Hz). Capture rate and display rate are separate concepts: capture stays native/high for real transients; the needle updates at the display rate. =<dev>= is the sink's monitor for outputs (resolved from the graph — see the metering-lifecycle decision — with =@DEFAULT_MONITOR@= as the default-sink fallback) and the source name for inputs.
+- Consequences: easier — no new dependency, a genuine windowed level, testable with a fake =parec= emitting scripted float windows; harder — the reader owns the capture-vs-display-rate windowing (pure math in =viewmodel=), subprocess management, and a charset/validity guard on the device name before argv (reuse =pactl.valid_name=). This is a *windowed peak/level meter*, not a hardware true-peak meter — the wording elsewhere is tempered to match.
+
+** DONE Activity from the pactl state field (RUNNING = active)
+CLOSED: [2026-07-06 Mon]
+- Context: the three-state lamp needs an "is audio flowing" bit that's cheap and event-cheap.
+- Decision: We will capture the =state= field in =pactl._device()= and treat =running= as active; =idle=/=suspended=/=None= are not active. The uncorked-stream cross-check is deferred to vNext.
+- Consequences: easier — one field, no extra call, the lamp updates on the panel's existing re-reads; harder — a brief RUNNING linger after playback and a corked-stream-on-IDLE case make the lamp slightly coarse (documented; refinement is vNext).
+
+** DONE Engine keyed per device; v1 GUI renders aggregate
+CLOSED: [2026-07-06 Mon]
+- Context: Craig will want per-device metering (option 3) later; the peak primitive is inherently per-device.
+- Decision: We will build the activity flag on every device dict and the =PeakMeter= manager keyed by device name, while the v1 GUI meters only the default sink + default source. Option 3 becomes a GUI-only change.
+- Consequences: easier — no engine rework for option 3, per-device state is unit-testable now; harder — the manager carries multi-reader machinery the v1 view doesn't exercise (kept minimal, exercised by tests).
+
+** DONE Lamp and needle are independent; metering failure degrades gracefully
+CLOSED: [2026-07-06 Mon]
+- Context: the peak path can fail (no =parec=, monitor unavailable, permission) where the cheap activity path still works.
+- Decision: We will drive the three-state lamp from the (cheap, always-available) activity flag and the needle from peak, as independent signals. A peak failure leaves the lamp working and the needle at rest; it never breaks the panel.
+- Consequences: easier — honest partial degradation, matches the panel's verify-everything contract; harder — two code paths to keep decoupled (no shared failure).
+
+* Review findings [4/4]
+
+** DONE parec --rate=25 produces no usable meter stream :blocking:
+Round-1 review verified live on ratio: =parec --rate= is the sample rate, not an update rate, and at 25 with default latency it flushed ~0 bytes; there's no peak resampler. Resolved: the "Peak via parec" decision now captures at native rate with =--latency-msec=40= and windows to the display rate in Python; "true peak meter" tempered to "windowed peak/level meter"; acceptance now demands a live-audio deflection check.
+
+** DONE Orphan parec on app-quit (teardown hooks absent)
+Round-1 review (code fact): =gui.py= emits no show/hide, and the app quits when its last window closes, leaking =subprocess= children. Resolved: the metering-lifecycle decision now spawns each reader in its own process group (=start_new_session=True=), kills the group, and tears down from =close-request= + =do_shutdown= (not a hide); acceptance verifies reap through the real close/quit path.
+
+** DONE Three-state lamp needs the _set_lamp removal set extended
+Round-1 review (code fact): =_set_lamp= (=gui.py:100-104=) removes a hardcoded class list and treats no-class as green, so new lamp classes stack. Resolved: Phase 2 now specifies adding the three classes to the removal set (or a dedicated setter) via a =control_lamp3= render path.
+
+** DONE state is uppercase; monitors filtered from list_sources
+Round-1 review (live fact): =pactl -f json= emits =state= uppercase and =list_sources= drops monitors via =_is_monitor=. Resolved: Phase 1 now =.lower()=s state with uppercase fixtures; monitor resolution uses =list_sources(include_monitors=True)= with =@DEFAULT_MONITOR@= fallback.
+
+* Implementation phases
+
+** Phase 1 — Engine: capture device activity state
+Extend =pactl._device()= to carry a normalized =state= — =pactl -f json= emits it *uppercase* (=RUNNING=/=IDLE=/=SUSPENDED=), so =.lower()= it to =running=/=idle=/=suspended=/=None=; thread it through =status.build_status()= so each sink/source dict exposes it. Pure parser change, TDD against real =pactl -f json= sample shapes (fixtures carry the uppercase inputs so the =.lower()= is exercised). Tree stays working — added field, nothing consumes it yet.
+
+** Phase 2 — PanelModel + GUI: three-state activity lamp
+Add =panel.control_active(control)= and =panel.control_lamp3(control)= (=muted= / =live-idle= / =live-active=); unit-test the state machine. Wire =gui.py= via a three-state render path — the existing =_set_lamp= (=gui.py:100-104=) removes a *hardcoded* class list (=gold/red/off/busy=) and treats no-class as green, so the three new classes must be added to that removal set (or a dedicated lamp-setter) or stale classes stack. Ships the activity indicator (aggregate) with no peak stream involved.
+
+** Phase 3 — Peak engine: per-device peak reader
+New GTK-free =peak.py=: =PeakReader(device_name, kind)= (spawns =parec= at native rate + low =--latency-msec= in its own process group, reads/reduces flush windows, validity-guards the name) and a =PeakMeter= manager keyed by device name (start/stop/read, kills each reader's process group on stop). Pure ballistics in =viewmodel.peak_fraction()= (decimate window-peaks to the display rate, normalize, attack/decay, noise floor). TDD with a fake =parec= on PATH emitting scripted float windows + pure math unit tests, plus a reader-lifecycle test asserting the process group is reaped. Nothing renders it yet — tree works.
+
+** Phase 4 — GUI: live peak needles + lifecycle
+Repoint =gui._set_vu= from =viewmodel.vu_fraction(volume)= to the peak source via =viewmodel.peak_fraction=. Start the =PeakMeter= (default sink's monitor + default source) when the panel is shown; tear it down from a =close-request= handler *and* =do_shutdown= (the real quit path — the app quits when its last window closes), pushing peaks to the needles via =GLib.idle_add=. Update the AT-SPI smoke to assert the meter starts on show and that a window-close/app-quit leaves no =parec= child. Ships windowed signal metering (aggregate).
+
+* Acceptance criteria
+- [ ] Each device dict in =build_status()= carries a =state= of =running=/=idle=/=suspended=/=None=.
+- [ ] INPUT/OUTPUT lamp reads muted when the default device is muted, live-idle when unmuted and not =running=, live-active when unmuted and =running=.
+- [ ] With *real audio playing* to the default sink, the OUTPUT needle deflects and tracks level (manual live check — a resting needle under silence does not confirm this); with the sink silent (fader up, no stream), the needle rests near zero.
+- [ ] With the mic capturing sound, the INPUT needle deflects; muted or silent, it rests.
+- [ ] A window-close *and* an app-quit each leave no =parec= process running (verified through the real close/quit path, not a synthetic hide — the process-group kill reaps children).
+- [ ] A missing/failing =parec= leaves the three-state lamp working and the needle at rest — the panel still opens and functions.
+- [ ] Full dotfiles =make test= green; audio suite grows with new unit tests; AT-SPI smoke passes.
+
+* Readiness dimensions
+- *Data model & ownership:* all signal data is generated/live (never user-authored) — =state= per read, =peak= per window. Owned by the engine; the view renders, never persists. Device identity keyed by stable =name=.
+- *Errors, empty states & failure:* peak-path failure (no =parec=, monitor unavailable, permission) degrades to activity-lamp-only + resting needle, never a broken panel (Decision 5). Empty/absent default device → lamp reads muted/absent (existing =control_lamp= behavior), needle rests. No new user-facing error strings; failures are silent-graceful by design since this is a passive display layer.
+- *Security & privacy:* the peak stream reads amplitude only, never audio content, and nothing is recorded or logged — only a float level reaches the needle. Device names pass the existing =pactl.valid_name= charset guard before hitting =parec= argv. No secrets.
+- *Observability:* the meter *is* the observability surface. A =parec= spawn failure is not surfaced as an error toast (passive layer) but leaves the needle at rest; a debug hook (log the reader lifecycle under the audio package's existing debug gate, if any) is a nice-to-have, not required for v1.
+- *Performance & scale:* one =parec= per metered device, mono, 25 Hz — negligible CPU, and only while the panel is open. v1 meters exactly 2 devices. Option 3 scales to N sinks+sources; the manager caps readers to the visible set. Window reduction is O(samples/window); no per-frame allocation beyond the small buffer.
+- *Reuse & lost opportunities:* reuses the existing VU-needle Cairo widget (=gui.py:160=, shared with net/bt), =pactl.valid_name=, the =bg()= worker pattern, the fake-binary test harness, and the humble-object split. Repoints the already-present but volume-fed needle rather than adding a new widget.
+- *Architecture fit & weak points:* fits the =pactl→status→panel→viewmodel→gui= layering exactly; peak is a new peer engine module (=peak.py=) beside =pactl.py=. Weak point: subprocess lifecycle (orphan =parec=) — mitigated by process-group spawn + =close-request=/=do_shutdown= teardown + a child-reaping test. Second weak point: two independent signal paths — mitigated by keeping them decoupled (Decision 5).
+- *Config surface:* two optional keys with safe defaults — =AUDIO_METER_RATE= (the *display*/needle update rate, default 25 Hz; distinct from the fixed native =parec= capture rate, which is not a user knob) and =AUDIO_METER= on/off (default on; off falls back to activity-lamp-only). Named in the audio package =config.py=. No calibration surface (non-goal).
+- *Documentation plan:* update the audio package README/help to describe the three-state lamp and the signal needles (vs the old volume needle) and the two config keys. No migration doc — additive.
+- *Dev tooling:* existing =make test= / audio suite / AT-SPI smoke cover it; a fake =parec= binary joins the existing fakes. No new make targets needed.
+- *Rollout, compatibility & rollback:* additive display layer, no persisted data or API change. Rollback = set =AUDIO_METER=off= (needle falls back to resting / the prior volume feed can be kept as the off-state fallback) — spelled out at build. Ships to both machines via the dotfiles =common=/hyprland stow path like the rest of the panel.
+- *External APIs & deps:* =parec= (pipewire-pulse) and the =pactl -f json= =state= field are the two external assumptions. =parec= presence verified on ratio at build (it ships with the already-installed pipewire-pulse); the =state= field presence verified against live =pactl -f json list sinks= output in Phase 1 fixtures. No new package install expected — confirm =parec= on ratio before Phase 3, add to archsetup deps only if absent.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Orphan =parec= processes* — the top risk. Mitigation: process-group spawn (=start_new_session=True=), a single =close-request=/=do_shutdown= teardown owner in =gui.py= calling =PeakMeter.stop_all()= (kills each group), and a test asserting no surviving children through the real close/quit path.
+- *Monitor naming edge cases* — a sink whose =.monitor= name doesn't follow =<sink>.monitor= (rare, but bluez/virtual sinks vary). Mitigation: resolve the monitor source from the sinks/sources graph rather than string-appending =.monitor= where possible; fall back to the appended name; degrade to resting needle if the monitor can't be opened.
+- *RUNNING linger / corked-on-IDLE coarseness* — the activity lamp may show live-active a beat after sound stops. Accepted for v1; the uncorked cross-check is the vNext refinement.
+- *Meter ballistics feel* — attack/decay tuning is a taste call; the pure =peak_fraction= math makes it adjustable without touching GTK, and the live feel is Craig's eyeball check (manual-testing task).
+
+* Testing / Verification / Rollout
+Unit: =pactl._device()= state parsing (each state + missing); =panel.control_active/control_lamp3= truth table (muted / live-idle / live-active / absent); =viewmodel.peak_fraction= ballistics (floor, clamp, decay, None); =peak.PeakReader= window→peak reduction and name-guard rejection via a fake =parec=; =PeakMeter= start/stop/reap (no orphan). Integration/smoke: AT-SPI smoke asserts meter starts on show and stops on close. Manual (Craig, filed to todo.org): play audio and confirm the OUTPUT needle tracks real level not fader; speak and confirm the INPUT needle + PTT registration; confirm the lamp's three states live; confirm no CPU when closed. Rollout: per-phase commit+push to dotfiles main, =make test= green gate each phase, note dotfiles at the end (archsetup-owns-dotfiles).
+
+* Review and iteration history
+** 2026-07-06 Mon @ 09:37:52 -0500 — Claude Code (archsetup) — author
+- What: initial draft scoping option 2 (activity + peak metering, per-device-keyed engine, aggregate GUI).
+- Why: Craig asked for an indication of which inputs/outputs actually have sound; the design was settled in the 2026-07-06 session (two signal levels, three-state lamp, engine keyed per device so option 3 is GUI-only).
+- Artifacts: parent spec 2026-07-03-audio-panel-spec.org; engine facts grounded in pactl.py:94, panel.py:133, viewmodel.py:72, gui.py:520-523; audio suite green baseline 124 tests.
+
+** 2026-07-06 Mon @ 09:52:00 -0500 — Claude Code (archsetup) — reviewer
+- What: round-1 spec-review with live verification on ratio. One blocking finding (parec --rate is sample-rate not update-rate; no peak resampler; flushes nothing at rate 25 without --latency-msec) and three non-blocking (orphan parec on app-quit since gui has no hide signal; _set_lamp hardcoded class-removal list; uppercase state + monitors filtered from list_sources). Rubric: Not-ready pending the parec-command fix. Every other code reference confirmed accurate.
+- Why: the peak half of the feature rested on an incorrect external assumption verified false live; the teardown risk the spec named as top-risk had no concrete hook in the real lifecycle.
+- Artifacts: findings recorded in * Review findings.
+
+** 2026-07-06 Mon @ 09:54:00 -0500 — Claude Code (archsetup) — responder
+- What: accepted all four findings and folded them in — rewrote the parec decision (native capture + --latency-msec + Python windowing, display/capture rates separated), the metering-lifecycle decision (process-group spawn + close-request/do_shutdown teardown + graph-resolved monitor with @DEFAULT_MONITOR@ fallback), Phase 1 (.lower() uppercase state), Phase 2 (_set_lamp removal set), Phase 3/4 (command + real-quit teardown), acceptance (live deflection + real-quit reap), and config (display vs capture rate).
+- Why: close the blocking gap and the three code-accuracy gaps before a re-review.
+- Artifacts: * Review findings all DONE; Decisions updated.
+
+** 2026-07-06 Mon @ 10:15:00 -0500 — Claude Code (archsetup) — reviewer
+- What: round-2 re-review of the folded spec. Confirmed the parec fix (native 44100 + --latency-msec=40 → ~25 workable window-peaks/sec), the process-group + close-request/do_shutdown teardown, the _set_lamp removal-set fix, and the uppercase-state/.lower() + @DEFAULT_MONITOR@ fixes. Rubric: Ready, no remaining blockers; one cosmetic wording note (stale "show/hide"/"true peak" in Goals/Risks) cleaned in the same pass. Flipped DRAFT→READY→DOING.
+- Why: verify the fixes resolve the round-1 findings before build.
+- Artifacts: review verdict; spec cosmetic cleanup.
+
+** 2026-07-06 Mon @ 10:40:00 -0500 — Claude Code (archsetup) — implementer
+- What: built all four phases TDD in the dotfiles audio/ package and flipped the spec IMPLEMENTED. Phase 1 pactl state capture (6054d3d); Phase 2 three-state activity lamp + viewmodel.lamp3_class + CSS (174ce14); Phase 3 peak.py windowed-peak engine + viewmodel.peak_fraction + fake-parec harness (55ab9f9); Phase 4 live VU needles + PeakMeter lifecycle + config keys + smoke orphan-check (21437b4). Audio suite 124→161; full make test green (49 suites); no-orphan reaping unit-tested.
+- Why: complete the no-approvals speedrun through to shipped code.
+- Artifacts: dotfiles commits 6054d3d..21437b4; manual-testing task filed in archsetup todo.org.