Commit 6b23c9fb by PLN (Algolia)

audio: a checker for the profile pin, and a fix that refuses to be a silence

Adds tools/check-audio.py and wires it into the gate as 'audio profiles',
advisory: a card pinned to Pro Audio is a preference, not a reason to refuse a
launch, but it IS the reason pavucontrol offers no Speaker / Headphones / HDMI.

The ranking is a faithful port of WirePlumber's own findBestProfile, including
its refusal to ever select pro-audio -- without that, a card someone pinned
there would read as correctly set.

--fix counts the sinks it would leave behind and stops at zero. On this box the
textbook repair (clear the pin, take WirePlumber's pick) would have switched off
the only four sinks present, since the laptop codec is pinned off and the USB
interface is unplugged. A fix that trades a wrong profile for no audio is not a
fix, and the fence has a test with that case in it.

Also records a finding the write-up turned up: the config fragment in
~/.config/wireplumber/wireplumber.conf.d/ is 0.5 format on an 0.4.17 install, so
neither it nor the DMIC guard written alongside it in July has ever been in
force. The tool flags that class -- a file that looks exactly like protection
and does nothing.
parent 7808c63f
......@@ -88,14 +88,57 @@ The guard written then — `~/.config/wireplumber/wireplumber.conf.d/51-disable-
there (checked: `grep -rn wireplumber tools/` finds only documentation and the
gig-up warning), so it was removed outside the tooling.
### …and the guard was never in force at all
It is worse than missing. `~/.config/wireplumber/wireplumber.conf.d/*.conf` is
**WirePlumber 0.5's** config format and location; this box runs **0.4.17**,
which reads `~/.config/wireplumber/*.lua.d/*.lua` and knows nothing about
`.conf.d`. The one fragment still in that directory tries to set
```
api.alsa.period-size = 1024
api.alsa.headroom = 0
```
and no sink node carries either property (`pw-dump`, 2026-09-22) — so that
tuning has never applied either, and the shipped `50-alsa-config.lua` has the
same two keys sitting commented out. Which means the thing that has kept this
box safe since July is not the guard; it is the card being switched off.
That is the honest framing for turning it back on: it is a real change of risk
posture, not a restoration of a protection that was working.
The 0.4 form of the guard, for when that decision is made:
```lua
-- ~/.config/wireplumber/main.lua.d/51-disable-sof-dmic.lua
-- Keeps the internal DMIC capture node out of the graph. The SOF DSP stopped
-- answering IPC while this PCM was being probed (2026-07-26) and WirePlumber
-- went to uninterruptible D-state, taking scsynth with it. Playback is
-- untouched: Speaker, Jack Out and HDMI 1-3 all stay.
table.insert(alsa_monitor.rules, {
matches = {
{ { "node.name", "matches", "alsa_input.*sof_sdw*" },
{ "node.name", "matches", "*Mic*" } },
},
apply_properties = { ["node.disabled"] = true },
})
```
Both constraints inside one `matches` entry are ANDed, which is what keeps this
off the headset mic on the same card. **Verify the node name before trusting
it:** it cannot be read today, because the node only exists while the HiFi
profile is active. The first thing to do after the re-probe is
`pw-dump | grep -i 'alsa_input.*sof_sdw'` and confirm the pattern matched
something — a rule that matches nothing is indistinguishable from protection.
Order of operations, therefore:
1. rewrite the DMIC guard, matching the node **by name** (`HiFi__Mic__source`) —
the archive's "card3" is stale by its own "the ALSA card number is not
stable" learning;
2. clear the two `default-profile` pins;
1. install the Lua guard above, in `main.lua.d/`, **not** `wireplumber.conf.d/`;
2. clear the `default-profile` pins — `tools/check-audio.py --fix`;
3. restart WirePlumber once, on a calm evening, and check `HiFi` appears;
4. only then set the SOF card to HiFi.
4. confirm the guard actually matched a node;
5. only then set the SOF card to HiFi.
## Gig relevance
......@@ -103,6 +146,33 @@ With the UMC202HD unplugged and the SOF card `off`, there is **no analog sink at
all** on this box — `gig-up --headphones` has nothing to fold onto. That is fine
for a venue (the UMC is the gig interface) and blocking for a train rehearsal.
## What shipped, 2026-09-22
`tools/check-audio.py` — check by default, `--fix` to repair, `--json` for the
gate, and `--dry-run` to see the commands without running them. It is probe
`audio profiles` in `tools/check-gig.py`, **ADVISE**, so every launch says this
out loud and no launch is ever refused over a card preference.
It reports the three causes above by name, and one more that this page found
while it was being written: **a config fragment in the other WirePlumber's
format**, which is the trap that hides the rest.
One fence is worth stating on its own, because the textbook fix was a trap.
WirePlumber's pick for the NVIDIA card with no display attached is `off`, and
its four raw Pro Audio outputs were **the only sinks on the box**. So "unstick
it from Pro Audio" would have left the machine silent, discovered the next
morning by someone who had not made the change. `--fix` therefore counts the
sinks it would leave behind and refuses at zero:
```
check-audio: REFUSING — applying this would leave the box with no sink at all.
alsa_card.pci-0000_01_00.1 -> off
Plug a display (HDMI profiles become available and outrank Pro Audio),
or plug the UMC202HD, then re-run. Nothing was changed.
```
A fix that trades a wrong profile for no audio is not a fix.
## What tooling can honestly promise
Following `check-preload.sh`'s shape — check by default, mutate only on an
......
#!/usr/bin/env python3.12
"""check-audio — why pavucontrol offers Pro Audio and nothing else.
tools/check-audio.py report; exit 1 if a card is mis-set
tools/check-audio.py --json machine-readable records
tools/check-audio.py --fix clear stale pins, pick the best profile
Check by default, mutate only on `--fix`, and the gate never passes `--fix` —
the same contract as `check-preload.sh`, and the reason `gig_gate.mutations()`
lets this probe into a read-only gate at all.
WHAT IT KNOWS THAT `pactl info` DOES NOT
A card sitting on "Pro Audio" is almost never a driver fault. WirePlumber's
own policy script REFUSES to choose that profile:
# /usr/share/wireplumber/scripts/policy-device-profile.lua
elseif profile and profile.name ~= "pro-audio" then
so a card on pro-audio was put there by hand, once, and then pinned: the
same script consults `~/.local/state/wireplumber/default-profile` BEFORE its
own pick (`findDefaultProfile`), and a saved profile that is still available
wins on every boot, forever. That is the whole of "stuck on pro audio, never
autofixes" — a preference, not a fault, which is why this probe ADVISES and
never blocks a launch.
The second failure is different in kind and cannot be fixed by a setting: a
card whose ALSA layer is healthy (`alsaucm` lists its verbs, `aplay -l`
shows the PCMs) but whose PipeWire profile list does not offer them. A
profile list is built ONCE, when the device object is created, so a UCM open
that failed during boot leaves a card that offers only `off` and `pro-audio`
for the rest of the session. Getting the verbs back needs the device
re-probed, which means restarting WirePlumber — and per the 2026-07-26
session that restart resets sink volume to the device default (87%) and drops
every `fold-orbits` link. So this tool NAMES that repair and refuses to
perform it: a launcher that restarts the session manager is a launcher that
can silence the room.
Third, the trap that hides the other two: a config fragment written in the
OTHER WirePlumber's format. 0.4 reads `~/.config/wireplumber/*.lua.d/*.lua`;
0.5 reads `~/.config/wireplumber/wireplumber.conf.d/*.conf`. Neither reads
the other's, and neither says a word about it — the file just sits there
looking like protection. Verified on this box 2026-09-22: a `.conf` fragment
setting `api.alsa.period-size = 1024` against wireplumber 0.4.17, and no
sink node carrying that property.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
PIN_FILE = Path.home() / ".local/state/wireplumber/default-profile"
WP_CONF_DIR = Path.home() / ".config/wireplumber"
# WirePlumber never selects this one; see the policy-script quote above. So its
# presence in the pin file is proof of a hand-set profile, not of a preference
# the session manager would reproduce.
NEVER_AUTO = "pro-audio"
OK, WARN, FAIL = "OK", "WARN", "FAIL"
# ── reading the box ────────────────────────────────────────────────────────
def _run(argv: list[str], timeout: int = 10) -> tuple[int, str]:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return 127, ""
return r.returncode, r.stdout
def cards() -> list[dict]:
"""Every ALSA card as pactl sees it, profiles and availability included."""
rc, out = _run(["pactl", "-f", "json", "list", "cards"])
if rc != 0 or not out.strip():
return []
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
return [c for c in data if isinstance(c, dict) and c.get("name")]
def pins() -> dict[str, str]:
"""The saved profile per card — the file that makes a choice permanent."""
try:
text = PIN_FILE.read_text()
except OSError:
return {}
out = {}
for line in text.splitlines():
if "=" in line and not line.startswith("["):
k, _, v = line.partition("=")
out[k.strip()] = v.strip()
return out
def ucm_verbs(card: dict) -> list[str]:
"""UCM verbs ALSA will serve for this card, e.g. ['HiFi'].
Empty is NORMAL and not a finding: an HDMI-only codec has no UCM config at
all, and `alsaucm` says so by failing. Only a card that HAS verbs which
PipeWire does not offer is evidence of anything.
"""
name = (card.get("properties") or {}).get("alsa.card_name")
if not name:
return []
rc, out = _run(["alsaucm", "-c", name, "list", "_verbs"])
if rc != 0:
return []
# ` 0: HiFi` / ` Play HiFi quality Music` — the verb is the numbered line
return [m.group(1).strip() for m in re.finditer(r"^\s*\d+:\s*(\S.*)$", out, re.M)]
def wp_version() -> tuple[int, int] | None:
rc, out = _run(["wireplumber", "--version"])
m = re.search(r"libwireplumber\s+(\d+)\.(\d+)", out) if rc == 0 else None
return (int(m.group(1)), int(m.group(2))) if m else None
# ── the policy, mirrored ───────────────────────────────────────────────────
def best_profile(card: dict) -> str | None:
"""What WirePlumber itself would choose, by its own rules.
A faithful port of `findBestProfile`, deliberately: the point of this tool
is to say "you are not where the session manager would put you", and that
claim is only true if the comparison uses the session manager's ranking —
pro-audio skipped by name, highest-priority available profile otherwise,
`off` only as a last resort.
"""
profiles = card.get("profiles") or {}
off = best = unknown = None
for name, p in profiles.items():
if name == NEVER_AUTO:
continue
avail, prio = p.get("available"), p.get("priority") or 0
if name == "off":
off = name
elif avail is True:
if best is None or prio > (profiles[best].get("priority") or 0):
best = name
elif avail is None: # "unknown" ranks below "yes"
if unknown is None or prio > (profiles[unknown].get("priority") or 0):
unknown = name
return best or unknown or off
def dead_fragments(ver: tuple[int, int] | None) -> list[Path]:
"""Config fragments this WirePlumber will not read, in its own config dir."""
if ver is None or not WP_CONF_DIR.is_dir():
return []
lua = sorted(p for d in WP_CONF_DIR.glob("*.lua.d") for p in d.glob("*.lua"))
conf = sorted(WP_CONF_DIR.glob("wireplumber.conf.d/*.conf"))
return conf if ver < (0, 5) else lua
# ── findings ───────────────────────────────────────────────────────────────
def findings() -> list[dict]:
out: list[dict] = []
cs, pinned, ver = cards(), pins(), wp_version()
if not cs:
out.append(dict(level=WARN, what="pactl",
detail="no cards readable — is pipewire-pulse up?",
fix="systemctl --user status pipewire-pulse", fixable=False))
return out
playback = 0
for c in cs:
name, active = c["name"], c.get("active_profile")
want, pin = best_profile(c), pinned.get(name)
profiles = c.get("profiles") or {}
if active and active != NEVER_AUTO and active != "off":
playback += (profiles.get(active, {}).get("sinks") or 0)
# 1. pinned somewhere the session manager would never go on its own
if pin == NEVER_AUTO:
out.append(dict(
level=WARN, what=f"{name}: pinned to {NEVER_AUTO}", card=name,
detail=(f"saved in {PIN_FILE.name}; WirePlumber would choose "
f"{want!r}. Pro Audio hides the named profiles "
"(Speaker / Headphones / HDMI 1-3) because it maps raw "
"PCMs instead of UCM roles."),
fix="tools/check-audio.py --fix", fixable=True, target=want))
elif pin and pin != active:
out.append(dict(
level=WARN, what=f"{name}: pin {pin!r} not in force", card=name,
detail=f"active profile is {active!r} — the pin is unavailable "
"or something set the profile after boot.",
fix="pactl set-card-profile %s %s" % (name, pin), fixable=False))
elif pin == "off" and want not in (None, "off"):
# Deliberate-looking: somebody switched a working card off. Say what
# it costs, never undo it silently — on this rig that pin is how the
# glitchy codec was kept out of the graph after it hung the stack.
out.append(dict(
level=WARN, what=f"{name}: pinned off", card=name,
detail=f"a usable profile exists ({want!r}) but the card is "
"pinned off. If that was deliberate, leave it.",
fix="tools/check-audio.py --fix # clears the pin", fixable=True,
target=want))
# 2. ALSA has roles this card's PipeWire profile list does not offer
verbs = ucm_verbs(c)
if verbs:
known = " ".join(list(profiles) + [p.get("description", "")
for p in profiles.values()]).lower()
missing = [v for v in verbs if v.lower() not in known]
if missing:
out.append(dict(
level=WARN, what=f"{name}: UCM verbs not offered", card=name,
detail=("ALSA serves " + ", ".join(missing) +
" (alsaucm) but PipeWire built no profile for them. "
"A profile list is made once, at device creation, so "
"this needs the DEVICE re-probed — not a setting."),
fix="on a calm evening, NOT mid-set: "
"systemctl --user restart wireplumber "
"(resets sink volume to 87% and drops fold-orbits links)",
fixable=False))
# 3. the consequence, stated once rather than per card
if playback == 0:
out.append(dict(
level=WARN, what="no ordinary playback sink", fixable=False,
detail="every card is off or on Pro Audio, so nothing on this box "
"offers a plain stereo sink — gig-up --headphones has "
"nothing to fold onto.",
fix="plug the UMC202HD, or fix the profiles above"))
# 4. the trap that makes the rest invisible
for frag in dead_fragments(ver):
out.append(dict(
level=WARN, what=f"dead config: {frag.name}", fixable=False,
detail=(f"wireplumber {ver[0]}.{ver[1]} does not read "
f"{frag.parent.name}/ — this file has no effect, and looks "
"exactly like protection that is in force."),
fix=("port it to ~/.config/wireplumber/main.lua.d/*.lua (0.4) "
"or wireplumber.conf.d/*.conf (0.5), then verify the property "
"landed with `pw-dump`")))
return out or [dict(level=OK, what="audio profiles", fixable=False,
detail="every card is where WirePlumber would put it.",
fix="")]
# ── the mutating half ──────────────────────────────────────────────────────
def sinks_after(changes: dict[str, str]) -> int:
"""How many sinks this box would have if `changes` were applied.
The fence below needs a NUMBER, not a judgement. Counted from the same card
list the findings came from, so the arithmetic is the session's own.
"""
total = 0
for c in cards():
profiles = c.get("profiles") or {}
chosen = changes.get(c["name"], c.get("active_profile"))
total += (profiles.get(chosen, {}).get("sinks") or 0)
return total
def apply(items: list[dict], dry: bool = False) -> int:
"""Clear the stale pins and move each card to the profile WP would choose.
Three things it will not do, on purpose: restart WirePlumber (see the module
docstring), touch a card whose only complaint is a pin it cannot improve on,
and leave the box with NO sink at all.
That last fence is not hypothetical. On this rig, 2026-09-22: the NVIDIA
card was pinned to Pro Audio and its four raw HDMI outputs were the only
sinks present, because the laptop codec was pinned off and the USB
interface was unplugged. WirePlumber's own pick for that card, with no
display attached, is `off` — so the "correct" fix would have been a silent
machine, discovered the next morning by someone who had not made the
change. A fix that trades a wrong profile for no audio is not a fix.
"""
todo = [f for f in items if f.get("fixable") and f.get("target")]
if not todo:
print("check-audio: nothing to fix that a setting can fix.")
return 0
changes = {f["card"]: f["target"] for f in todo}
after = sinks_after(changes)
if after == 0:
print("check-audio: REFUSING — applying this would leave the box with "
"no sink at all.")
for card, target in sorted(changes.items()):
print(f" {card} -> {target}")
print(" Plug a display (HDMI profiles become available and outrank "
"Pro Audio),\n or plug the UMC202HD, then re-run. "
"Nothing was changed.")
return 1
drop = {f["card"] for f in todo}
if PIN_FILE.exists():
keep = [ln for ln in PIN_FILE.read_text().splitlines()
if ln.partition("=")[0].strip() not in drop]
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
backup = PIN_FILE.with_name(f"{PIN_FILE.name}.{stamp}.bak")
if dry:
print(f" would back up {PIN_FILE} -> {backup.name}")
else:
shutil.copy2(PIN_FILE, backup)
PIN_FILE.write_text("\n".join(keep) + "\n")
print(f" pins cleared: {', '.join(sorted(drop))} (backup {backup.name})")
rc = 0
for f in todo:
argv = ["pactl", "set-card-profile", f["card"], f["target"]]
if dry:
print(" would run: " + " ".join(argv))
continue
code, _ = _run(argv)
rc |= code
print((" set %s -> %s" % (f["card"], f["target"])) if code == 0
else (" FAILED: " + " ".join(argv)))
# The pin file is WirePlumber's, and it rewrites it when a profile changes.
# Saying so beats letting him discover it: the clearing above only sticks
# for cards nothing sets again.
print(" note: WirePlumber owns that file and rewrites it on every profile "
"change.\n Re-run --check after the next boot to confirm it "
"stayed cleared.")
return rc
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="check-audio",
description=__doc__.split("\n")[0])
ap.add_argument("--json", action="store_true", help="machine-readable records")
ap.add_argument("--fix", action="store_true",
help="clear stale pins and set the best available profile")
ap.add_argument("--dry-run", action="store_true",
help="with --fix: print the commands, run none of them")
a = ap.parse_args(argv)
items = findings()
if a.json:
print(json.dumps(items, indent=2))
return 1 if any(f["level"] != OK for f in items) else 0
for f in items:
mark = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}[f["level"]]
print(f"[{mark}] {f['what']}")
print(f" {f['detail']}")
if f.get("fix"):
print(f" fix: {f['fix']}")
bad = [f for f in items if f["level"] != OK]
if a.fix:
print()
return apply(bad, dry=a.dry_run)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
......@@ -151,6 +151,19 @@ PROBES: tuple[Probe, ...] = (
fix="tools/check-audio-graph.sh -v # recording integrity, "
"Master->UMC, is the interface clocking, is the prune live"),
# ADVISE, never BLOCK. A card pinned to Pro Audio is a PREFERENCE — it is
# saved in WirePlumber's own state file and restored on every boot, which is
# exactly why it never self-corrects — and a gate that refuses to launch
# over a preference is a gate that gets ignored. But it is the reason
# pavucontrol offers no Speaker / Headphones / HDMI 1-3, and on 2026-09-22 it
# was also the reason this box had no ordinary stereo sink at all, which
# gig-up --headphones needs. Read-only here; the repair is `--fix`, which
# the gate must never pass (see gig_gate.mutations()).
Probe("audio profiles", [PY, "tools/check-audio.py"],
fix="tools/check-audio.py --fix # clears stale pins; REFUSES if the "
"result would leave the box with no sink at all",
kind=ADVISE),
# Cold, seeded, no rig required — and the slowest probe in the gate at ~12s
# of GHC, which is why `--fast` exists for the boot path.
Probe("setlist compiles", [PY, "tools/silent-eval.py", "--seeded"],
......
"""The audio-profile checker, and the fence that stops its fix being a silence.
`check-audio.py` exists because one symptom ("pavucontrol only offers Pro
Audio") has two unrelated causes, and the cheap one masks the expensive one:
1. a SAVED PIN. WirePlumber's own policy script refuses to select pro-audio,
so a card sitting on it was put there by hand and then restored from
`~/.local/state/wireplumber/default-profile` on every boot. That is why it
never self-corrects, and it is a preference, not a fault.
2. a PROFILE LIST built without the card's UCM verbs. That one cannot be
fixed by a setting at all — the list is made once, when the device object
is created — so the tool must NAME the repair and refuse to perform it.
The tests below pin the three things that would quietly turn this tool into a
liability: the ranking must be WirePlumber's own, an absent UCM must not read as
a fault, and a fix that would leave the box with no sink must not run.
"""
from __future__ import annotations
import importlib.util
import os
import sys
import pytest
TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, TOOLS)
def _load(path: str, name: str):
spec = importlib.util.spec_from_file_location(name, os.path.join(TOOLS, path))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
CA = _load("check-audio.py", "check_audio")
def _card(name="alsa_card.test", active="pro-audio", profiles=None, alsa_name="test"):
return {"name": name, "active_profile": active,
"properties": {"alsa.card_name": alsa_name},
"profiles": profiles or {}}
# ── the ranking is WirePlumber's, not ours ─────────────────────────────────
def test_pro_audio_is_never_the_answer():
"""`policy-device-profile.lua` skips it by NAME. If this tool ranked it, it
would report a card as correctly set the moment somebody pinned it there —
which is precisely the state it was written to detect."""
c = _card(profiles={
"off": {"priority": 0, "available": True},
"pro-audio": {"priority": 9999, "available": True},
})
assert CA.best_profile(c) == "off"
def test_the_highest_priority_available_profile_wins():
c = _card(profiles={
"off": {"priority": 0, "available": True},
"output:hdmi-stereo": {"priority": 5900, "available": True},
"output:hdmi-surround": {"priority": 800, "available": True},
"pro-audio": {"priority": 1, "available": True},
})
assert CA.best_profile(c) == "output:hdmi-stereo"
def test_an_unavailable_profile_is_not_chosen_however_good():
"""Every HDMI profile on this box reads `available: no` with no display
attached, and WirePlumber ranks those below `off`. Getting this backwards
would have the tool demand a profile that cannot be set."""
c = _card(profiles={
"off": {"priority": 0, "available": True},
"output:hdmi-stereo": {"priority": 5900, "available": False},
"pro-audio": {"priority": 1, "available": True},
})
assert CA.best_profile(c) == "off"
def test_unknown_availability_outranks_off_but_not_yes():
"""WirePlumber's third bucket: `available` neither yes nor no. It exists for
ports it cannot probe, and it is preferred over switching the card off."""
c = _card(profiles={
"off": {"priority": 0, "available": True},
"HiFi": {"priority": 100, "available": None},
})
assert CA.best_profile(c) == "HiFi"
c["profiles"]["analog-stereo"] = {"priority": 50, "available": True}
assert CA.best_profile(c) == "analog-stereo"
# ── reading the pin file ───────────────────────────────────────────────────
def test_the_pin_file_header_is_not_a_card(tmp_path, monkeypatch):
pin = tmp_path / "default-profile"
pin.write_text("[default-profile]\n"
"alsa_card.pci-0000_01_00.1=pro-audio\n"
"bluez_card.AA_BB=a2dp-sink\n")
monkeypatch.setattr(CA, "PIN_FILE", pin)
assert CA.pins() == {"alsa_card.pci-0000_01_00.1": "pro-audio",
"bluez_card.AA_BB": "a2dp-sink"}
def test_no_pin_file_is_not_an_error(tmp_path, monkeypatch):
monkeypatch.setattr(CA, "PIN_FILE", tmp_path / "absent")
assert CA.pins() == {}
# ── a card with no UCM is normal, not broken ───────────────────────────────
def test_a_card_without_ucm_reports_no_verbs(monkeypatch):
"""`alsaucm -c NVidia` fails on this box, and that is CORRECT: an HDMI codec
has no use-case config. Reporting it as a missing profile would put a
permanent warn in the gate for a card that is working."""
monkeypatch.setattr(CA, "_run", lambda *a, **k: (1, ""))
assert CA.ucm_verbs(_card()) == []
def test_ucm_verbs_are_read_off_the_numbered_lines(monkeypatch):
monkeypatch.setattr(CA, "_run", lambda *a, **k:
(0, " 0: HiFi\n Play HiFi quality Music\n"))
assert CA.ucm_verbs(_card()) == ["HiFi"]
# ── the config-format trap ─────────────────────────────────────────────────
def test_each_wireplumber_flags_the_other_ones_config(tmp_path, monkeypatch):
"""0.4 reads `*.lua.d/*.lua`, 0.5 reads `wireplumber.conf.d/*.conf`, and
neither says a word about the other's files — they just sit there looking
like protection that is in force. Verified on this box 2026-09-22: a `.conf`
fragment setting api.alsa.period-size against wireplumber 0.4.17, and no
sink node carrying the property."""
(tmp_path / "main.lua.d").mkdir()
(tmp_path / "main.lua.d" / "51-x.lua").write_text("-- x\n")
(tmp_path / "wireplumber.conf.d").mkdir()
(tmp_path / "wireplumber.conf.d" / "50-y.conf").write_text("{}\n")
monkeypatch.setattr(CA, "WP_CONF_DIR", tmp_path)
assert [p.name for p in CA.dead_fragments((0, 4))] == ["50-y.conf"]
assert [p.name for p in CA.dead_fragments((0, 5))] == ["51-x.lua"]
assert CA.dead_fragments(None) == []
# ── the fence: a fix may not be a silence ──────────────────────────────────
def test_a_fix_that_would_leave_no_sink_is_refused(monkeypatch, capsys):
"""The case that actually occurred, 2026-09-22.
The NVIDIA card was pinned to Pro Audio and its four raw HDMI outputs were
the only sinks on the box, because the laptop codec was pinned off and the
USB interface was unplugged. WirePlumber's own pick for that card, with no
display attached, is `off` — so the textbook fix was a silent machine,
found the next morning by someone who had not made the change.
"""
card = _card(name="c1", active="pro-audio", profiles={
"off": {"priority": 0, "available": True, "sinks": 0},
"pro-audio": {"priority": 1, "available": True, "sinks": 4},
})
monkeypatch.setattr(CA, "cards", lambda: [card])
ran = []
monkeypatch.setattr(CA, "_run", lambda argv, **k: ran.append(argv) or (0, ""))
rc = CA.apply([dict(card="c1", target="off", fixable=True, level=CA.WARN)])
out = capsys.readouterr().out
assert rc == 1 and ran == [], "it went ahead and switched the last sink off"
assert "REFUSING" in out and "no sink at all" in out
def test_a_fix_that_keeps_a_sink_goes_ahead(monkeypatch, capsys, tmp_path):
card = _card(name="c1", active="pro-audio", profiles={
"off": {"priority": 0, "available": True, "sinks": 0},
"output:hdmi-stereo": {"priority": 5900, "available": True, "sinks": 1},
"pro-audio": {"priority": 1, "available": True, "sinks": 4},
})
monkeypatch.setattr(CA, "cards", lambda: [card])
monkeypatch.setattr(CA, "PIN_FILE", tmp_path / "absent")
ran = []
monkeypatch.setattr(CA, "_run", lambda argv, **k: ran.append(argv) or (0, ""))
rc = CA.apply([dict(card="c1", target="output:hdmi-stereo",
fixable=True, level=CA.WARN)])
assert rc == 0
assert ran == [["pactl", "set-card-profile", "c1", "output:hdmi-stereo"]]
def test_the_pin_file_is_backed_up_before_it_is_rewritten(monkeypatch, tmp_path):
"""It is WirePlumber's file and it holds every bluetooth codec choice too.
Rewriting it in place with no copy would be a silent data loss on a file
nobody thinks to back up."""
pin = tmp_path / "default-profile"
pin.write_text("[default-profile]\nc1=pro-audio\nbluez_card.AA=a2dp-sink\n")
monkeypatch.setattr(CA, "PIN_FILE", pin)
card = _card(name="c1", profiles={
"off": {"priority": 0, "available": True, "sinks": 0},
"analog-stereo": {"priority": 100, "available": True, "sinks": 1},
"pro-audio": {"priority": 1, "available": True, "sinks": 4}})
monkeypatch.setattr(CA, "cards", lambda: [card])
monkeypatch.setattr(CA, "_run", lambda *a, **k: (0, ""))
CA.apply([dict(card="c1", target="analog-stereo", fixable=True, level=CA.WARN)])
backups = list(tmp_path.glob("default-profile.*.bak"))
assert len(backups) == 1, "the pin file was rewritten with no copy kept"
assert "bluez_card.AA=a2dp-sink" in pin.read_text(), "dropped an unrelated pin"
assert "c1=" not in pin.read_text(), "the stale pin survived"
def test_dry_run_changes_nothing(monkeypatch, tmp_path, capsys):
pin = tmp_path / "default-profile"
pin.write_text("[default-profile]\nc1=pro-audio\n")
monkeypatch.setattr(CA, "PIN_FILE", pin)
before = pin.read_text()
card = _card(name="c1", profiles={
"off": {"priority": 0, "available": True, "sinks": 0},
"analog-stereo": {"priority": 100, "available": True, "sinks": 1},
"pro-audio": {"priority": 1, "available": True, "sinks": 4}})
monkeypatch.setattr(CA, "cards", lambda: [card])
ran = []
monkeypatch.setattr(CA, "_run", lambda argv, **k: ran.append(argv) or (0, ""))
CA.apply([dict(card="c1", target="analog-stereo", fixable=True, level=CA.WARN)],
dry=True)
assert pin.read_text() == before and ran == []
assert "would run" in capsys.readouterr().out
# ── it must stay a CHECK as far as the gate is concerned ───────────────────
def test_the_gate_asks_it_without_the_fixing_flag():
"""Belt to the fence in test_check_gig: that one proves no probe carries a
mutating flag, this one proves THIS probe is in the table at all, and
advisory — a card preference must never print NO-GO over a launch."""
cg = _load("check-gig.py", "check_gig_audio")
hits = [p for p in cg.PROBES if p.name == "audio profiles"]
assert len(hits) == 1, "the audio probe is not in the gate's table"
probe = hits[0]
assert "--fix" not in probe.argv
assert probe.kind == cg.ADVISE
assert "--fix" in probe.fix, "the fix hint should name the repair"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment