Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -7,7 +7,7 @@ with Gabriel's go-ahead**, and failures get fixed then.
|
||||
|
||||
## The run
|
||||
|
||||
- `panama test` — the full suite (**166** contracts as of the phase 4 Shell
|
||||
- `panama test` — the full suite (**169** contracts as of the phase 6 Sound
|
||||
wave; the top-level README's count line is set to match and is itself
|
||||
checked by `setup/readme-contract`).
|
||||
|
||||
@@ -249,3 +249,99 @@ re-checked against their files as those landed:
|
||||
then `display-arrangement-contract`, and `displays-contract` last — it is the
|
||||
only one that drives the physical display, and it refuses to start from a
|
||||
scale that does not match what `monitors.lua` ships.
|
||||
|
||||
## Phase 6 (Sound) — append below
|
||||
|
||||
Spec: `2026-08-24-sound-redesign.md`. The Sound page became a complete PipeWire
|
||||
surface: honest device list with badges and a ghost row, per-channel speaker
|
||||
test, microphone test, per-app mic mute and per-app output routing, alert-sound
|
||||
theme, over-amplification, and native device profiles in place of the GNOME
|
||||
handoff.
|
||||
|
||||
**Nothing in this wave was run.** Three agents were editing the tree
|
||||
concurrently and every harness here drives the live audio graph — the page
|
||||
harness constructs the real Sound page against the session's own PipeWire, and
|
||||
the services harness starts a shell. What *was* verified is listed as static
|
||||
below: bash syntax on every contract, the JSON and pw-metadata fixtures parsed,
|
||||
and each service's own parsing logic replayed in node or Python against the
|
||||
landed source so the expected values in the assertions are the values the code
|
||||
actually produces.
|
||||
|
||||
### New contracts (3)
|
||||
|
||||
| Contract | What it pins | Verified |
|
||||
|---|---|---|
|
||||
| `quickshell/sound-cards-contract` | `SoundCards`' parse of `pactl -f json list cards`: profiles keyed by name becoming an ordered list sorted by pactl priority (the fixture deliberately lists them out of order, so insertion order fails), a profile pactl marked unavailable kept and flagged rather than dropped, `device.description` as the card name, the port hint naming the connected port and reading exactly `No port connected` when none is, `set-card-profile` argv, an empty card or profile name starting no write, unparseable output and a failing read both degrading into `lastError` with an array still in `cards`, and recovery on the next refresh. Plus the static grep on the live `-f json` command, which the fixture seam means nothing else exercises. | **Statically verified**: `SoundCards.parse` and `portHintFor` replayed in Python against the fixture — profile order, availability flags, descriptions and both port hints match the assertions exactly. Every IPC call is **deferred**. |
|
||||
| `quickshell/sound-routing-contract` | `SoundRouting`: every stream of a group moved, by `object.serial` and not by node id (both are plausible numbers in a log); a serial-less stream falling back to its node id; an empty group or unnamed sink running nothing; and `routeToDefault` releasing the pin through `pw-metadata -n default -d <node-id> target.object` **and** the legacy `target.node`, never through `move-sink-input` — moving a stream to the current default pins it there, which is the bug the button undoes. Plus the id asymmetry both ways, `busy`/`lastError` on refusal, recovery, and the spec-required comment above `routeToDefault`. | **Statically verified** against the landed service: the argv shapes, the serial fallback, the two metadata keys and the early returns all read directly off `SoundRouting.qml`. The runs are **deferred**. |
|
||||
| `quickshell/sound-defaults-contract` | The one distinction the ghost row depends on: `default.configured.audio.sink` and not `default.audio.sink`. The fixture sets them to different values and writes the configured one first, so neither "last key wins" nor a machine whose configured device is present can make it pass by accident. Also: an unconfigured session reading as `""` rather than as the effective device, an empty store, a non-JSON value on an unrelated key not taking the read down with it, a failed read leaving nothing invented, recovery, the ghost record and its label (Bluetooth address, AirPlay hostname, USB product string, empty), and a static ban on reading `preferredDefaultAudioSink`, which is null in exactly the case the service exists for. | **Statically verified**: `SoundDefaults.parse` and `label` evaluated in node against all five fixtures — every expected string in the contract came from that run. The IPC half is **deferred**. |
|
||||
|
||||
### Updated contracts (4)
|
||||
|
||||
| Contract | What it now pins | Verified |
|
||||
|---|---|---|
|
||||
| `quickshell/sound-page-contract` | Rebuilt around the new page. Kept: the two `SoundDeviceList`s, the Dictation negatives and handoff, the balance and device-row pins, the Quick Settings sharing. Dropped: `openGnomePanel("sound")` and `label: "Device profiles"` — both now asserted **absent**, replaced by the native profile card (`title:`, `SoundCards.cards`, `setProfile(`, `refresh()`, `lastError`). Added: `captureApplications` filtered by `AudioInStream` and reaching `SoundCaptureRow` with a per-app mute, a tracked `PwObjectTracker` over capture nodes, the row hiding itself when nothing is listening; the ghost row asked of `SoundDefaults.absent(root.output)`, rendering after the `Repeater`, and non-interactive by a brace-scan proving every `TapHandler`/`HoverHandler` carries `!root.ghost`; the `device.api` badges; over-amplification gated at 1.5 on the page and on Quick Settings' **output** slider only, with the >100% region marked; the four `SoundRouting` calls in `ApplicationVolumeRow`. The no-shell-out ban is unchanged on the four core files and now extended over all eleven page components. | **The whole static half was run** against the landed tree and passes. The harness half is **deferred**. |
|
||||
| `quickshell/application-volume-contract` | Grouping pins unchanged; new `clampVolume` case for the `max` parameter — `setVolume(group, 1.4, 1.5)` lands 1.4 and unmutes, 2.5 clamps to 1.5, **no** max clamps to 1 (the default must not quietly follow the preference), a negative clamps to 0, and a non-numeric value changes nothing and returns false. | **Statically verified**: the landed `AudioStreams.js` evaluated in node produced byte-identical output to the `jq` filter's expectations. The IPC run is **deferred**. |
|
||||
| `quickshell/osd-helper-contract` | Over-amplification and the blip, both out of `settings.json`. Every run now gets its own `XDG_CONFIG_HOME`, because the helper would otherwise read Gabriel's real preferences and pass or fail on which switches he has on. Pins `-l 1.5` on volume up **and down** (wpctl clamps the result, so coming down from 130% would snap to 100% without it), `-l 1` on the microphone in the same run, `-l 1` for off / key absent / file absent / malformed JSON, the blip on up/down/toggle and not on brightness or microphone, silence with `volumeChangeBlip` false, and silence with no sound file — with the OSD still shown in every degraded case. | **Bash syntax only.** The helper is agent A's and had not landed when this was written; see the open item below. |
|
||||
|
||||
### Cross-agent shapes these contracts pin
|
||||
|
||||
Written from the spec's pinned API while agents A and B worked in parallel, then
|
||||
re-checked against their files as those landed:
|
||||
|
||||
- `PANAMA_SOUND_CARDS_FIXTURE` and `PANAMA_SOUND_DEFAULTS_FIXTURE` are **file
|
||||
paths** the service `cat`s in place of the live command, read once at
|
||||
singleton construction. So a contract changes what the file *says* between
|
||||
cases rather than where it points, and deletes it to make a read fail.
|
||||
Confirmed against both landed services.
|
||||
- `sound-services-harness.qml` is new and shared by all three service
|
||||
contracts. Whichever contract is running sets an inert fixture for the two
|
||||
services it is not testing, so nothing reaches the live daemon and every line
|
||||
in a command log belongs to the service under test.
|
||||
- `SoundDeviceList` asks `SoundDefaults.absent(output)` rather than comparing
|
||||
configured names itself — the service owns both the comparison and the label.
|
||||
The contract followed B's refactor to that shape.
|
||||
- The ghost label is A's `SoundDefaults.label()`, so it reads
|
||||
`Bluetooth device (AA:BB:CC:11:22:33)` **with** parentheses. `SoundDeviceList`
|
||||
briefly had its own `tidyName()` producing the same string without them; the
|
||||
harness and the assertion track the service's version.
|
||||
|
||||
### Docs updated in the same wave
|
||||
|
||||
- `services/SettingsSearch.qml` — the seven hand-written Sound entries from the
|
||||
spec (Balance, Speaker test, Microphone test, Alert sound, Applications using
|
||||
the microphone, Move an application's audio, Device profiles) on top of the
|
||||
four that were already there, and `groupPages` gained `"sound": "sound"` so
|
||||
the new schema group self-indexes.
|
||||
- Top-level `README.md` — contract count 166 → 169, recounted with the same
|
||||
`find` `setup/readme-contract` uses; that contract was run and passes.
|
||||
|
||||
### Still open before the run
|
||||
|
||||
- **`osd-helper-contract` is written against a `panama-osd` that had not
|
||||
landed.** It assumes two things of agent A's helper: that the settings file
|
||||
is resolved as `${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json` (the
|
||||
`panama-idle` / `panama-lid` spelling, not `panama-palette`'s
|
||||
`PANAMA_SETTINGS` override), and that the blip's sound file can be overridden
|
||||
with `PANAMA_OSD_BLIP_SOUND` (matching the existing `PANAMA_OSD_*` seams in
|
||||
the same script), which is the only way to exercise the missing-file branch
|
||||
deterministically. If A spelled either differently, the env names in
|
||||
`run_helper` are the only lines that need changing. Reconcile before running.
|
||||
- The contract also now asserts `-l` on the **down** step, which the
|
||||
pre-redesign helper did not pass. The reasoning is in the contract; if the
|
||||
landed helper only limits the up step, that is a real bug at 150% and not a
|
||||
contract to relax.
|
||||
- `sound-page-contract`'s runtime half asserts `.captureApplications >= 0` and
|
||||
`.captureRowVisible == (.captureApplications > 0)` — true on a machine where
|
||||
nothing is recording, which is the ordinary case. Getting a positive capture
|
||||
count under test would mean holding the microphone open from the contract;
|
||||
the grouping itself is covered by `captureTypesValid` and by the static greps.
|
||||
- No contract in this wave has had its harness started. Run order for the
|
||||
sweep: `sound-defaults-contract`, `sound-cards-contract`,
|
||||
`sound-routing-contract` (all three fixture-fed and cheap), then
|
||||
`application-volume-contract`, then `osd-helper-contract`, and
|
||||
`sound-page-contract` last — it is the only one that constructs the real page
|
||||
against the session's own audio graph.
|
||||
- Nothing in this wave plays a sound on purpose, but `sound-page-contract`
|
||||
constructs `SoundPage`, whose microphone test and channel strip are one
|
||||
IPC-less click away from `pw-play`. The harness exposes no method that
|
||||
triggers either; keep it that way.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Sound redesign — one page, whole story
|
||||
|
||||
Approved mock: `home-mocks/sound.html` (scratchpad, :8642). This spec is the implementation
|
||||
contract; where the mock and this file disagree, this file wins. The page stays a single sectioned
|
||||
scroll (no tabs). Everything is live PipeWire — nothing to apply or confirm.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Fix the Test/Use overflow** — `SoundDeviceRow.qml`'s heading Column subtracts `useButton.width`
|
||||
but not the Test button's width nor the inner Row's spacing, so output rows overflow the card by
|
||||
`testWidth + 7` px. The rebuilt row layout must measure both buttons (no hardcoded 78/64 widths;
|
||||
let implicitWidth speak).
|
||||
2. **Honest device list** — badges and ghost rows so the list tells the whole story.
|
||||
3. **Complete the page** — per-channel test, mic test, mic-using apps, per-app routing, alert
|
||||
sounds Panama's own notifications obey, over-amplification, native device profiles.
|
||||
|
||||
Non-goals: alert-sound *volume* (no real channel; deliberately dropped), mono output, sample-rate
|
||||
switching, EasyEffects/noise suppression, input-side balance.
|
||||
|
||||
## Constraints that stand
|
||||
|
||||
- `sound-page-contract` bans `Process|pactl|wpctl` in **AudioDevices / SoundDeviceList /
|
||||
SoundDeviceRow / AudioBalance**. Anything that shells out lives in a sibling singleton service
|
||||
(the SoundTest/SoundFeedback precedent). Update the contract only where the spec says so.
|
||||
- Quick Settings keeps sharing `AudioDevices` (outputs/inputs/select).
|
||||
- No continuously repainting animations. The input level meter uses `PwNodePeakMonitor` (native,
|
||||
event-driven); it renders only while the Input section is on screen and the device is selected.
|
||||
|
||||
## Service API (pinned — UI programs against this)
|
||||
|
||||
**`AudioDevices.qml`** (native-only, extended):
|
||||
- `captureApplications` — mic-using apps: `AudioStreams.group(captureStreams, PwNodeType.AudioInStream)`
|
||||
where `captureStreams` filters `ready && audio && (type & PwNodeType.AudioInStream)`.
|
||||
- `applicationVolume/Muted` + setters already exist and work for both groups (AudioStreams.js is
|
||||
group-shape agnostic).
|
||||
- Device badges are UI-side, native: `node.properties["device.api"]` — `"raop"` → AirPlay badge,
|
||||
`"bluez5"` → Bluetooth badge. Subtitle rate/format from `node.properties["audio.rate"]` /
|
||||
format keys when present; omit gracefully when absent.
|
||||
|
||||
**`SoundDefaults.qml`** (new singleton, shells out): the configured-vs-effective story.
|
||||
- `configuredSinkName` / `configuredSourceName` — from `pw-metadata -n default 0` (or `pw-dump`
|
||||
fallback; agent picks the stable one), refreshed on `Pipewire.defaultAudioSink/Source` changes
|
||||
and on a page-open `refresh()`.
|
||||
- The ghost row renders when the configured name maps to **no present node**; label from the
|
||||
stored name (e.g. `bluez_output.74_15_F5_13_A4_28.1` → cleaned to the stored description when
|
||||
pw-metadata carries one, else a tidied name). Ghost rows are non-interactive; badge text
|
||||
"Returns when connected".
|
||||
|
||||
**`SoundCards.qml`** (new singleton, shells out): native device profiles.
|
||||
- `cards: [{ name, description, activeProfile, profiles: [{ name, description, available }],
|
||||
portHint }]` from `pactl -f json list cards` (portHint: best human line from port availability,
|
||||
e.g. "Line out connected" / "No port connected").
|
||||
- `setProfile(cardName, profileName)` → `pactl set-card-profile`; `busy`, `lastError`,
|
||||
`refresh()`. Refresh on page open (the Displays `Brightness.refresh()` pattern).
|
||||
|
||||
**`SoundRouting.qml`** (new singleton, shells out): per-app output routing.
|
||||
- `moveApplication(group, sinkName)` — `pactl move-sink-input <serial> <sink>` for every stream
|
||||
in the group (stream serial from `node.properties["object.serial"]`, fallback `node.id`).
|
||||
- `routeToDefault(group)` — return the app to following the system default (agent determines the
|
||||
reliable mechanism: `pw-metadata` target clear, or move to the current default sink — document
|
||||
which in a comment).
|
||||
- `currentSinkFor(group)` — name of the sink the group's first stream is linked to, `""` when it
|
||||
follows the default. Derived natively where possible (`Pipewire.linkGroups` is readable) —
|
||||
shells out only if link groups prove insufficient.
|
||||
- `busy`, `lastError`.
|
||||
|
||||
**`SoundTest.qml`** (extended, keeps its no-race separation):
|
||||
- `playChannel(node, channelName)` — per-channel freedesktop samples
|
||||
(`audio-channel-front-left.oga`, `-front-right`, `-front-center`, `-rear-left`, `-rear-right`,
|
||||
`-side-left`, `-side-right` as they exist on disk), `pw-play --target <node.name>`.
|
||||
- `channelsFor(node)` — ordered `[{ name, label }]` from `node.audio.channels` via
|
||||
`PwAudioChannel`; stereo → Front Left / Front Right.
|
||||
- `playingChannel` — for the lit chip.
|
||||
- Mic test: `startMicTest(sourceNode, sinkNode)` → `pw-record` ~3 s to a file under
|
||||
`$XDG_RUNTIME_DIR`, then `pw-play` it back; `micTestState`: `"idle" | "recording" | "playing"`;
|
||||
`cancelMicTest()`. The temp file is overwritten each run, never accumulated.
|
||||
|
||||
**`SoundFeedback.qml`** (extended):
|
||||
- Existing `eventSounds` / `inputFeedback` unchanged.
|
||||
- `soundTheme` / `setSoundTheme(name)` — gsettings `org.gnome.desktop.sound theme-name`.
|
||||
- `themes` — installed themes scanned from `/usr/share/sounds/*/index.theme` (name + directory).
|
||||
- `previewAlert()` — plays the current theme's bell (`bell.oga`, fallback to freedesktop's) via
|
||||
the SoundTest pw-play pattern, through the default sink.
|
||||
|
||||
**`Notifs.qml`**: when a notification popup is shown and `SoundFeedback.eventSounds` is true,
|
||||
play the current theme's bell via a dedicated Process (`pw-play`), throttled so a burst of
|
||||
notifications plays at most one sound per second. Low-urgency notifications stay silent.
|
||||
|
||||
**Schema** — new group `sound` (first audio keys in the schema; comments above entry braces):
|
||||
- `overAmplification` bool, def false, label "Over-amplification", detail per mock ("Lets the
|
||||
volume slider go to 150% — louder, at the cost of distortion on some hardware").
|
||||
- `volumeChangeBlip` bool, def true, label "Volume-change blip", detail per mock.
|
||||
`SettingsSearch.groupPages` gains `"sound": "sound"`.
|
||||
|
||||
**Over-amplification plumbing** (max is **1.5**, everywhere gated on the setting):
|
||||
- `AudioStreams.setVolume(nodes, volume, max)` — clamp bound becomes a parameter (pure JS stays
|
||||
Settings-free); callers pass `Settings.overAmplification ? 1.5 : 1`.
|
||||
- Output volume sliders (SoundPage, quicksettings `AudioSlider` for the sink only — the mic
|
||||
slider stays 0–1) get `max` 1.5 when enabled, with the >100% region visually marked.
|
||||
- `scripts/panama-osd`: volume up/down uses `-l 1.5` when `overAmplification` is true in
|
||||
`~/.config/panama/settings.json` (read with jq, tolerate a missing key/file).
|
||||
|
||||
**Volume blip**: `panama-osd` volume up/down/toggle plays
|
||||
`/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga` (pw-play, fire-and-forget,
|
||||
skipped when the file is missing) when `volumeChangeBlip` is true in settings.json.
|
||||
|
||||
## Page layout (top to bottom)
|
||||
|
||||
`SoundPage.qml` rebuilt. Lede: "Live PipeWire — every change lands immediately, nothing to apply."
|
||||
|
||||
1. **Output card** — h2 with right-aligned current device name. Device rows (icon, name +
|
||||
badges, subtitle, trailing control, radio): Test button on the selected output only; clicking
|
||||
Test unfolds a **channel-chip strip** under that row (one chip per channel from
|
||||
`SoundTest.channelsFor`, lit while `playingChannel` matches). AirPlay/Bluetooth badges. The
|
||||
**ghost row** (configured-but-absent default) renders last, non-interactive, dimmed.
|
||||
Below the list: Volume slider, Balance (existing AudioBalance), Over-amplification toggle.
|
||||
2. **Input card** — device rows with the live level meter on the selected row (existing
|
||||
`PwNodePeakMonitor`, widened to a proper meter with a warm→red tip). Input volume slider.
|
||||
"Test your microphone" ActionRow → `startMicTest`, button label walks
|
||||
Record & play back → Recording… → Playing back…. **Using the microphone** row: chips per
|
||||
`captureApplications` group with a per-app mic mute; row hidden when empty.
|
||||
3. **Applications card** — existing mixer rows extended with a per-app **output picker**
|
||||
(System default + each present sink; `SoundRouting`). Keep the empty-state copy
|
||||
"Applications playing sound will appear here" (contract-pinned).
|
||||
4. **Alerts & feedback section** — Alerts card: Alert sound (theme dropdown + Preview),
|
||||
Event sounds (detail now says "…and Panama's notification chime"), Volume-change blip,
|
||||
Input feedback.
|
||||
5. **Advanced section** — Device profiles card from `SoundCards` (name, portHint subtitle,
|
||||
profile dropdown per card). Replaces the "Open panel" GNOME punt — `openGnomePanel("sound")`
|
||||
leaves this page.
|
||||
6. Error surfaces: each shelling service's `lastError` degrades its own card's subtitle, the
|
||||
existing pattern.
|
||||
|
||||
## Search & docs
|
||||
|
||||
- Hand-written entries (page "sound"): Balance, Speaker test, Microphone test, Alert sound,
|
||||
Applications using the microphone, Move an application's audio, Device profiles. Keep the four
|
||||
existing ones. Schema group `sound` self-indexes via groupPages.
|
||||
- README: no structural change needed beyond keeping the contract count line accurate.
|
||||
- Settings docs and launcher commands regenerate after the schema lands (orchestrator does this
|
||||
in the audit pass).
|
||||
|
||||
## Contracts (write, do NOT run)
|
||||
|
||||
- `sound-page-contract`: update pinned strings for the rebuilt page (the "exactly two
|
||||
SoundDeviceList" pin may change if the list moves inline — pin whatever the final structure
|
||||
is), keep the no-shell-out ban on the four core files, extend it to assert the new services
|
||||
are the only Process users, keep the Dictation negatives, extend the harness assertions for
|
||||
captureApplications and the ghost-row logic (static where possible).
|
||||
- `application-volume-contract`: unchanged grouping pins; add clamp-parameter coverage
|
||||
(`setVolume(nodes, v, 1.5)` clamps to 1.5, default max stays 1).
|
||||
- New contracts for SoundCards/SoundRouting/SoundDefaults parsing (feed them canned
|
||||
pactl/pw-metadata JSON via a test seam env var, the panama-brightness pattern).
|
||||
- `osd-helper-contract`: extend for the blip and the 1.5 limit (settings.json fixtures).
|
||||
- Everything lands in the test-backlog sweep list; nothing runs now.
|
||||
|
||||
## Agent ownership (parallel)
|
||||
|
||||
- **A — services & plumbing**: `services/AudioDevices.qml`, `services/AudioStreams.js`,
|
||||
`services/SoundTest.qml`, `services/SoundFeedback.qml`, new `services/SoundDefaults.qml`,
|
||||
`services/SoundCards.qml`, `services/SoundRouting.qml`, `services/Notifs.qml`,
|
||||
`scripts/panama-osd`, `config/PreferenceSchema.qml` (sound group), services qmldir if one
|
||||
exists for `qs.services`.
|
||||
- **B — UI**: `modules/settings/SoundPage.qml`, `SoundDeviceList.qml`, `SoundDeviceRow.qml`,
|
||||
`AudioBalance.qml`, `ApplicationMixer.qml`, `ApplicationVolumeRow.qml`, new components in
|
||||
`modules/settings/` (+ their qmldir lines), `modules/quicksettings/AudioSlider.qml` (over-amp
|
||||
max only).
|
||||
- **C — periphery**: `services/SettingsSearch.qml`, `tests/quickshell/sound-page-contract`,
|
||||
`application-volume-contract`, `osd-helper-contract`, new contracts + harness fixtures,
|
||||
test-backlog spec, README count line if it changes.
|
||||
|
||||
B programs against the pinned API above; A must not change it without updating this spec.
|
||||
Reference in New Issue
Block a user