173 lines
11 KiB
Markdown
173 lines
11 KiB
Markdown
# 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.
|