11 KiB
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
- Fix the Test/Use overflow —
SoundDeviceRow.qml's heading Column subtractsuseButton.widthbut not the Test button's width nor the inner Row's spacing, so output rows overflow the card bytestWidth + 7px. The rebuilt row layout must measure both buttons (no hardcoded 78/64 widths; let implicitWidth speak). - Honest device list — badges and ghost rows so the list tells the whole story.
- 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-contractbansProcess|pactl|wpctlin 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)wherecaptureStreamsfiltersready && 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 fromnode.properties["audio.rate"]/ format keys when present; omit gracefully when absent.
SoundDefaults.qml (new singleton, shells out): the configured-vs-effective story.
configuredSinkName/configuredSourceName— frompw-metadata -n default 0(orpw-dumpfallback; agent picks the stable one), refreshed onPipewire.defaultAudioSink/Sourcechanges and on a page-openrefresh().- 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 }]frompactl -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 DisplaysBrightness.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 fromnode.properties["object.serial"], fallbacknode.id).routeToDefault(group)— return the app to following the system default (agent determines the reliable mechanism:pw-metadatatarget 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.linkGroupsis 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-rightas they exist on disk),pw-play --target <node.name>.channelsFor(node)— ordered[{ name, label }]fromnode.audio.channelsviaPwAudioChannel; 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, thenpw-playit back;micTestState:"idle" | "recording" | "playing";cancelMicTest(). The temp file is overwritten each run, never accumulated.
SoundFeedback.qml (extended):
- Existing
eventSounds/inputFeedbackunchanged. soundTheme/setSoundTheme(name)— gsettingsorg.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):
overAmplificationbool, def false, label "Over-amplification", detail per mock ("Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware").volumeChangeBlipbool, def true, label "Volume-change blip", detail per mock.SettingsSearch.groupPagesgains"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 passSettings.overAmplification ? 1.5 : 1.- Output volume sliders (SoundPage, quicksettings
AudioSliderfor the sink only — the mic slider stays 0–1) getmax1.5 when enabled, with the >100% region visually marked. scripts/panama-osd: volume up/down uses-l 1.5whenoverAmplificationis 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."
- 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 whileplayingChannelmatches). 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. - 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 percaptureApplicationsgroup with a per-app mic mute; row hidden when empty. - 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). - 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.
- 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. - Error surfaces: each shelling service's
lastErrordegrades 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
soundself-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, newservices/SoundDefaults.qml,services/SoundCards.qml,services/SoundRouting.qml,services/Notifs.qml,scripts/panama-osd,config/PreferenceSchema.qml(sound group), services qmldir if one exists forqs.services. - B — UI:
modules/settings/SoundPage.qml,SoundDeviceList.qml,SoundDeviceRow.qml,AudioBalance.qml,ApplicationMixer.qml,ApplicationVolumeRow.qml, new components inmodules/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.