Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/39-audio-levels-as-state.md
2026-08-10 17:00:23 -04:00

9.8 KiB

TASK 39 — Audio levels as tracked state, and sound roles

Status: open-policy, reconciled 2026-08-07. Audio transport works and WirePlumber persistence is verified. What remains is roles, defaults, lock-tier behaviour, and state-driven profile intent — not microphone bring-up and not another persistence layer. Repo: souveraine (sessiond + surfaces), Pixel3Arch.

What Casey asked

"we now are monitoring / tracking the brightness before shutdown/reboots but we should probably add sound levels to that; or at least consider the approaches later on how we might separate some notification level sounds vs others. Frankly most times I think the phone should be expected to have audio up to around 70%; but I may be errant."

Measured 2026-07-28 — WirePlumber persistence is NOT the bug

Step 1 below is done, and it came back clean. Do not build a second persistence layer.

~/.local/state/wireplumber/default-routes carries the speaker route's volume:

alsa_card.platform-sound:output:...Speaker={"mute":false, ...
  "channelVolumes":[0.118778, 0.118778]}

0.118778 = 0.49³, and wpctl get-volume @DEFAULT_AUDIO_SINK@ reports exactly 0.49 — wpctl shows the cubic-mapped value, the state file stores linear. They agree.

Persistence was tested directly rather than inferred: set the sink to 0.55, and within 6 s default-routes was rewritten with channelVolumes":[0.166378 (= 0.55³). Restored to 0.49 cleanly. WirePlumber 0.5.15, restore-device working through default-routes (there is no separate restore-device file in this version — that naming is older).

So "sound seems pinned on boot" is not a failure to save. It is WirePlumber faithfully restoring 0.49 every boot, which is simply lower than the ~70% Casey expects. That makes this entirely a defaults and roles question — which is what the rest of this file already says the real question is — and removes the one scenario that would have justified sessiond owning the value.

Persistence guardrail

The WirePlumber check is complete. If restoration regresses, diagnose its state file and endpoint identity before adding ownership elsewhere. A second persistence layer would create two writers for one value; sessiond does not need to own volume on current evidence.

The real question is not persistence, it is the stream model

"Sound level" is not one number, and choosing to persist it as one number bakes in a model we would then have to undo. Every phone platform splits audio by role, because a single slider cannot express the thing people actually want — ringer loud while media is quiet, or media loud while notifications stay out of the way. Android's stream split (music / notification / ring / alarm / voice call) is the reference, the same way it was for TASK-37.

PipeWire already carries the mechanism: per-stream volumes plus media.role / media.category on the stream. What is missing is our policy — which roles exist on this device, what each defaults to, and which ones a volume gesture actually moves.

Casey's "notification level sounds vs others" is exactly this split, and it is the part worth designing. The persistence falls out of it: you restore per role, not one master.

This touches AUDIO-PRIVACY, not just ergonomics

Which sounds are audible while the screen is locked is not a volume question, it is the same tier question as visual redaction: a notification that speaks its contents aloud on a locked phone leaks exactly what the lockscreen redaction rules exist to prevent. Any role model here has to state what each role does at each lock tier, or we will have solved audibility and reopened the leak we already closed on the display side.

On the 70%

Worth measuring rather than adopting. Two reasons to be careful:

  • Volume scales are perceptual, not linear — "70%" on a slider is not 70% of loudness, and the mapping differs between the PipeWire volume, the UI slider, and the codec's own gain. A number picked in one of those three does not mean the same thing in the others.
  • The relevant default is probably per role, not global. A 70% that is right for media is very likely wrong for a ringtone that has to be heard from another room.

Casey flagged he may be errant here, and the honest answer is that the number should come out of the role model, not go into it.

Remaining shape

  1. Define the roles this device has and each one's default and lock-tier behaviour.
  2. Keep WirePlumber as value owner unless a concrete device-state reason proves otherwise; volume is preference, not the security-relevant state the state machine was built for.
  3. Define state-driven profile intent for media, calls, speakerphone, and a future headset so a graph restart cannot choose by priority alone.
  4. Make volume gestures and the settings UI move roles, not one master number.

Connects to

AUDIO-PRIVACY.md (the lock-tier half), SETTINGS-AUTHORITY.md, TASK-08 (device state manager — and the question of whether audio belongs in it at all), TASK-19 (settings/control centre — where roles would surface).

