Commit 2d51973f by PLN (Algolia)

feat(tools): rig-install.sh + SETUP.md — clone the gig onto a new box

The install counterpart to rig-doctor.py: the doctor only ever reads,
this converges. Idempotent by construction — detect, report, act only
if something is actually missing, never fail the whole run for one
optional piece. Covers every checked item in rig-doctor.py: system
packages (printed, pacman+--yes is the only path this script ever
executes itself), SuperCollider quarks at current HEAD (no lockfile —
PLN rejected pinning), the mi-UGens extension, the private
parvagues-synths repo + its link.sh, the two ~/.local/bin shims two
systemd units silently crash-loop without (nothing in the repo created
these before now — a top-5 fresh-box breakage), the systemd --user
units from rig_units.py's one authored table, and the sample carry-on
bundle (rsync, out of band from git on purpose).

Root-owned installs (perf-audio+sudoers, parvagues-protect, the
[midiviz-pin] KWin rule) are print-only, always — this script never
runs sudo. A script that quietly escalates itself is one nobody can
audit before a gig; `--print-root-steps` shows just those three
commands and does nothing else.

Every path is resolved at run time from the script's own location,
$HOME, and $XDG_DATA_HOME — never baked in. This rig's oldest
recurring bug is a binding resolved once and never re-resolved; an
installer is exactly the kind of code tempted to commit that bug by
writing down what it found on its own author's box. For the same
reason it never repoints an existing systemd-unit or ~/.local/bin
symlink that already resolves correctly, even to a DIFFERENT checkout
than this one — only creates what's entirely missing, or repairs a
dangling link. Tested twice on PLN's own performing laptop (already
fully provisioned): both runs report "already ok" throughout and
change no running state, except parvagues-synths/link.sh adopting 18
already-identical synthdefs as symlinks (its own designed
no-op-equivalent).

Also regenerates tools/parvagues-rig.target from rig_units.py's
generator: it had drifted (still Wanted the now-manual lcxl3-driver,
missing midiviz) since the unit table changed under it.

