# 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.