Profile selection belongs in the state machine (raised 2026-08-01, Casey)

The card sat in Voice Call (Earpiece, Mic) instead of HiFi, so every sound came out of the earpiece rather than the Both CS35L36 amps, speakerphone port, and it read as "the speaker got weaker". A pactl set-card-profile ... HiFi restores it and wireplumber persists the choice — but that is a manual poke, and the profile got there because a wireplumber restart re-ran selection with no notion of what the device was for at that moment.

Casey's framing: the device has to know when a profile is needed and carry a proper classification for them, the way Android and iOS do — in-call vs media vs speakerphone vs headset, with the routing following the classification rather than whatever priority number happened to win. That is state-machine work and belongs with audio-as-state, not a udev rule or a login hook.

The stack is settled: PipeWire + WirePlumber own runtime audio and pipewire-pulse provides the compatibility API. The uninstalled pkgs/pipewire-abi-provider package records a retired native-Pulse experiment; it must not drive current policy. See AUDIO-PRIVACY.md for the authority map.

False diagnosis retired — 2026-08-02

HiFi capture is not structurally silent and does not require a Q6 voice session. With the stale dual channel map cleared, the normal DMIC4 → DEC5/TX5 → SLIMBUS_0_TX → hw:0,1 path produced 235520 frames, 204136 nonzero, peak 32726. Speaker and microphone both work in HiFi.

The failed measurements had left TX5 and TX7 mapped into the mono capture front-end at once. The UCM transition must leave exactly one capture leg and must dismantle the outgoing route. PAF/audio.md retains the lifecycle evidence; Git retains the discarded branch survey. Neither belongs in this policy task.

FIXED — the in-call speaker button (2026-08-02)

Casey, observed: an inbound call defaulted to speakerphone, and tapping the speaker button (to turn on speakerphone) switched to headset mode.

Root cause. gnome-calls' speaker button calls org.mobian_project.CallAudio, a name owned by upstream callaudiod (extra 0.1.99-3, D-Bus activated). callaudiod moves audio by switching a sink port (cad-pulse.c, set_output_port), which needs one sink carrying both an earpiece and a speaker port. blueline has both CS35L36 amps behind a single PCM, so UCM models them as separate devices and ACP splits them into Voice Call (Earpiece, Mic) and Voice Call (Mic, Speaker) — no such sink exists. Disabling the speaker asks for "the highest priority port that is not the speaker", which resolves to the headset. The call was already on speakerphone, so the tap sent EnableSpeaker(FALSE) and the phone went to headset. Both halves of the report are one bug.

Fix: pkgs/souveraine-callaudiod. Upstream 0.1.99 plus one patch routing CAD_OPERATION_ENABLE_SPEAKER through blueline-callaudio-hook speaker on|off, which writes the amplifier controls directly — no profile switch, so the PCM a live call runs on is never disturbed. Completion mirrors operation_complete_cb so the speaker-state property stays truthful. Gated on the hook being executable, so it is upstream behaviour on any other device. Cross-built on ArchDev; provides/conflicts/replaces callaudiod, which gnome-calls and phosh both depend on by name.

Verified on the device over the same D-Bus interface the button uses — no call needed, callaudiocli drives it:

earpiece baseline                  Right AMP Enable Switch = off
callaudiocli --enable-speaker 1 -> on    (loudspeaker, both amps)
callaudiocli --enable-speaker 0 -> off   (earpiece, NOT headset)
callaudiocli --status           -> CALL_AUDIO_SPEAKER_OFF

Casey confirms the phone has carried real calls since this fix. The exact speaker-button acceptance was not separately recorded during a live bearer, so keep that narrow UI check in the next convenient call rather than treating the call path itself as unverified.

Three notes worth keeping. pacman -U --noconfirm answers No to the "remove the conflicting package?" prompt, and replaces= only applies to repo syncs — so installing a replacement locally means answering the prompt, not reaching for -Rdd. meson resolves glib's codegen tools from pkg-config variables, which come back sysroot-prefixed under PKG_CONFIG_SYSROOT_DIR; the shared cross file pins glib-compile-resources/gdbus-codegen but not glib-mkenums, so the package ships a supplementary cross file rather than editing that shared file, which lives unversioned on the builder and would lose the change. And neither this package nor the kernel is published to souveraine-aarch64, so both are hand-installed even though the repo is configured on the phone.