SETUP.md is the human procedure: the fast path, what rig-install.sh
does and doesn't do, the LCXL2/LCXL3 hardware-detected split, a
troubleshooting section keyed off the doctor's real FAIL text, and an
explicit "known not portable yet" — start_and_midi.scd and
sample_watch.scd hardcode /home/pln, so a non-pln account gets a rig
that boots clean with banks and preload silently missing (task #28).
Named honestly rather than hidden from the next person who hits it.
parent 06039c8a
# SETUP — cloning the gig onto a new machine
This is the procedure for getting the ParVagues rig from nothing to
performing, on a box that has never seen it before: PLN's own new XPS24, or a
friend's laptop that will never touch PLN's home directory.
Two repos and one out-of-band transfer make up "the rig":
* **Tidal** (this repo) — the `.tidal` tracks, the SuperDirt/MIDI/Ardour
glue, systemd units, and the tools (`rig-doctor.py`, `rig-install.sh`,
`gig-preflight.py`, `gig-up.sh`) that check and converge a machine.
* **[parvagues-synths](https://git.nech.pl/pln/parvagues-synths)**
PLN's custom SynthDefs, a **private** repo. You need SSH access to
`git.nech.pl` to clone it (ask PLN for an account/key). `rig-install.sh`
clones it automatically next to this repo and runs its `link.sh`, which
symlinks 18 `.scd` files into SuperCollider's synthdefs folder.
* **The samples** travel **out of band** — git cannot carry 15 GB
reasonably. They come from a separately-staged carry-on bundle (rsync) or
get rebuilt from a box that already has the corpus. Nothing about the
install fails without them; they can arrive after everything else works.
## Fast path
```
git clone git@git.nech.pl:pln/Tidal.git && cd Tidal
python3 tools/rig-doctor.py # what's missing, with fixes
tools/rig-install.sh # converge; prints any sudo steps
rsync -a /mnt/freebox/PLN/parvagues-carryon/ ~/.local/share/SuperCollider/downloaded-quarks/Dirt-Samples/
./gig-up.sh # play
```
The `rsync` line assumes the default `$XDG_DATA_HOME` (`~/.local/share`); if
yours is set to something else, the Dirt-Samples root is
`${XDG_DATA_HOME:-~/.local/share}/SuperCollider/downloaded-quarks/Dirt-Samples`.
`rig-install.sh` prints this same path, resolved for your box, so you don't
have to compute it by hand.
## What `rig-install.sh` does and doesn't do
It's idempotent — run it as many times as you like. Re-running it on a box
that's already set up should report "already ok" on nearly every line
(that's how it was tested — twice, on PLN's own performing laptop). The one
thing that moved on the first run: `parvagues-synths/link.sh` adopted 18
already-identical synthdefs as symlinks — its own designed no-op-equivalent,
not a change in what SuperCollider loads.
It detects your package manager (pacman/apt/dnf) and **prints** the install
command for SuperCollider, Ardour, PipeWire(+jack), alsa-utils, ffmpeg, ghc/
cabal (via ghcup), and the Python deps — it does not run your package
manager for you, except on `pacman` with `--yes` (Arch is the only
known-good, tested distro here; on Debian/Fedora, review the printed command
before running it — package names and quirks vary).
It installs the SuperCollider quarks (SuperDirt, Dirt-Samples, BatLib,
SCLOrkSynths, Vowel) at **current HEAD**, not a pinned commit — there is no
lockfile here on purpose (a version gate that fails at showtime because a
quark drifted six months ago is worse than the drift itself).
It clones `parvagues-synths` and runs its `link.sh` — never re-implements
the linking, never overwrites a differing file without `--force`.
It creates the two `~/.local/bin` shims (`tidal-ardour-autoroute.sh`,
`midi-autoconnect.sh`) that two systemd units depend on. **Forgetting these
is the single most common fresh-box breakage** — without them, those units
restart forever and nothing about the failure is obvious from `systemctl
--user status`. Nothing else in the repo created them before this script
existed.
It enables/starts the systemd `--user` units from `tools/rig_units.py`'s one
authored table — but only actually applies with `--yes` (otherwise it shows
you a dry run). `--yes` also starts SuperDirt itself, by that table's own
"on-demand" policy — the same thing `gig-up.sh` does at gig time.
It **never runs `sudo`**, anywhere, for anything except the one `pacman
--yes` path above. Three things need root and are print-only —
`--print-root-steps` shows just these three, nothing else:
1. `perf-audio` + its sudoers rule (lets the perf tray switch CPU modes
without a password prompt).
2. `parvagues-protect` + its root systemd unit (protects `scsynth` from the
OOM killer). Copied, not symlinked — a root unit whose `ExecStart` lives
under a user's home is a privilege-escalation hole.
3. The `[midiviz-pin]` KWin rule (keeps the MIDI lens pinned above every
window, on every desktop) — not root, but it rewrites your window-rules
file, so it's print-only the same way.
A script that quietly escalates itself is one nobody can audit before a gig
— that's the whole reason these three are commands you read and run
yourself, not something this script does behind your back.
## LCXL2 vs LCXL3 — both first-class, but pick ONE
The rig supports both generations of the Novation Launch Control XL. Which
one is plugged in is detected live, not configured — `rig-install.sh` looks
at the MIDI bus and tells you which systemd unit matches what's plugged in
right now (`lcxl3-driver.service` for the v3 board, `lcxl-leds-watch.service`
for the original). **These two `Conflicts=` each other on purpose** — only
one painter may run, because starting the wrong one for the hardware
actually plugged in silently kills the right one (this cost PLN 86
crash-loop restarts in one afternoon, 2026-09-05). Neither is auto-started at
boot; `gig-up.sh` chooses the right one every time you bring the rig up,
based on what's on the bus at that moment. If your board's LEDs are dark or
its faders don't reach Ardour, this — not a broken unit — is the first thing
to check: `systemctl --user status lcxl3-driver.service lcxl-leds-watch.service`.
## Troubleshooting, from the doctor's own FAIL messages
`tools/rig-doctor.py` names the exact fix for everything it checks; a few
worth calling out because they're easy to misread:
* **`quark <name>: missing`** — SuperCollider's Quarks GUI, or
`git clone <repo> ~/.local/share/SuperCollider/downloaded-quarks/<name>`.
`rig-install.sh` does this for you; run the doctor again after to confirm.
* **`synthdefs: only N/18 .scd files`** — you're missing (or haven't linked)
the private synths repo. `cd parvagues-synths && ./link.sh`. If it reports
"DIFFERS, kept yours" for any file, that's a real local edit — don't
`--force` it away without checking.
* **`root: /home/<other-user>/...` (FAIL, hardcoded to a different user)**
— see "known not portable yet" below. This is the one class of failure
the doctor can point at but this installer cannot fix for you.
* **`perf-audio + sudoers: permission denied to check`** — expected as a
non-root user (`/etc/sudoers.d` is `750 root:root`); it means "can't tell",
not "missing". Verify as root, or just run the print-root-steps install
command — it's idempotent.
* **`midi painter unit: wrong painter running`** — see the LCXL section
above; stop the wrong one, start the matching one.
* **sample coverage FAIL, listing missing banks** — you have the repo but
not (all of) the audio yet. Get the carry-on bundle (fast path above) or
rebuild with `tools/sample-pack.py --all --pack DEST` on a box that has
the corpus. Every bank travels WHOLE — never copy a subset of one, `bank:N`
indexes files in glob order and dropping one renumbers the rest.
## Known NOT portable yet — read this before you assume a clean install
**Two `.scd` files hardcode `/home/pln` as literal strings**:
`start_and_midi.scd` (the three sample-bank roots it loads at boot, *and*
the repo-root path it loads `preload.scd`/`sample_watch.scd` from) and
`sample_watch.scd` (the same three sample roots, for its live-reload
watcher). `preload.scd` itself has no such literal — it just never runs,
because the path *to* it is one of the hardcoded strings above. On a box
where the account is not literally `pln`, **SuperDirt boots completely
cleanly, with no error anywhere** — and every bank under those roots, plus
preload warming and the sample-reload watcher, is just silently absent. `rig-doctor.py`'s `check_scd_sample_roots` check is the one
thing standing between you and finding this out by ear, mid-set. This is
tracked as task #28; `rig-install.sh` cannot fix it because it would require
rewriting `.tidal`/`.scd` source under version control on someone else's
clone, which is out of scope for an installer. If you hit this with no
warning, that's a bug in how this was rolled out — you should never have to
discover it yourself. Run the doctor before you play; if it FAILs on any
`root: /home/...`, either create matching paths on your box, or fix the
three literals in those two files locally (not upstream, unless your
username is going to be `pln` for everyone who ever runs this, which it
won't).
**Everything else in this repo resolves paths at run time** (`$HOME`,
`$XDG_DATA_HOME`, the script's own directory) — this is the ONE known
exception, called out explicitly rather than discovered the hard way.
......@@ -16,7 +16,7 @@ Wants=parvagues-sc.service
Wants=parvagues-sc-watchdog.service
Wants=tidal-ardour-autoroute.service
Wants=midi-autoconnect.service
Wants=lcxl3-driver.service
Wants=parvagues-bridge.service
Wants=gig-log.service
Wants=perf-tray.service
Wants=midiviz.service
#!/usr/bin/env bash
# rig-install — converge a Linux box toward being able to perform the
# ParVagues rig. The installer counterpart to `tools/rig-doctor.py`: the
# doctor only ever reads, this writes — but only what the doctor found
# missing, and only by checking first. Re-run it as many times as you like;
# a fully-provisioned box (this one, today) should report "already ok" on
# every line and change nothing.
#
# NOT DOCKER. PLN rejected containers on purpose: realtime scheduling, the
# PipeWire/JACK graph, ALSA MIDI passthrough, a GPU and a Qt GUI all cost
# more to containerise than they save on a machine he owns. This installs
# straight onto the host, the same way the rest of the rig already runs.
#
# WHAT BITES
#
# * THE STALE-BINDING BUG, one level up. This rig's oldest recurring bug is
# a path/id resolved once at boot and never re-resolved (see
# feedback_stale_binding_pattern in memory) — an INSTALLER is exactly the
# kind of code that is tempted to bake in what it found on ITS box. Every
# path here comes from `$(dirname "${BASH_SOURCE[0]}")`, `$HOME`, or
# `$XDG_DATA_HOME`, resolved fresh every run — never a literal
# `/home/pln`. If you find one, it's a bug, not a shortcut.
# * NEVER REPOINTS A WORKING SYMLINK. Several of the rig's systemd units on
# PLN's own laptop are symlinks into `/home/pln/Work/Sound/Tidal`, not
# into whatever checkout this script happens to be running from (a
# worktree, a fresh clone elsewhere). This script mirrors rig-doctor's
# own lenient rule for exactly that reason: a link that exists and
# resolves to a same-named file under some `tools/` dir is "already ok",
# full stop — even when that target is a different checkout than this
# one. It only ever CREATES a link that is entirely missing, or repairs
# one that is dangling (points at nothing). It never touches a regular
# file standing in a unit's place, even if that looks inconsistent with
# its symlinked siblings (three of the ten deployed units — lcxl3-driver,
# midiviz, perf-tray — are plain copies on PLN's box today, not links;
# that's pre-existing state, not a bug this script gets to fix).
# * `--yes` IS SCOPED to exactly two things: the detected `pacman` system
# package install, and `rig_units.py --ensure --apply` (which can START
# services — including, by rig_units.py's own on-demand policy,
# SuperDirt itself). Everything else that only ever CREATES something
# that was fully absent (a quark, the mi-UGens extension, the synthdefs
# clone, the `~/.local/bin` shims) runs unconditionally, because on an
# already-correct box those are no-ops by construction — there is
# nothing for `--yes` to gate. Root-owned installs (perf-audio, protect,
# the sudoers file) are never run by this script at all, `--yes`
# included — see `--print-root-steps`.
# * SAMPLES ARE DELIBERATELY OUT OF BAND. Without `--samples SRC` this step
# only prints how to get them (rsync from the freebox carry-on bundle, or
# `sample-pack.py --all --pack DEST` on a box that has the corpus) and
# never fails — a box can be fully set up before the 15 GB of audio
# arrives, and that ordering is the point of a same-day install.
# * mi-UGens needs SuperCollider's own plugin-API headers to build from
# source, and there is no reliable way to guess where those live across
# distros. This script clones the repo IF it's missing and a build
# toolchain is present, but if it can't find that header path it prints
# the manual steps and moves on — it will never claim a build succeeded
# that didn't.
#
# Usage:
# tools/rig-install.sh converge everything safe to do
# without asking; print what a
# fresh box still needs
# tools/rig-install.sh --yes also run the pacman install and
# actually enable/start the
# systemd units (see above)
# tools/rig-install.sh --samples SRC rsync SRC into the Dirt-Samples
# root (SRC = a carry-on bundle
# dir, or any dir shaped like one)
# tools/rig-install.sh --print-root-steps print ONLY the root-owned install
# commands (perf-audio, protect,
# the KWin rule) and exit — nothing
# else runs, nothing is executed
#
# Every step: detect, report, act only if needed, never take down the whole
# run for one optional component — failures accumulate and print at the end,
# alongside rig-doctor's own verdict (the installer's last line is the
# doctor's, not a claim of success this script has no standing to make).
set -u
# ── paths, resolved fresh every run — see "WHAT BITES" above ──────────────
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOME_DIR="$HOME"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME_DIR/.local/share}"
SC_DATA="$XDG_DATA_HOME/SuperCollider"
QUARKS_ROOT="$SC_DATA/downloaded-quarks"
DIRT_SAMPLES="$QUARKS_ROOT/Dirt-Samples"
MI_UGENS="$SC_DATA/Extensions/mi-UGens"
SYNTHDEFS_DIR="$SC_DATA/synthdefs"
LOCAL_BIN="$HOME_DIR/.local/bin"
# ── output ───────────────────────────────────────────────────────────────
if [ -t 1 ]; then
C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_FAIL=$'\e[31m'; C_INFO=$'\e[36m'; C_RST=$'\e[0m'
else
C_OK=; C_WARN=; C_FAIL=; C_INFO=; C_RST=
fi
ok(){ printf '%s✓ %s%s\n' "$C_OK" "$*" "$C_RST"; }
info(){ printf '%s• %s%s\n' "$C_INFO" "$*" "$C_RST"; }
warn(){ printf '%s! %s%s\n' "$C_WARN" "$*" "$C_RST"; }
fail(){ printf '%s✗ %s%s\n' "$C_FAIL" "$*" "$C_RST"; FAILURES+=("$*"); }
hdr(){ printf '\n%s== %s ==%s\n' "$C_INFO" "$*" "$C_RST"; }
FAILURES=()
# ── flags ────────────────────────────────────────────────────────────────
YES=0
PRINT_ROOT_ONLY=0
SAMPLES_SRC=""
while [ $# -gt 0 ]; do
case "$1" in
--yes) YES=1; shift ;;
--print-root-steps) PRINT_ROOT_ONLY=1; shift ;;
--samples) SAMPLES_SRC="${2:-}"; shift 2 ;;
-h|--help)
sed -n '2,/^set -u/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'
exit 0 ;;
*) echo "unknown flag: $1 (see --help)" >&2; exit 2 ;;
esac
done
# ── root-owned installs: PRINT ONLY, never run — see module header ────────
print_root_steps(){
hdr "ROOT STEPS — run these yourself, nothing above/below this script does"
cat <<EOF
1) perf-audio + sudoers — lets the perf tray switch CPU modes without a
password prompt. Without it, perf-tray's mode buttons just fail.
The sudoers rule below grants the CURRENT user (\$USER=$USER); the file
as committed says "pln" — if that's not you, edit the first word of
$REPO_ROOT/perf-audio.sudoers to your own username before installing it.
sudo install -m 755 -o root -g root "$REPO_ROOT/perf.sh" /usr/local/sbin/perf-audio
sudo install -m 440 -o root -g root "$REPO_ROOT/perf-audio.sudoers" /etc/sudoers.d/perf-audio
sudo visudo -cf /etc/sudoers.d/perf-audio # validate
2) parvagues-protect + its root unit — without it, the OOM killer can take
scsynth under memory pressure. Copied (not symlinked) on purpose: a ROOT
systemd unit whose ExecStart lives under a user's \$HOME is a
privilege-escalation hole (anything able to write that file gets root on
the next boot) — install-protect.sh's own header spells this out. Re-run
it after editing the source to redeploy the copy.
sudo "$REPO_ROOT/tools/install-protect.sh"
3) [midiviz-pin] KWin rule — keeps the MIDI lens above every window and on
all desktops. Manual KDE config; this does NOT need root, but it DOES
rewrite your window-rules file, so it is print-only like the two above.
Verified against PLN's own live ~/.config/kwinrulesrc (2026-09):
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key Description "midiviz — keep above, on all desktops (ParVagues rig)"
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key above true
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key aboverule 2
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key desktops ""
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key desktopsrule 2
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key wmclass midiviz
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key wmclasscomplete false
kwriteconfig6 --file kwinrulesrc --group midiviz-pin --key wmclassmatch 1
Then add "midiviz-pin" to the [General] rules= list yourself (it's a
space-separated list; if you already have other rules, append rather
than overwrite, and bump count= to match) and reload KWin:
qdbus org.kde.KWin /KWin reconfigure 2>/dev/null || kwin_x11 --replace &
Simplest and safest if you're not sure: System Settings → Window
Management → Window Rules → New, and match the fields above by hand.
EOF
}
if [ "$PRINT_ROOT_ONLY" = 1 ]; then
print_root_steps
exit 0
fi
echo "rig-install: repo root = $REPO_ROOT"
echo "rig-install: home = $HOME_DIR"
echo "rig-install: user = $USER"
echo "rig-install: yes-mode = $YES"
# ── 1. preflight ────────────────────────────────────────────────────────
hdr "PREFLIGHT"
if [ "$(uname -s)" != "Linux" ]; then
fail "not Linux ($(uname -s)) — this rig is Linux-only for now"
echo; echo "rig-install: cannot continue on a non-Linux host."
exit 1
fi
ok "Linux ($(uname -r))"
if [ "$(id -u)" -eq 0 ]; then
fail "running as root — refusing. The rig runs as a normal user; root-owned"
echo " pieces are installed by the separate, explicit commands in --print-root-steps."
exit 1
fi
ok "not root (running as $USER)"
for b in git python3; do
if command -v "$b" >/dev/null 2>&1; then
ok "$b: $(command -v "$b")"
else
fail "$b not on PATH — this installer cannot proceed without it"
fi
done
if [ ${#FAILURES[@]} -gt 0 ]; then
echo; echo "rig-install: missing a hard prerequisite, stopping here."
printf ' - %s\n' "${FAILURES[@]}"
exit 1
fi
# ── 2. system packages — DETECT, PRINT, never run except pacman+--yes ─────
hdr "SYSTEM PACKAGES"
PKG_MGR=""
if command -v pacman >/dev/null 2>&1; then PKG_MGR=pacman
elif command -v apt-get >/dev/null 2>&1; then PKG_MGR=apt
elif command -v dnf >/dev/null 2>&1; then PKG_MGR=dnf
fi
case "$PKG_MGR" in
pacman)
PKGS="supercollider ardour pipewire pipewire-jack alsa-utils ffmpeg python-mido python-numpy python-scipy python-pillow python-pyqt5"
CMD="sudo pacman -S --needed $PKGS"
;;
apt)
PKGS="supercollider ardour pipewire pipewire-jack alsa-utils ffmpeg python3-pip"
CMD="sudo apt-get install $PKGS"
;;
dnf)
PKGS="supercollider ardour pipewire pipewire-jack-audio-connection-kit alsa-utils ffmpeg python3-pip"
CMD="sudo dnf install $PKGS"
;;
*)
warn "no known package manager on PATH (checked pacman, apt-get, dnf) — install by hand: supercollider, ardour, pipewire + pipewire-jack, alsa-utils, ffmpeg"
;;
esac
if [ -n "$PKG_MGR" ]; then
info "detected package manager: $PKG_MGR"
echo " $CMD"
if [ "$PKG_MGR" = apt ] || [ "$PKG_MGR" = dnf ]; then
echo " pip install --user mido numpy scipy pillow pyqt5 # python deps (pacman path above installs these as system packages instead)"
[ "$PKG_MGR" = dnf ] && echo " NB ffmpeg on Fedora needs RPM Fusion enabled first — dnf's own ffmpeg is a stub on some releases."
fi
if [ "$PKG_MGR" = pacman ] && [ "$YES" = 1 ]; then
info "running it (--yes, pacman is the known-good path):"
if sudo pacman -S --needed $PKGS; then
ok "pacman install done (or everything was already installed)"
else
fail "pacman install failed or was cancelled — see output above"
fi
elif [ "$PKG_MGR" = pacman ]; then
info "not running it — pass --yes to actually run the command above"
else
info "not running it — this installer only ever executes the pacman path, and only with --yes"
fi
fi
# ghc/cabal: doctor's own fix is ghcup, not a distro package (versions drift
# too much distro-to-distro to trust the package's cabal/ghc pairing).
if command -v ghc >/dev/null 2>&1 && command -v cabal >/dev/null 2>&1; then
ok "ghc + cabal already on PATH ($(ghc --version 2>/dev/null | awk '{print $NF}'), $(cabal --version 2>/dev/null | head -1 | awk '{print $NF}'))"
else
warn "ghc/cabal missing — install via ghcup (known-good: ghc 9.4.7, cabal 3.14.1.1):"
echo " curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh"
echo " (a distro ghc/cabal package works too, but versions vary — ghcup is what this rig was built against)"
fi
if command -v cabal >/dev/null 2>&1; then
if cabal list --installed 2>/dev/null | grep -qi '^tidal '; then
ok "tidal library already installed"
else
tidal_found=""
for store in "$HOME_DIR/.cabal/store"/*/tidal-[0-9]*; do
[ -d "$store" ] && tidal_found=1
done
if [ -n "$tidal_found" ]; then
ok "tidal library found under ~/.cabal/store"
else
warn "tidal library not found — cabal update && cabal install tidal-1.10.0"
fi
fi
fi
# pulsar + its tidalcycles package: print-only, no known package-manager path
# is reliable for an EOL Electron 12 editor.
if command -v pulsar >/dev/null 2>&1; then
ok "pulsar already on PATH ($(command -v pulsar))"
else
warn "pulsar missing — https://pulsar-edit.dev/download.html (EOL project, may need an older Electron/Node; check your distro's AUR/repo for an unofficial package and verify it yourself before trusting it)"
fi
TIDALCYCLES_PKG="$HOME_DIR/.pulsar/packages/tidalcycles/package.json"
if [ -f "$TIDALCYCLES_PKG" ]; then
ok "tidalcycles pulsar package present"
else
warn "tidalcycles pulsar package missing — in Pulsar: Settings > Install > search 'tidalcycles' (known-good v4.1.0), or: pulsar -p install tidalcycles"
fi
# ── 3. SuperCollider quarks ──────────────────────────────────────────────
hdr "SUPERCOLLIDER QUARKS"
QUARK_NAMES="SuperDirt Dirt-Samples quarks BatLib SCLOrkSynths Vowel"
missing_quarks=()
for q in $QUARK_NAMES; do
if [ -d "$QUARKS_ROOT/$q" ]; then
commit="$(git -C "$QUARKS_ROOT/$q" log -1 --format='%h %ad' --date=short 2>/dev/null)"
if [ -n "$commit" ]; then
ok "quark $q already present ($commit)"
else
warn "quark $q dir exists but isn't a git checkout — can't tell if it's the real quark or a leftover empty dir (e.g. from a stale Dirt-Samples root created by an earlier --samples run). Verify by hand or: rm -rf \"$QUARKS_ROOT/$q\" && re-run"
fi
else
missing_quarks+=("$q")
fi
done
if [ ${#missing_quarks[@]} -eq 0 ]; then
ok "all 6 quarks present — nothing to install"
elif ! command -v sclang >/dev/null 2>&1; then
fail "quarks missing (${missing_quarks[*]}) but sclang is not on PATH — install SuperCollider first (see SYSTEM PACKAGES above)"
else
info "missing: ${missing_quarks[*]} — installing current HEAD (no lockfile: PLN explicitly rejected pinning quark versions)"
tmp_scd="$(mktemp --suffix=.scd)"
{
echo '('
echo 'Quarks.checkForUpdates({'
for q in "${missing_quarks[@]}"; do
printf 'try { Quarks.install("%s") } { |e| ("QUARK FAIL %s: " ++ e.errorString).postln };\n' "$q" "$q"
done
echo '"RIG_INSTALL_QUARKS_DONE".postln;'
echo '0.exit;'
echo '});'
echo ')'
} > "$tmp_scd"
info "running sclang headless (timeout 30min — Dirt-Samples' own clone is large):"
if QT_QPA_PLATFORM=offscreen timeout 1800 sclang "$tmp_scd" 2>&1 | tee /tmp/rig-install-quarks.log | grep -E "QUARK FAIL|RIG_INSTALL_QUARKS_DONE|ERROR"; then :; fi
rm -f "$tmp_scd"
for q in "${missing_quarks[@]}"; do
if [ -d "$QUARKS_ROOT/$q" ]; then
commit="$(git -C "$QUARKS_ROOT/$q" log -1 --format='%h %ad' --date=short 2>/dev/null)"
ok "quark $q installed${commit:+ ($commit)}"
else
fail "quark $q still missing after install attempt — see /tmp/rig-install-quarks.log"
fi
done
fi
# ── 4. mi-UGens extension ───────────────────────────────────────────────
hdr "MI-UGENS EXTENSION"
if [ -d "$MI_UGENS" ] && find "$MI_UGENS" -maxdepth 1 -iname '*.so' -print -quit | grep -q .; then
ok "mi-UGens already built at $MI_UGENS"
else
info "mi-UGens missing (only the mi* synths need it — miplaits, mirings, mitides, miwarps, miomi, mielements)"
if ! command -v git >/dev/null 2>&1 || ! command -v cmake >/dev/null 2>&1; then
warn "git and/or cmake not available — cannot even attempt a build. Manual steps:"
echo " git clone https://github.com/v7b1/mi-UGens.git"
echo " cd mi-UGens && mkdir build && cd build"
echo " cmake -DCMAKE_BUILD_TYPE=Release -DSC_PATH=<path/to/supercollider/source> .."
echo " cmake --build . --config Release --target install"
echo " (SC_PATH = a checkout of SuperCollider's OWN source matching your installed"
echo " version — plugin headers, not the installed binary. See the repo's README"
echo " if that path isn't obvious on your distro.)"
else
sc_path=""
for cand in /usr/share/SuperCollider /usr/include/SuperCollider /opt/SuperCollider; do
[ -d "$cand" ] && sc_path="$cand"
done
if [ -z "$sc_path" ]; then
warn "cmake/git present but no SuperCollider source/headers found at the usual paths — cannot auto-build. Manual steps:"
echo " git clone https://github.com/v7b1/mi-UGens.git"
echo " cd mi-UGens && mkdir build && cd build"
echo " cmake -DCMAKE_BUILD_TYPE=Release -DSC_PATH=<path/to/supercollider/source> .."
echo " cmake --build . --config Release --target install"
else
build_dir="$(mktemp -d)"
info "attempting a build (SC_PATH=$sc_path) in $build_dir"
if git clone --depth 1 https://github.com/v7b1/mi-UGens.git "$build_dir/mi-UGens" >/tmp/rig-install-miugens.log 2>&1 \
&& cmake -S "$build_dir/mi-UGens" -B "$build_dir/mi-UGens/build" -DCMAKE_BUILD_TYPE=Release -DSC_PATH="$sc_path" >>/tmp/rig-install-miugens.log 2>&1 \
&& cmake --build "$build_dir/mi-UGens/build" --config Release >>/tmp/rig-install-miugens.log 2>&1; then
mkdir -p "$MI_UGENS"
find "$build_dir/mi-UGens/build" -maxdepth 2 -iname '*.so' -exec cp -v {} "$MI_UGENS/" \; >>/tmp/rig-install-miugens.log 2>&1
ok "mi-UGens built and installed to $MI_UGENS"
else
fail "mi-UGens build failed — see /tmp/rig-install-miugens.log; manual fallback: git clone https://github.com/v7b1/mi-UGens.git and follow its README"
fi
fi
fi
fi
# ── 5. custom SynthDefs (private repo) ──────────────────────────────────
hdr "CUSTOM SYNTHDEFS"
existing_n=$(find "$SYNTHDEFS_DIR" -maxdepth 1 -iname '*.scd' 2>/dev/null | wc -l)
SYNTHS_CLONE=""
for cand in "$(dirname "$REPO_ROOT")/parvagues-synths" "$HOME_DIR/Work/Sound/parvagues-synths"; do
[ -d "$cand/.git" ] && SYNTHS_CLONE="$cand"
done
if [ -z "$SYNTHS_CLONE" ]; then
target="$(dirname "$REPO_ROOT")/parvagues-synths"
info "no parvagues-synths clone found — cloning (private repo, needs SSH access to git.nech.pl) into $target"
if git clone git@git.nech.pl:pln/parvagues-synths.git "$target" >/tmp/rig-install-synths-clone.log 2>&1; then
SYNTHS_CLONE="$target"
ok "cloned parvagues-synths"
else
fail "clone failed — this almost always means YOU DO NOT HAVE SSH ACCESS to git.nech.pl yet. Ask PLN for access (an SSH key / account on git.nech.pl), then re-run this script. (raw git error in /tmp/rig-install-synths-clone.log)"
fi
fi
if [ -n "$SYNTHS_CLONE" ]; then
if [ -x "$SYNTHS_CLONE/link.sh" ]; then
info "running $SYNTHS_CLONE/link.sh (idempotent: creates missing links only, never touches a differing file without --force)"
( cd "$SYNTHS_CLONE" && ./link.sh )
else
fail "$SYNTHS_CLONE exists but has no link.sh — repo layout unexpected"
fi
elif [ "$existing_n" -ge 18 ]; then
ok "$existing_n .scd files already in $SYNTHDEFS_DIR (no local parvagues-synths clone found, but the target dir is already populated — leaving it alone)"
fi
# ── 6. ~/.local/bin symlinks ─────────────────────────────────────────────
hdr "~/.local/bin SHIMS"
mkdir -p "$LOCAL_BIN"
for name in tidal-ardour-autoroute.sh midi-autoconnect.sh; do
link="$LOCAL_BIN/$name"
target="$REPO_ROOT/tools/$name"
if [ -L "$link" ]; then
real="$(readlink -f "$link" 2>/dev/null)"
if [ -n "$real" ] && [ -f "$real" ]; then
ok "$link -> $real (already ok — left as-is even if it points at a different checkout)"
else
warn "$link is a DANGLING symlink -> $(readlink "$link") — relinking to $target"
ln -sf "$target" "$link" && ok "relinked $link -> $target" || fail "could not relink $link"
fi
elif [ -e "$link" ]; then
warn "$link exists but is a regular file, not a symlink — leaving it alone (its systemd unit may still crash-loop if it's not the right script)"
else
if [ -f "$target" ]; then
ln -s "$target" "$link" && ok "created $link -> $target" || fail "could not create $link"
else
fail "$target does not exist in this checkout — cannot create $link"
fi
fi
done
# ── 7. systemd --user units ─────────────────────────────────────────────
hdr "SYSTEMD --USER UNITS"
UNITS_DIR="$HOME_DIR/.config/systemd/user"
mkdir -p "$UNITS_DIR"
# The only map this script needs: where to point a unit file that is
# ENTIRELY ABSENT. Never consulted for a unit that already exists (symlink
# or regular file) — see "WHAT BITES" above.
declare -A UNIT_SRC=(
[parvagues-sc]="$REPO_ROOT/parvagues-sc.service"
[parvagues-sc-watchdog]="$REPO_ROOT/tools/parvagues-sc-watchdog.service"
[tidal-ardour-autoroute]="$REPO_ROOT/tools/tidal-ardour-autoroute.service"
[midi-autoconnect]="$REPO_ROOT/tools/midi-autoconnect.service"
[lcxl3-driver]="$REPO_ROOT/tools/lcxl3-driver.service"
[lcxl-leds-watch]="$REPO_ROOT/tools/lcxl-leds-watch.service"
[parvagues-bridge]="$REPO_ROOT/tools/bridge/parvagues-bridge.service"
[gig-log]="$REPO_ROOT/tools/gig-log.service"
[perf-tray]="$REPO_ROOT/perf-tray.service"
[midiviz]="$REPO_ROOT/tools/midiviz.service"
)
created_any=0
for unit in "${!UNIT_SRC[@]}"; do
dest="$UNITS_DIR/$unit.service"
src="${UNIT_SRC[$unit]}"
if [ -e "$dest" ] || [ -L "$dest" ]; then
: # already present (symlink or regular file) — never touched, see header
elif [ -f "$src" ]; then
ln -s "$src" "$dest" && { info "created $dest -> $src"; created_any=1; } || fail "could not create $dest"
else
warn "unit $unit has no source file in this checkout at $src — skipping"
fi
done
[ "$created_any" = 1 ] && systemctl --user daemon-reload
if command -v systemctl >/dev/null 2>&1; then
echo
python3 "$REPO_ROOT/tools/rig_units.py" --status || true
echo
info "dry-run of the converge (nothing applied yet):"
python3 "$REPO_ROOT/tools/rig_units.py" --ensure
if [ "$YES" = 1 ]; then
info "applying (--yes):"
python3 "$REPO_ROOT/tools/rig_units.py" --ensure --apply
else
info "not applying — pass --yes to actually enable+start the units above"
info "(this also starts SuperDirt itself, per rig_units.py's own 'on-demand' policy — that IS the intended behaviour of --ensure --apply, the same call gig-up.sh makes at gig time)"
fi
# LCXL generation — DETECTED, never auto-chosen here. rig_units.py marks
# both painters "manual" on purpose: starting the wrong one for the
# plugged hardware Conflicts=-kills the right one (see its own comment,
# 2026-09-05, 86 crash-loop attempts in one afternoon). gig-up.sh's leds()
# is the one place allowed to choose, live, at gig time.
if command -v aconnect >/dev/null 2>&1; then
surface="$(aconnect -l 2>/dev/null | grep -oiE "'[^']*(launch control xl|lcxl)[^']*'" | head -1)"
if [ -n "$surface" ]; then
if echo "$surface" | grep -q '3'; then
info "MIDI surface detected: $surface (LCXL3/mk3) — the matching unit is lcxl3-driver.service. Start it live with gig-up.sh, or: systemctl --user start lcxl3-driver.service"
else
info "MIDI surface detected: $surface (LCXL2/mk2) — the matching unit is lcxl-leds-watch.service. Start it live with gig-up.sh, or: systemctl --user start lcxl-leds-watch.service"
fi
else
info "no LCXL on the MIDI bus right now — plug it in and re-run, or start the right painter at gig time (gig-up.sh does this automatically)"
fi
fi
# tools/parvagues-rig.target drift check — the generator is the source of
# truth (rig_units.py's SERVICES table); this only ever WARNS, it never
# rewrites a tracked repo file on someone else's clone.
if ! python3 "$REPO_ROOT/tools/rig_units.py" --target | diff -q - "$REPO_ROOT/tools/parvagues-rig.target" >/dev/null 2>&1; then
warn "tools/parvagues-rig.target is stale vs its generator — regenerate with: python3 tools/rig_units.py --target > tools/parvagues-rig.target"
else
ok "tools/parvagues-rig.target matches its generator"
fi
else
fail "systemctl not found — this rig needs systemd --user"
fi
# ── 8. samples ───────────────────────────────────────────────────────────
hdr "SAMPLES"
if [ -n "$SAMPLES_SRC" ]; then
if [ -d "$SAMPLES_SRC" ]; then
mkdir -p "$DIRT_SAMPLES"
info "rsyncing $SAMPLES_SRC/ -> $DIRT_SAMPLES/"
if rsync -a "$SAMPLES_SRC/" "$DIRT_SAMPLES/"; then
ok "samples synced"
else
fail "rsync failed — re-run: rsync -a \"$SAMPLES_SRC/\" \"$DIRT_SAMPLES/\""
fi
else
fail "--samples $SAMPLES_SRC is not a directory"
fi
else
n=$(find "$DIRT_SAMPLES" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
if [ "$n" -gt 0 ]; then
ok "$n bank folders already in $DIRT_SAMPLES (not re-verified against a manifest here — see rig-doctor.py --bundle PATH below)"
else
warn "no samples yet at $DIRT_SAMPLES. Two ways to get them (never required to finish this install):"
echo " rsync -a /mnt/freebox/PLN/parvagues-carryon/ \"$DIRT_SAMPLES/\" # 281 banks, 15.11 GB, 9385 files — verified bundle"
echo " tools/sample-pack.py --all --pack DEST # rebuild on a box that already has the corpus"
fi
fi
# ── 9. verify ────────────────────────────────────────────────────────────
hdr "ACCUMULATED ISSUES FROM THIS RUN"
if [ ${#FAILURES[@]} -eq 0 ]; then
ok "none"
else
printf '%s\n' "${FAILURES[@]}" | while read -r l; do warn "$l"; done
fi
hdr "VERIFY — tools/rig-doctor.py"
doctor_out="$(mktemp)"
python3 "$REPO_ROOT/tools/rig-doctor.py" | tee "$doctor_out"
doctor_rc=${PIPESTATUS[0]:-$?}
doctor_verdict="$(grep '^CAN THIS BOX PLAY A SET?' "$doctor_out")"
rm -f "$doctor_out"
hdr "VERIFY — tools/gig-preflight.py (informational: expects the rig to be RUNNING; a freshly-installed, not-yet-started box will show FAILs here that gig-up.sh resolves)"
python3 "$REPO_ROOT/tools/gig-preflight.py" || true
echo
echo "rig-install: done. Final word belongs to the doctor, not this script:"
echo "$doctor_verdict"
exit "$doctor_rc"
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