Files
Panama/docs/superpowers/specs/2026-08-24-sound-redesign.md

11 KiB
Raw Permalink Blame History

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 overflowSoundDeviceRow.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 01) 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.