Commit 58371350 by PLN (Algolia)

feat(tools): rig-doctor — does THIS machine have what the rig needs at all

Answers a different question than gig-preflight.py's "is the installed rig
HEALTHY right now": on a fresh box with nothing installed, can it even GET
there, and for each gap, the exact command to fix it. Same spirit as fbk
doctor — a friend who bought PLN's old LCXL2 can run this on their own
laptop and paste the output back, turning "does this work elsewhere" from a
guess into a list. Installs nothing; every check reads (binary --version,
systemctl is-active/is-enabled, aconnect -l, git log, subprocess import
probes) and nothing writes to the machine, the repo, or a service.

The rule that matters most: never hardcode /home/pln or any absolute user
path, and re-derive everything at call time (Path.home(), $XDG_DATA_HOME,
Path(__file__).resolve()) rather than baking a module-level constant from
the machine that wrote the file. That class of bug is real and already in
this repo — start_and_midi.scd hardcodes three sample roots as literal
/home/pln/... strings, so on a box where the user isn't pln, SuperDirt
boots clean with those banks silently gone. This tool is built to be the
thing that catches that, which means it can't commit it itself.

Sample coverage deliberately does not parse .tidal files (one parser,
tools/setlist_samples.py, per test_one_tidal_parser.py) — it shells out to
tools/sample-pack.py --all --json and, with --bundle PATH, verifies against
a carry-on bundle's MANIFEST.json as independent ground truth (without one,
sample-pack can only self-report what THIS box already resolves, since
resolving a bank name requires the folder to already exist on disk — a
fresh box has nothing to walk).

Findings from this run (PLN's own working laptop, the correctness signal —
39 pass, 0 fail, 5 warn across all four invocations: human, --json, --quiet,
--bundle /home/pln/Work/Sound/parvagues-carryon):
  - toolchain fully green: sclang/scsynth 3.14.1, all 6 quarks at their
    known-good commits, ghc 9.4.7/cabal 3.14.1.1/tidal 1.10.0, pulsar
    v4.1.0, ardour9, pipewire+pipewire-jack, all 5 python deps
  - all 281 carry-on bundle banks (15.11 GB) present when checked with
    --bundle; 18/18 synthdefs present
  - real, structural gaps this box legitimately WARNs on: /etc/sudoers.d is
    750 root:root, so presence of perf-audio's sudoers rule can't be
    verified without root (reported honestly as "unknown", not a false
    FAIL); no LCXL plugged in at run time; .env/yt-profile absent (this run
    happens to be from a worktree, which doesn't carry gitignored files —
    a checkout artifact, not a machine gap)

python3 -m pytest -q: 903 passed (unchanged baseline).
parent ff569232
#!/usr/bin/env python3
"""rig-doctor — does THIS machine have what the ParVagues rig needs, at all?
Not "is the installed rig healthy right now" — that is `tools/gig-preflight.py`,
which lives, breathes, and reads sysfs/procfs on a box that already has the
rig running. This is the other question, asked BEFORE any of that is true: a
fresh Linux box, nothing installed, can it even get there — and if not, what
is the exact command to fix the first thing in the way.
INSTALLS NOTHING. Every check reads; nothing here writes to the machine, the
repo, or a service. A friend who bought PLN's old LCXL2 can run this on their
own laptop and paste the output back — same spirit as `fbk doctor`.
WHAT BITES
* `start_and_midi.scd` hardcodes THREE sample roots as literal
`/home/pln/...` strings. On a box where the user is not `pln`, all three
loaders silently point at nothing — SuperDirt boots clean, no error, and
every bank under those roots is just gone. NEVER hardcode `/home/pln` or
any absolute user path in THIS file either — that would make the doctor
the exact bug class it exists to catch. Every path here is re-derived
every run from `Path.home()`, `$XDG_DATA_HOME`, or
`Path(__file__).resolve()` — never a module-level constant baked from the
machine that happened to write this file (this rig's oldest recurring
bug: a binding resolved once, never re-resolved — see
`feedback_stale_binding_pattern` in memory).
* A version lockfile would need PLN's SuperCollider quarks to match a
pinned commit before the rig is "valid" — and that is exactly the kind
of gate that turns "I updated a quark six months ago and forgot" into a
hard stop the night of a gig. So known-good versions (quarks commits,
tidal 1.10.0, ghc 9.4.7/cabal 3.14.1.1, pulsar's tidalcycles v4.1.0) are
printed as DIAGNOSTICS only, measured on PLN's own laptop 2026-09.
Divergence is information, not failure.
* Samples can't be checked for real without an independent ground truth.
`tools/sample-pack.py` resolves bank names by WALKING this box's own
Dirt-Samples/extra trees — on a box with none yet, that walk finds
nothing, so it can only report "0 banks", never "N missing". `--bundle
PATH` (MANIFEST.json from the 281-bank/15.11 GB carry-on bundle) is the
only way to get a real missing-vs-present count on a fresh box.
* A bank name that never resolves is NORMAL, not a missing sample — `#` is
`|>` in this dialect (`s "k" # s "jazz"` plays jazz; `k` is a rhythm
skeleton that is never supposed to resolve). This tool never treats an
unresolved token as a failure.
* The custom synthdefs repo ships exactly 18 `.scd` sources, and two of
them lie about their own symbol name: `vowelbass.scd` declares the synth
`vowelwob`, `metalScreech.scd` declares `metallicScreech`. Cost an hour
to debug once; surfaced here so it never does again.
* `/etc/sudoers.d` is `750 root:root` — a non-root doctor run cannot even
`stat` a file inside it to tell "not installed" from "can't check". This
tool reports that distinction (`unknown`, not a false FAIL) rather than
lying about ground it structurally cannot see.
* Two systemd units per LCXL generation `Conflicts=` each other
(`lcxl3-driver` vs `lcxl-leds-watch`) — the wrong one active for the
hardware actually plugged in is a real, silent failure, not merely "the
other generation".
Read-only by construction: nothing below writes a file, starts a service, or
changes a git ref. It shells out to read-only commands only (`--version`,
`is-active`, `aconnect -l`, `git log`, subprocess `import` probes).
Usage:
tools/rig-doctor.py # human report
tools/rig-doctor.py --quiet # only WARN/FAIL lines
tools/rig-doctor.py --json # machine-readable, for pasting
tools/rig-doctor.py --bundle PATH # verify samples against a
# carry-on bundle's MANIFEST.json
# (the fast path on a fresh box —
# without one, sample coverage
# can only be self-reported, see
# check_samples' docstring)
Exit 0 if no FAIL. WARN alone never fails the run — a friend's machine will
legitimately WARN on PLN-specific hardware (a UMC202HD, an LCXL of the
"wrong" generation) without that meaning anything is broken.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
# ── paths — every one derived at call time, nothing baked in ──────────────
REPO_ROOT = Path(__file__).resolve().parents[1]
def HOME() -> Path:
return Path.home()
def XDG_DATA_HOME() -> Path:
v = os.environ.get("XDG_DATA_HOME")
return Path(v) if v else HOME() / ".local/share"
def DIRT_SAMPLES() -> Path:
return XDG_DATA_HOME() / "SuperCollider/downloaded-quarks/Dirt-Samples"
def QUARKS_ROOT() -> Path:
return XDG_DATA_HOME() / "SuperCollider/downloaded-quarks"
def MI_UGENS() -> Path:
return XDG_DATA_HOME() / "SuperCollider/Extensions/mi-UGens"
def SYNTHDEFS_DIR() -> Path:
return XDG_DATA_HOME() / "SuperCollider/synthdefs"
def EXTRA_SAMPLES() -> Path:
return HOME() / "Work/Sound/Samples/extra"
PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
results: list[dict] = []
def add(group: str, name: str, state: str, detail: str, fix: str = "") -> None:
results.append({"group": group, "check": name, "state": state,
"detail": detail, "fix": fix})
def run_cmd(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except FileNotFoundError:
return 127, "", "not found"
except Exception as e: # timeout, permission, whatever — never raise
return 1, "", str(e)
def which_chain(*candidates: str) -> str | None:
"""First of these binary names that exists on PATH, or None."""
for c in candidates:
p = shutil.which(c)
if p:
return c
return None
def path_state(p: Path) -> str:
"""present | absent | unknown (permission denied to even stat it —
this happens for real: /etc/sudoers.d is 750 root:root, so a non-root
doctor run cannot tell 'not installed' from 'installed but I can't look'.
Reporting that honestly beats a confident false FAIL.)."""
try:
os.stat(p)
return "present"
except FileNotFoundError:
return "absent"
except PermissionError:
return "unknown"
except OSError:
return "unknown"
def import_ok(module: str) -> tuple[bool, str]:
"""Import in a SUBPROCESS — a missing/broken module must never kill the
doctor itself (this file imports nothing third-party at module scope)."""
rc, out, err = run_cmd([sys.executable, "-c", f"import {module}"], timeout=10)
return rc == 0, (err.strip().splitlines()[-1] if err.strip() else "")
def human(n: float) -> str:
if n >= 1e9:
return f"{n/1e9:.2f} GB"
if n >= 1e6:
return f"{n/1e6:.1f} MB"
if n >= 1e3:
return f"{n/1e3:.1f} KB"
return f"{n:.0f} B"
# --------------------------------------------------------------------------- #
# TOOLCHAIN
# --------------------------------------------------------------------------- #
def check_sc() -> None:
"""sclang/scsynth — presence + version, known-good 3.14.1."""
for binname in ("sclang", "scsynth"):
path = shutil.which(binname)
if not path:
add("TOOLCHAIN", binname, FAIL, "not on PATH",
f"install SuperCollider (your distro's package, or "
f"https://supercollider.github.io/downloads) — need {binname}")
continue
rc, out, err = run_cmd([binname, "-v"])
ver = (out or err).strip().splitlines()[0] if (out or err) else "?"
if "3.14.1" in ver:
add("TOOLCHAIN", binname, PASS, ver)
else:
add("TOOLCHAIN", binname, WARN, f"{ver} (known-good: 3.14.1)",
"not enforced — untested combinations may still work fine")
def check_quarks() -> None:
"""The six SuperCollider quarks start_and_midi.scd/SuperDirt need.
Commit+date reported as DIAGNOSTICS only (see module docstring) — a
quark being at a different commit than PLN's laptop is not a failure.
"""
known_good = {
"SuperDirt": ("7e245e8", "2022-01-04"),
"Dirt-Samples": ("9a6dff8", "2023-10-27"),
"quarks": ("b77eaaa", "2024-05-28"),
"BatLib": ("8185a39", "2025-02-02"),
"SCLOrkSynths": ("6730c74", "2023-04-18"),
"Vowel": ("ab59caa", "2022-01-04"),
}
root = QUARKS_ROOT()
for name, (kgc, kgd) in known_good.items():
d = root / name
if not d.is_dir():
add("TOOLCHAIN", f"quark {name}", FAIL, f"missing at {d}",
f"SuperCollider's Quarks GUI, or: git clone <its repo url> "
f"'{d}'")
continue
rc, out, err = run_cmd(["git", "-C", str(d), "log", "-1",
"--format=%h %ad", "--date=short"])
if rc == 0 and out.strip():
commit, _, date = out.strip().partition(" ")
note = f"{commit} {date} (PLN's laptop: {kgc} {kgd})"
else:
note = "present, not a git checkout — can't diagnose drift"
add("TOOLCHAIN", f"quark {name}", PASS, note)
def check_mi_ugens() -> None:
"""WARN not FAIL: without it, the mi* SynthDefs (miplaits, mirings,
mitides, miwarps, miomi, mielements) fail to build; everything else
still runs."""
d = MI_UGENS()
if d.is_dir():
add("TOOLCHAIN", "mi-UGens extension", PASS, str(d))
else:
add("TOOLCHAIN", "mi-UGens extension", WARN, f"missing at {d}",
"needed only for mi* synths — install from "
"https://github.com/v7b1/mi-UGens into that path")
def check_haskell() -> None:
"""ghc + cabal + whether the tidal library is actually built.
Known-good: ghc 9.4.7, cabal 3.14.1.1, tidal 1.10.0. A cabal v2 store can
hold builds under several ghc-<ver> subfolders at once (this box has
both 9.4.7's default toolchain and a leftover 9.12.1 store) — so tidal
presence is checked across ALL of them, not just the active ghc's.
"""
ghc = shutil.which("ghc")
if not ghc:
add("TOOLCHAIN", "ghc", FAIL, "not on PATH",
"install via ghcup: https://www.haskell.org/ghcup/ (need 9.4.7)")
else:
rc, out, err = run_cmd(["ghc", "--version"])
ver = out.strip().rsplit(" ", 1)[-1] if out.strip() else "?"
state = PASS if ver == "9.4.7" else WARN
add("TOOLCHAIN", "ghc", state, f"{ver} (known-good: 9.4.7)")
cabal = shutil.which("cabal")
if not cabal:
add("TOOLCHAIN", "cabal", FAIL, "not on PATH",
"install via ghcup: https://www.haskell.org/ghcup/ (need 3.14.1.1)")
cabal_ok = False
else:
rc, out, err = run_cmd(["cabal", "--version"])
first = out.strip().splitlines()[0] if out.strip() else "?"
ver = first.rsplit(" ", 1)[-1]
state = PASS if ver == "3.14.1.1" else WARN
add("TOOLCHAIN", "cabal", state, f"{first} (known-good: 3.14.1.1)")
cabal_ok = True
store = HOME() / ".cabal/store"
found: list[str] = []
if store.is_dir():
for ghc_dir in store.iterdir():
if not ghc_dir.is_dir():
continue
for pkg in ghc_dir.iterdir():
n = pkg.name
# exact package "tidal", not "tidal-link"/"tidal-listener"
if n.startswith("tidal-") and len(n) > 6 and n[6].isdigit():
ver = n[len("tidal-"):].split("-")[0]
found.append(f"{ver} ({ghc_dir.name})")
if found:
state = PASS if any(f.startswith("1.10.0") for f in found) else WARN
add("TOOLCHAIN", "tidal library", state,
f"found: {', '.join(sorted(set(found)))} (known-good: 1.10.0)")
elif cabal_ok:
add("TOOLCHAIN", "tidal library", FAIL,
f"no tidal-* build under {store}",
"cabal update && cabal install tidal-1.10.0 "
"(or follow https://tidalcycles.org/docs/getting_started)")
else:
add("TOOLCHAIN", "tidal library", FAIL, "cabal missing — can't check",
"install cabal first")
def check_pulsar() -> None:
"""Pulsar + its tidalcycles package, known-good v4.1.0.
Note: Pulsar is an EOL Electron 12 / Node 14 editor — this is the most
bespoke, least-supported part of the whole stack, and the piece most
likely to simply not build on a new distro.
"""
p = shutil.which("pulsar")
if not p:
add("TOOLCHAIN", "pulsar", FAIL, "not on PATH",
"https://pulsar-edit.dev/download.html (EOL project — may need "
"an older Electron/Node to even install)")
return
add("TOOLCHAIN", "pulsar", PASS, p)
pkg = HOME() / ".pulsar/packages/tidalcycles/package.json"
if not pkg.is_file():
add("TOOLCHAIN", "tidalcycles (pulsar pkg)", FAIL,
f"missing at {pkg}",
"in Pulsar: Settings > Install > search 'tidalcycles' (known-good v4.1.0)")
return
try:
data = json.loads(pkg.read_text())
ver = data.get("version", "?")
except (OSError, json.JSONDecodeError) as e:
add("TOOLCHAIN", "tidalcycles (pulsar pkg)", WARN, f"unreadable: {e}")
return
state = PASS if ver == "4.1.0" else WARN
add("TOOLCHAIN", "tidalcycles (pulsar pkg)", state,
f"v{ver} (known-good: v4.1.0)")
def check_ardour() -> None:
"""The launcher (gig-up.sh line ~20 / ARDOUR_SESSION, and its
`launch_bin "Ardour" ardour ardour9 ardour8 Ardour` call) tries these
binary names in this exact order. Match the chain, don't demand one name.
"""
found = which_chain("ardour", "ardour9", "ardour8", "Ardour")
if found:
add("TOOLCHAIN", "ardour", PASS, f"found as '{found}'")
else:
add("TOOLCHAIN", "ardour", FAIL,
"none of ardour/ardour9/ardour8/Ardour on PATH",
"https://ardour.org/download.html (records all orbits as stems — "
"required for the performing session)")
def check_pipewire() -> None:
"""pipewire + pipewire-jack (the shim; no jack2 needed), and whether
pipewire.service is active for THIS user."""
pw = shutil.which("pipewire")
if pw:
add("TOOLCHAIN", "pipewire", PASS, pw)
else:
add("TOOLCHAIN", "pipewire", FAIL, "not on PATH",
"install your distro's pipewire package")
pwjack = shutil.which("pw-jack")
if pwjack:
add("TOOLCHAIN", "pipewire-jack", PASS, pwjack)
else:
add("TOOLCHAIN", "pipewire-jack", FAIL, "pw-jack not on PATH",
"install your distro's pipewire-jack package (NOT jack2 — the "
"shim is what SuperCollider/Ardour need)")
rc, out, err = run_cmd(["systemctl", "--user", "is-active", "pipewire.service"])
state_str = out.strip() or "?"
if state_str == "active":
add("TOOLCHAIN", "pipewire.service", PASS, state_str)
else:
add("TOOLCHAIN", "pipewire.service", WARN, state_str,
"systemctl --user start pipewire.service pipewire-pulse.service")
def check_alsa_ffmpeg() -> None:
for binname, why in (
("aconnect", "gig-up.sh's MIDI-claim probe needs it"),
("aseqdump", "gig-up.sh's MIDI-claim probe needs it"),
("ffmpeg", "recording/mastering pipeline needs it"),
):
p = shutil.which(binname)
if p:
add("TOOLCHAIN", binname, PASS, p)
else:
pkg = "alsa-utils" if binname != "ffmpeg" else "ffmpeg"
add("TOOLCHAIN", binname, FAIL, "not on PATH",
f"install {pkg} ({why})")
def check_python_modules() -> None:
"""The rig's own GUI/bridge deps, imported in a SUBPROCESS so a missing
one can't take this doctor down with it."""
mods = {
"mido": "MIDI I/O (lcxl tooling, bridge)",
"numpy": "audio-lens, gig-log analysis",
"scipy": "audio analysis",
"PIL": "midiviz, lcxl3-probe-display (pillow)",
"PyQt5": "perf-tray, midiviz GUIs",
}
for mod, why in mods.items():
ok, err = import_ok(mod)
pipname = "pillow" if mod == "PIL" else mod
if ok:
add("TOOLCHAIN", f"python: {mod}", PASS, why)
else:
add("TOOLCHAIN", f"python: {mod}", FAIL, err or "import failed",
f"pip install --user {pipname} # {why}")
# --------------------------------------------------------------------------- #
# SAMPLES
# --------------------------------------------------------------------------- #
def _scd_quoted_paths(text: str) -> list[str]:
"""Pull the literal quoted path strings out of start_and_midi.scd's
loadSoundFiles(...) and PathName.new(...) calls, by plain per-line string
search — NOT a regex over Tidal mini-notation (this is a .scd file, and
these are filesystem paths, not sample names), so this does not touch the
one-tidal-parser invariant in tools/tests/test_one_tidal_parser.py.
Line-scoped on purpose: `~dirt.loadSoundFiles(folder.fullPath, ...)` (the
drum-machines loop body) has no quoted path at all, and a commented-out
example line (`// for example: ...("/Users/myUserName/...")`) must not be
read as a real root — both would corrupt the match if a quote-hunt were
allowed to run past the end of the line that has the call.
"""
out = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("//"):
continue
for marker in ("loadSoundFiles(", "PathName.new("):
i = line.find(marker)
if i == -1:
continue
after = i + len(marker)
# Only a call whose FIRST argument is itself a quoted literal
# counts — loadSoundFiles(folder.fullPath, ...) (the drum-machine
# loop body) takes a variable, and its first quote is buried deep
# in an unrelated .replace("-","") call further along the line.
if after >= len(line) or line[after] != '"':
continue
q2 = line.find('"', after + 1)
if q2 != -1:
out.append(line[after + 1:q2])
return out
def check_scd_sample_roots() -> None:
"""Do the three roots start_and_midi.scd actually loads exist — read OUT
of that file, never hardcoded here. If they're /home/pln/... literals and
this box's home is different, that's a FAIL: SuperDirt boots with those
banks silently missing, no error anywhere."""
scd = REPO_ROOT / "start_and_midi.scd"
if not scd.is_file():
add("SAMPLES", "start_and_midi.scd", FAIL, f"not found at {scd}")
return
text = scd.read_text(errors="replace")
literals = _scd_quoted_paths(text)
if not literals:
add("SAMPLES", "sample roots (start_and_midi.scd)", WARN,
"found no loadSoundFiles()/PathName.new() quoted paths to check")
return
home = str(HOME())
for lit in literals:
checkdir = lit[:-1] if lit.endswith("*") else lit # strip glob
checkdir = checkdir.rstrip("/") or "/"
exists = Path(checkdir).is_dir()
if exists:
add("SAMPLES", f"root: {lit}", PASS, "exists")
elif lit.startswith("/home/") and not lit.startswith(home):
other_user = lit.split("/")[2] if len(lit.split("/")) > 2 else "?"
add("SAMPLES", f"root: {lit}", FAIL,
f"hardcoded to user '{other_user}', this box's home is {home} — "
f"SuperDirt will boot with this bank silently missing",
f"edit start_and_midi.scd: replace '{lit}' with a path under "
f"{home}, or make {checkdir} exist on this box")
else:
add("SAMPLES", f"root: {lit}", FAIL, "does not exist",
f"create {checkdir}, or fix the path in start_and_midi.scd")
def check_sample_coverage(bundle: Path | None) -> None:
"""Does this box have the banks the set needs?
Deliberately does NOT parse .tidal files itself (tools/tests/
test_one_tidal_parser.py enforces exactly one parser) — shells out to
`tools/sample-pack.py --all --json`, the tool that already resolves
every bank a track references via the sanctioned parser.
THE CATCH on a truly fresh box: sample-pack.py resolves bank NAMES to
FOLDERS by walking this box's own Dirt-Samples/extra trees. On a box
that has no samples at all yet, that walk finds nothing to resolve, so
it can only ever report "0 banks found" — it cannot tell you what's
MISSING, because it has no independent list of what SHOULD be there.
That's what --bundle is for: a MANIFEST.json built on a machine that DID
have the samples is an independent ground truth to check this box
against. Without --bundle, this check can only self-report what THIS
box already resolves — genuinely useful on PLN's own laptop, close to
useless on a from-scratch one.
An unresolved token is NEVER reported as missing (see sample-pack.py's
own docstring rule 4 — `#` is |>, `s "k" # s "jazz"` plays jazz, `k`
never needed to resolve). That set is exposed by sample-pack itself
under --unresolved and is not this check's business.
"""
script = REPO_ROOT / "tools/sample-pack.py"
if not script.is_file():
add("SAMPLES", "sample-pack.py", FAIL, f"missing at {script}")
return
rc, out, err = run_cmd([sys.executable, str(script), "--all", "--json"], timeout=60)
if rc != 0 or not out.strip():
add("SAMPLES", "sample coverage", WARN,
f"tools/sample-pack.py --all --json failed (rc={rc}): "
f"{err.strip().splitlines()[-1] if err.strip() else '?'}",
"run it directly to debug: tools/sample-pack.py --all --json")
return
try:
report = json.loads(out)
except json.JSONDecodeError as e:
add("SAMPLES", "sample coverage", WARN, f"unparseable JSON: {e}")
return
if bundle is None:
mapped = report["banks"]["mapped"]
extra = report["banks"]["extra"]
n = len(mapped) + len(extra)
nbytes = sum(e["bytes"] for e in mapped + extra)
add("SAMPLES", "sample coverage", PASS if n else WARN,
f"{n} banks resolve on this box (mapped+extra, {human(nbytes)}) — "
f"self-reported only; pass --bundle PATH (e.g. the carry-on "
f"bundle) for a real missing-vs-present check on a fresh box. "
f"NB an unresolved bank name is NORMAL, not a missing sample — "
f"it is usually a synth name or mini-notation structure "
f"overridden downstream ('#' is '|>').")
return
manifest_path = bundle / "MANIFEST.json"
if not manifest_path.is_file():
add("SAMPLES", "sample coverage", WARN,
f"--bundle {bundle} given but no MANIFEST.json there")
return
try:
manifest = json.loads(manifest_path.read_text())
except (OSError, json.JSONDecodeError) as e:
add("SAMPLES", "sample coverage", WARN, f"unreadable MANIFEST.json: {e}")
return
dirt, extra_root = DIRT_SAMPLES(), EXTRA_SAMPLES()
missing_bytes = 0
missing_files = 0
missing_names: list[str] = []
for b in manifest.get("banks", []):
name = b["name"]
candidates = [dirt / name, extra_root / name]
if any(c.is_dir() for c in candidates):
continue
missing_bytes += b.get("bytes", 0)
missing_files += b.get("files", 0)
missing_names.append(name)
total = len(manifest.get("banks", []))
unresolved_note = (" (a bank name that never resolves in a .tidal file "
"is NORMAL — usually a synth name or a mini-notation "
"placeholder overridden by '#', never a missing "
"sample; not what this check is about)")
if not missing_names:
add("SAMPLES", "sample coverage (vs bundle)", PASS,
f"all {total} bundle banks present on this box{unresolved_note}")
else:
sample_names = ", ".join(missing_names[:8])
more = f" (+{len(missing_names) - 8} more)" if len(missing_names) > 8 else ""
add("SAMPLES", "sample coverage (vs bundle)", FAIL,
f"{len(missing_names)}/{total} banks missing "
f"(~{human(missing_bytes)}, {missing_files} files): "
f"{sample_names}{more}{unresolved_note}",
f"drop the missing folders from {bundle} into "
f"{dirt}/ (see {bundle}/README.md — every folder travels whole, "
f"never subset one: bank:N indexing depends on file order)")
# --------------------------------------------------------------------------- #
# SYNTHS
# --------------------------------------------------------------------------- #
def check_synthdefs() -> None:
"""The custom SynthDefs repo (private): git@git.nech.pl:pln/parvagues-synths.git
Expect 18 .scd sources under $XDG_DATA_HOME/SuperCollider/synthdefs.
"""
d = SYNTHDEFS_DIR()
if not d.is_dir():
add("SYNTHS", "synthdefs", FAIL, f"missing dir {d}",
"git clone git@git.nech.pl:pln/parvagues-synths.git && "
"cd parvagues-synths && ./link.sh")
return
scd_files = sorted(p.name for p in d.glob("*.scd"))
n = len(scd_files)
note = ""
have = set(scd_files)
if "vowelbass.scd" in have or "metalScreech.scd" in have:
note = (" — NB two filename/symbol mismatches that cost an hour to "
"debug: vowelbass.scd declares \\vowelwob, "
"metalScreech.scd declares \\metallicScreech")
if n >= 18:
add("SYNTHS", "synthdefs", PASS, f"{n} .scd files in {d}{note}")
else:
add("SYNTHS", "synthdefs", WARN, f"only {n}/18 .scd files in {d}{note}",
"cd parvagues-synths (git@git.nech.pl:pln/parvagues-synths.git) "
"&& ./link.sh")
# --------------------------------------------------------------------------- #
# DEVICES
# --------------------------------------------------------------------------- #
def _aconnect_clients() -> list[str]:
rc, out, err = run_cmd(["aconnect", "-l"])
if rc != 0:
return []
names = []
for line in out.splitlines():
line = line.strip()
if line.startswith("client "):
# "client 14: 'Midi Through' [type=kernel]"
after_colon = line.split(":", 1)
if len(after_colon) == 2:
rest = after_colon[1].strip()
if rest.startswith("'"):
end = rest.find("'", 1)
if end != -1:
names.append(rest[1:end])
return names
def _unit_active(unit: str) -> str:
rc, out, err = run_cmd(["systemctl", "--user", "is-active", f"{unit}.service"])
return out.strip() or "?"
def _unit_enabled(unit: str) -> str:
rc, out, err = run_cmd(["systemctl", "--user", "is-enabled", f"{unit}.service"])
return out.strip() or "?"
def check_midi_controller() -> None:
"""Is an LCXL2 or LCXL3 present, detected from the live ALSA bus (never
assumed) — and does the enabled painter unit match the generation found.
gig-preflight.py's check_midi_surface already verifies LED-dialect health
(mk2 tooling vs mk3 SysEx) while the rig is RUNNING; that is not
reimplemented here. This check answers the fresh-box question: is a
surface on the bus at all, and — from rig_units.py's own SERVICES table
— is the systemd unit for the RIGHT generation the one currently active.
Both generations are first-class; this never fails merely for being the
other one.
"""
if not shutil.which("aconnect"):
add("DEVICES", "midi controller", WARN, "aconnect not installed — can't probe")
return
names = _aconnect_clients()
surface = next((n for n in names
if "launch control xl" in n.lower() or "lcxl" in n.lower()),
None)
if not surface:
add("DEVICES", "midi controller", WARN,
"no Launch Control XL / LCXL client on the MIDI bus right now "
"(plug it in and re-run, or this box performs without CC control)")
return
is_mk3 = "3" in surface # "LCXL3 1" vs "Launch Control XL"
gen = "LCXL3 (mk3)" if is_mk3 else "LCXL2 (mk2)"
add("DEVICES", "midi controller", PASS, f"{gen} present: '{surface}'")
want_unit = "lcxl3-driver" if is_mk3 else "lcxl-leds-watch"
other_unit = "lcxl-leds-watch" if is_mk3 else "lcxl3-driver"
want_state = _unit_active(want_unit)
other_state = _unit_active(other_unit)
want_enabled = _unit_enabled(want_unit)
other_enabled = _unit_enabled(other_unit)
states = (f"{want_unit}: enabled={want_enabled} active={want_state}; "
f"{other_unit}: enabled={other_enabled} active={other_state}")
if want_state == "active":
add("DEVICES", "midi painter unit", PASS,
f"matches detected {gen}. {states}")
elif other_state == "active":
add("DEVICES", "midi painter unit", FAIL,
f"wrong painter running for the detected {gen} — LEDs will not "
f"light correctly. {states}",
f"systemctl --user stop {other_unit}.service && "
f"systemctl --user start {want_unit}.service")
else:
add("DEVICES", "midi painter unit", WARN,
f"neither painter unit is active for the detected {gen}. {states}",
f"systemctl --user start {want_unit}.service")
def check_audio_interface() -> None:
"""Report the ALSA cards present. UMC202HD is PLN's (with known USB
re-enumeration quirks) — its absence is a WARN, not a FAIL: the rig can
run on internal audio."""
cards_file = Path("/proc/asound/cards")
if not cards_file.is_file():
add("DEVICES", "audio interface", WARN, "/proc/asound/cards not readable")
return
text = cards_file.read_text(errors="replace")
card_names = []
for l in text.splitlines():
s = l.strip()
# A card HEADER line looks like " 0 [sofsoundwire ]: sof-soundwire
# - sof-soundwire" (leading space, then the index) — its continuation
# line (the USB path / vendor string) is indented past the "]:" and
# has no "[" of its own. Every line here has leading whitespace, so
# the discriminator is the stripped form starting with a digit AND
# containing "[", not "does this line start with a space".
if not s or not s[0].isdigit() or "[" not in s:
continue
parts = s.split(":", 1)
if len(parts) == 2:
card_names.append(parts[1].strip())
has_umc = any("umc202" in n.lower() for n in card_names)
if has_umc:
add("DEVICES", "audio interface", PASS,
f"UMC202HD present. cards: {'; '.join(card_names)}")
else:
add("DEVICES", "audio interface", WARN,
f"no UMC202HD found. cards present: {'; '.join(card_names) or 'none'}",
"not required — rig runs on internal audio; plug in the UMC202HD "
"for its gain-staged path if this is the performing box")
# --------------------------------------------------------------------------- #
# INSTALL SURFACE (not in git — the real portability blockers)
# --------------------------------------------------------------------------- #
def check_perf_audio() -> None:
bin_state = path_state(Path("/usr/local/sbin/perf-audio"))
sudo_state = path_state(Path("/etc/sudoers.d/perf-audio"))
install_cmd = (
"sudo install -m 755 -o root -g root ~/Work/Sound/Tidal/perf.sh "
"/usr/local/sbin/perf-audio && "
"sudo install -m 440 -o root -g root ~/Work/Sound/Tidal/perf-audio.sudoers "
"/etc/sudoers.d/perf-audio && sudo visudo -cf /etc/sudoers.d/perf-audio"
)
if bin_state == "present" and sudo_state == "present":
add("INSTALL SURFACE", "perf-audio + sudoers", PASS,
"/usr/local/sbin/perf-audio and /etc/sudoers.d/perf-audio both present")
elif sudo_state == "unknown":
add("INSTALL SURFACE", "perf-audio + sudoers", WARN,
f"bin={bin_state}, sudoers=permission denied to check as this user "
f"(that's expected — /etc/sudoers.d is 750 root:root)",
f"verify as root: sudo test -e /etc/sudoers.d/perf-audio && echo ok. "
f"If missing: {install_cmd}")
else:
add("INSTALL SURFACE", "perf-audio + sudoers", FAIL,
f"bin={bin_state}, sudoers={sudo_state} — perf tray can't switch "
f"modes without a password prompt", install_cmd)
def check_protect() -> None:
binp = Path("/usr/local/bin/parvagues-protect")
unitp = Path("/etc/systemd/system/parvagues-protect.service")
bin_state, unit_state = path_state(binp), path_state(unitp)
if bin_state == "present" and unit_state == "present":
add("INSTALL SURFACE", "parvagues-protect", PASS, f"{binp} + {unitp}")
else:
add("INSTALL SURFACE", "parvagues-protect", FAIL,
f"bin={bin_state}, unit={unit_state} — the OOM killer can take "
f"scsynth under memory pressure",
"sudo tools/install-protect.sh")
def check_local_bin_symlinks() -> None:
"""NOTHING in the repo creates these — forgetting them leaves the two
login-time systemd units restarting forever. Top-5 fresh-box breakage.
Only checks that the link resolves to a readable, correctly-named script
under some 'tools/' directory — NOT that it points into THIS process's
own REPO_ROOT. This doctor may itself be run from a worktree or a clone
at a different path than the checkout the user actually performs from
(see feedback_worktree_agents_root_at_master in memory); demanding an
exact match to REPO_ROOT would false-FAIL a perfectly correct symlink
into the user's real checkout.
"""
names = ("tidal-ardour-autoroute.sh", "midi-autoconnect.sh")
local_bin = HOME() / ".local/bin"
fallback_target = REPO_ROOT / "tools"
for name in names:
link = local_bin / name
if not (link.is_symlink() or link.is_file()):
add("INSTALL SURFACE", f"~/.local/bin/{name}", FAIL,
f"missing {link} — its systemd unit will crash-loop",
f"mkdir -p {local_bin} && ln -s {fallback_target / name} {link}")
continue
if not link.is_symlink():
add("INSTALL SURFACE", f"~/.local/bin/{name}", WARN,
f"{link} exists but is a regular file, not a symlink")
continue
real = link.resolve()
if not real.is_file():
add("INSTALL SURFACE", f"~/.local/bin/{name}", FAIL,
f"{link} -> {real} (target does not exist — broken link)",
f"ln -sf {fallback_target / name} {link}")
elif real.name == name and real.parent.name == "tools":
add("INSTALL SURFACE", f"~/.local/bin/{name}", PASS, f"{link} -> {real}")
else:
add("INSTALL SURFACE", f"~/.local/bin/{name}", WARN,
f"{link} -> {real} (unexpected target, but it exists)")
def _rig_units_status() -> tuple[int, list[dict]]:
"""Run tools/rig_units.py --status as a subprocess (the ONE authored
table of units — never reimplemented here) and parse its fixed-column
text table. Returns (returncode, rows)."""
script = REPO_ROOT / "tools/rig_units.py"
if not script.is_file():
return 1, []
rc, out, err = run_cmd([sys.executable, str(script), "--status"])
rows = []
for line in out.splitlines():
if not line or line.startswith("unit") or line.startswith("\nrig:") \
or line.startswith("rig:"):
continue
parts = line.split(None, 4)
if len(parts) >= 4:
rows.append({
"unit": parts[0], "boot": parts[1], "enabled": parts[2],
"active": parts[3], "note": parts[4] if len(parts) > 4 else "",
})
return rc, rows
def check_systemd_units() -> None:
"""tools/rig_units.py's SERVICES table is THE inventory. Report installed/
enabled/active/failed per unit against what that table declares — not a
second copy of the list."""
rc, rows = _rig_units_status()
if not rows:
add("INSTALL SURFACE", "systemd --user units", WARN,
"tools/rig_units.py --status produced nothing to parse",
"run it directly: tools/rig_units.py --status")
return
problems = [r for r in rows if r["active"] == "failed"]
not_enabled = [r for r in rows if r["boot"] == "login"
and r["enabled"] not in ("enabled", "enabled-runtime")]
if problems:
names = ", ".join(f"{r['unit']} (failed)" for r in problems)
add("INSTALL SURFACE", "systemd --user units", FAIL,
f"{len(rows)} units known; FAILED: {names}",
"journalctl --user -u <unit> -n 50 ; systemctl --user reset-failed")
elif not_enabled:
names = ", ".join(r["unit"] for r in not_enabled)
add("INSTALL SURFACE", "systemd --user units", WARN,
f"{len(rows)} units known; not enabled at boot: {names}",
"tools/rig_units.py --ensure --apply")
else:
add("INSTALL SURFACE", "systemd --user units", PASS,
f"{len(rows)} units known to rig_units.py, none failed")
def check_kwin_rule() -> None:
"""[midiviz-pin] — manual KDE config, nothing installs it. WARN only."""
f = HOME() / ".config/kwinrulesrc"
if f.is_file() and "[midiviz-pin]" in f.read_text(errors="replace"):
add("INSTALL SURFACE", "kwin rule [midiviz-pin]", PASS, "present")
else:
add("INSTALL SURFACE", "kwin rule [midiviz-pin]", WARN,
f"not found in {f}",
"add it by hand in KDE: Window Rules > New > match midiviz's "
"window class > Above/On all desktops = yes")
def check_launcher_paths() -> None:
"""Paths gig-up.sh (repo root, line ~20/271) assumes: the Ardour
performing session, and the freebox mount.
Missing session is WARN, not FAIL: gig-up.sh itself only warns and
proceeds when it can't find "$ARDOUR_SESSION" (`launch_bin "Ardour"
ardour ardour9 ardour8 Ardour`) — a missing session means no stem
recording, not no sound. FAILing harder than the launcher it's checking
would be its own bug.
"""
session = HOME() / "Work/Sound/Ardour/Tidal Live/Tidal Live.ardour"
if session.is_file():
add("INSTALL SURFACE", "Ardour performing session", PASS, str(session))
else:
add("INSTALL SURFACE", "Ardour performing session", WARN,
f"not found: {session} — gig-up.sh will warn and proceed without "
f"stem recording",
"copy/create the 'Tidal Live' Ardour session at that path "
"(records all orbits as stems)")
freebox = Path("/mnt/freebox")
if freebox.is_dir() and os.path.ismount(freebox):
add("INSTALL SURFACE", "freebox mount", PASS, str(freebox))
else:
add("INSTALL SURFACE", "freebox mount", WARN,
f"{freebox} not mounted — not required to perform, needed for "
f"archive/mastering workflows")
# --------------------------------------------------------------------------- #
# SECRETS / STATE (never print contents — presence only)
# --------------------------------------------------------------------------- #
def check_secrets() -> None:
items = [
("SECRETS", ".env (repo root)", REPO_ROOT / ".env"),
("SECRETS", "SoundCloud playwright profile",
HOME() / ".config/tidal-ears/sc-chromium"),
("SECRETS", "YouTube profile",
HOME() / ".local/share/parvagues/yt-profile"),
]
for group, name, p in items:
st = path_state(p)
if st == "present":
add(group, name, PASS, "present")
elif st == "unknown":
add(group, name, WARN, "permission denied checking — can't tell")
else:
add(group, name, WARN, "absent (fine unless this box needs to "
"publish/upload — never printed, presence only)")
# --------------------------------------------------------------------------- #
# Checks that take no argument. check_sample_coverage(bundle) is called
# separately by run_all() since it needs the --bundle path.
CHECKS = (
check_sc, check_quarks, check_mi_ugens, check_haskell, check_pulsar,
check_ardour, check_pipewire, check_alsa_ffmpeg, check_python_modules,
check_scd_sample_roots,
check_synthdefs,
check_midi_controller, check_audio_interface,
check_perf_audio, check_protect, check_local_bin_symlinks,
check_systemd_units, check_kwin_rule, check_launcher_paths,
check_secrets,
)
GROUP_ORDER = ["TOOLCHAIN", "SAMPLES", "SYNTHS", "DEVICES", "INSTALL SURFACE", "SECRETS"]
COLOR = {PASS: "\033[32m", WARN: "\033[33m", FAIL: "\033[31m"}
RESET = "\033[0m"
def run_all(bundle: Path | None) -> None:
for check in CHECKS:
try:
check()
except Exception as e: # a broken check must never block the report
add("DOCTOR", check.__name__, WARN, f"check itself failed: {e}")
try:
check_sample_coverage(bundle)
except Exception as e:
add("SAMPLES", "sample coverage", WARN, f"check itself failed: {e}")
def verdict() -> tuple[bool, str]:
fails = [r for r in results if r["state"] == FAIL]
if not fails:
return True, "yes — no FAILs"
# rank: toolchain absence is the deepest blocker, then samples/synths,
# then install-surface conveniences.
order = {"TOOLCHAIN": 0, "SAMPLES": 1, "SYNTHS": 2, "DEVICES": 3,
"INSTALL SURFACE": 4, "SECRETS": 5, "DOCTOR": 6}
fails.sort(key=lambda r: order.get(r["group"], 9))
first = fails[0]
return False, f"no — start with: {first['check']} ({first['detail']})" + \
(f" -> {first['fix']}" if first["fix"] else "")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
prog="rig-doctor",
description=__doc__.split("\n")[0])
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("-q", "--quiet", action="store_true", help="only WARN/FAIL")
ap.add_argument("--bundle", type=Path, metavar="PATH",
help="verify samples against PATH/MANIFEST.json (a "
"carry-on bundle) instead of self-reporting")
a = ap.parse_args(argv)
run_all(a.bundle)
can_play, reason = verdict()
if a.json:
print(json.dumps({
"generated": time.strftime("%Y-%m-%dT%H:%M:%S"),
"host": {"home": str(HOME()), "user": os.environ.get("USER", "?")},
"checks": results,
"summary": {
"pass": sum(1 for r in results if r["state"] == PASS),
"warn": sum(1 for r in results if r["state"] == WARN),
"fail": sum(1 for r in results if r["state"] == FAIL),
"total": len(results),
},
"verdict": {"can_play": can_play, "reason": reason},
}, indent=2))
else:
tty = sys.stdout.isatty()
width = max((len(r["check"]) for r in results), default=10)
by_group: dict[str, list[dict]] = {}
for r in results:
by_group.setdefault(r["group"], []).append(r)
order = GROUP_ORDER + [g for g in by_group if g not in GROUP_ORDER]
for group in order:
rows = by_group.get(group, [])
rows = [r for r in rows if not (a.quiet and r["state"] == PASS)]
if not rows:
continue
print(f"\n== {group} ==")
for r in rows:
tag = r["state"]
if tty:
tag = f"{COLOR[r['state']]}{r['state']:<4}{RESET}"
else:
tag = f"{r['state']:<4}"
print(f"{tag} {r['check']:<{width}} {r['detail']}")
if r["fix"] and r["state"] != PASS:
print(f" {'':<{width}} -> {r['fix']}")
needs_fix = [r for r in results if r["state"] in (FAIL, WARN) and r["fix"]]
if needs_fix:
print("\n== FIXES ==")
for r in needs_fix:
print(f"[{r['state']}] {r['check']}: {r['fix']}")
n_fail = sum(1 for r in results if r["state"] == FAIL)
n_warn = sum(1 for r in results if r["state"] == WARN)
print(f"\n{len(results)} checks: {n_fail} fail, {n_warn} warn, "
f"{len(results) - n_fail - n_warn} pass")
print(f"\nCAN THIS BOX PLAY A SET? {reason}")
return 1 if any(r["state"] == FAIL for r in results) else 0
if __name__ == "__main__":
sys.exit(main())
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