Commit 5818b2b0 by PLN (Algolia)

feat(monitor): headphones mode — every orbit in your ears, no Ardour

The rig is multichannel by design: orbit k is the pair (out_{2k-1}, out_{2k}),
wired to its own Ardour track at a gig. Away from the stage that design means
d1 is the only orbit that can reach a sink — and on a freshly booted laptop
(2026-09-07) not even that: scsynth had NO links to any sink, so the whole box
was silent with every check green.

- tools/fold-orbits.py: fold all orbit pairs onto one stereo sink, summed at
  unity. Idempotent, and it VERIFIES — pw-link exits 0 for "File exists" and
  for a port that vanished mid-call, so its exit status proves nothing. The
  channel count is discovered from the graph (28 today, 12 in an older
  comment, 2 on stock SuperDirt), never assumed. Refuses while Ardour runs,
  which owns the mix and prunes SC->hardware links every 2 s.
- gig-up.sh --headphones: SuperDirt -> fold -> Pulsar. No Ardour, no fader
  restore; the fold becomes the readiness gate, because it IS the whole
  monitoring path.
- gig-preflight: `monitor path` check — stage (Ardour receiving on N tracks)
  or headphones (fold present), and it asks fold-orbits rather than
  re-deriving the port math.
- 18 tests, fixtures captured from the real graph: `pw-link -lo` marks links
  by INDENTATION only, so a grep of it fabricates relationships, and a source
  port printed with no indented child is the exact state this tool fixes.

Measured, not assumed: 14/14 orbits arrive at the sink (bd swept d1-d14, peak
per orbit), and the links survive 32 s of autoroute reconciling.
parent 0af672a5
......@@ -21,11 +21,36 @@ ARDOUR_SESSION="$HOME/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour" # lean p
READY_TIMEOUT="${READY_TIMEOUT:-120}" # s to wait for SuperDirt (samples can be slow)
PRELOAD_TRACKS="${PRELOAD_TRACKS:-20}" # warm samples from the last N edited tracks (0 = off)
HEADPHONES=0 # --headphones: no Ardour, all orbits folded to one sink
C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_INFO=$'\e[36m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m'
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"; }
usage(){
cat <<'USAGE'
gig-up — ordered rig launch.
gig-up.sh stage rig: SuperDirt -> Ardour (the mix) -> Pulsar
gig-up.sh --headphones travel rig: SuperDirt -> Pulsar, every orbit folded
onto the default sink. No Ardour, no faders, no
stems — just all 14 orbits in your ears.
gig-up.sh --help
Env: READY_TIMEOUT (120) PRELOAD_TRACKS (20) SETLIST GIG_PERF=off GIG_CUTGPU=off
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
-H|--headphones) HEADPHONES=1 ;;
-h|--help) usage; exit 0 ;;
*) warn "unknown option: $1"; usage; exit 2 ;;
esac
shift
done
# --- readiness probes (the three signals that mean "SuperDirt owns the rig") ---
scsynth_up(){ pgrep -x scsynth >/dev/null 2>&1; }
osc_up(){ ss -uln 2>/dev/null | grep -q ':57120'; }
......@@ -346,10 +371,24 @@ restore_faders(){
warn "faders: declined — $(printf '%s' "$out" | tail -1)"
fi
}
restore_faders
if [ "$HEADPHONES" = 1 ]; then
info "faders: skipped — headphones mode never opens the Ardour session."
else
restore_faders
fi
# 3) Ardour — now safe to open (SuperDirt already holds the controller)
if ardour_up; then
# 3) Ardour — or, on headphones, the orbit fold that replaces it
if [ "$HEADPHONES" = 1 ]; then
# No Ardour means no Master, and the rig is multichannel: orbit k lives on the
# pair (out_{2k-1}, out_{2k}), so out of the box you hear d1 and nothing else
# — and on 2026-09-07, on a freshly booted laptop, not even that: scsynth had
# no links to any sink at all. Fold every pair onto the default sink instead.
if python3 "$DIR/tools/fold-orbits.py"; then
:
else
warn "fold: failed — only d1 (or nothing) will be audible. Re-run: tools/fold-orbits.py"
fi
elif ardour_up; then
ok "Ardour already running — skipping."
elif [ -f "$ARDOUR_SESSION" ]; then
# pw-jack, or Ardour's JACK backend takes the WRONG libjack and there are two
......@@ -403,8 +442,16 @@ echo
if command -v python3 >/dev/null; then
info "readiness: surface state (globals that could be swallowing your sound)"
python3 "$DIR/tools/gig-log.py" preflight 2>&1 | sed 's/^/ /' || true
if [ "$HEADPHONES" = 1 ]; then
# The fold is the whole monitoring path in this mode, so it is the gate:
# a link that a suspended/replaced sink node took with it is silence with
# no error anywhere. Ardour's faders cannot swallow what never reaches them.
info "readiness: orbit fold (the entire monitoring path on headphones)"
python3 "$DIR/tools/fold-orbits.py" --check 2>&1 | sed 's/^/ /' || true
else
info "readiness: Ardour faders (reads the last SAVED session — save first)"
python3 "$DIR/tools/check-mix.py" 2>&1 | tail -4 | sed 's/^/ /' || true
fi
# Pulsar saves the BUFFER, not the file, and Window:Reload restores the cached
# buffer rather than disk — so a tab left open across an edit can silently write
# three-day-old text back over committed work. This catches that in seconds.
......@@ -413,4 +460,9 @@ if command -v python3 >/dev/null; then
fi
echo
ok "gig-up done. SuperDirt owns the LaunchControl; Ardour + Pulsar are coming up."
if [ "$HEADPHONES" = 1 ]; then
ok "gig-up done (headphones). All orbits summed to the default sink; Pulsar is coming up."
info "plugged headphones in after this? the default sink changed — re-run: tools/fold-orbits.py"
else
ok "gig-up done. SuperDirt owns the LaunchControl; Ardour + Pulsar are coming up."
fi
#!/usr/bin/env python3
"""fold-orbits — hear EVERY orbit on one stereo sink, with no Ardour.
The rig is multichannel by design: SuperDirt emits orbit k on the stereo pair
(out_{2k-1}, out_{2k}), and at a gig `tidal-ardour-autoroute` wires each pair to
its own "Tidal NN" track in Ardour, whose Master is the only thing the room
hears. That is correct on stage and useless in a train: with no Ardour, orbit 0
(d1) is the only thing that reaches the default sink — and on 2026-09-07 not even
that, because nothing had linked SuperCollider to the sink at all.
So: fold all orbit pairs into ONE stereo sink, summed at unity, for headphones.
tools/fold-orbits.py # fold onto the PipeWire default sink
tools/fold-orbits.py --check # report only; exit 1 if not folded
tools/fold-orbits.py --undo # drop the fold links again
It REFUSES while Ardour is running, because autoroute prunes every
SuperCollider->hardware link within 2 s while Ardour holds the mix — the fold
would evaporate and you would be left blaming the tool. --force if you know why.
Folding is idempotent, and it VERIFIES: after linking it re-reads the graph and
fails loudly if a link it asked for is not there. `pw-link` exits 0 on
"File exists", and would happily exit 0 on a port that never appeared.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
SC_CLIENT = "SuperCollider"
# The pairs are unity-summed by PipeWire, so N orbits playing at once is N times
# the level of one. That is the same arithmetic as Ardour's Master with every
# fader at 0 dB, and it is the honest default: no hidden trim to explain later.
STEREO_SUFFIXES = (("playback_FL", "playback_FR"), ("playback_1", "playback_2"))
# ── pure helpers (unit-tested in tools/tests/test_fold_orbits.py) ─────────────
def parse_ports(text: str) -> list[str]:
"""Port names from `pw-link -o` / `-i` output, one per line."""
return [ln.strip() for ln in text.splitlines() if ln.strip()]
def sc_out_indices(ports: list[str], client: str = SC_CLIENT) -> list[int]:
"""The N of every `<client>:out_N` port, ascending — discovered, never assumed.
scsynth's channel count is a boot option (28 today, 12 in the comments of
start_and_midi.scd, 2 on a stock SuperDirt), so the orbit count has to come
from the graph rather than from a constant that will rot.
"""
pat = re.compile(rf"^{re.escape(client)}:out_(\d+)$")
return sorted(int(m.group(1)) for m in (pat.match(p) for p in ports) if m)
def orbit_pairs(indices: list[int]) -> list[tuple[int, int]]:
"""[(1,2), (3,4), ...] — orbit k is (out_{2k-1}, out_{2k}); drop a lone tail."""
have = set(indices)
pairs = []
for left in range(1, (max(indices) if indices else 0) + 1, 2):
if left in have and left + 1 in have:
pairs.append((left, left + 1))
return pairs
def sink_stereo_ports(ports: list[str], sink: str) -> tuple[str, str] | None:
"""The sink's L/R input ports, by the naming PipeWire actually used."""
owned = {p.split(":", 1)[1]: p for p in ports if p.startswith(sink + ":")}
for left, right in STEREO_SUFFIXES:
if left in owned and right in owned:
return owned[left], owned[right]
return None
def parse_links(text: str) -> set[tuple[str, str]]:
"""(source, dest) pairs from `pw-link -lo`, which marks links by INDENTATION.
A grep of this output fabricates relationships: the destination lines carry
no source name, so the only thing that ties them to a port is the column
they start in.
"""
links: set[tuple[str, str]] = set()
source = None
for raw in text.splitlines():
if not raw.strip():
continue
if not raw[0].isspace():
source = raw.strip()
continue
if source:
dest = re.sub(r"^[|]?[<>-]*\s*", "", raw.strip())
links.add((source, dest))
return links
def orbits_of(links: list[tuple[str, str]]) -> list[int]:
"""The d-numbers these SC out ports belong to — orbit k is played by `dk`."""
found = set()
for src, _dst in links:
m = re.search(r"out_(\d+)$", src)
if m:
found.add((int(m.group(1)) + 1) // 2)
return sorted(found)
def fmt_orbits(orbits: list[int]) -> str:
"""[1,2,3,5] -> 'd1-d3, d5' — runs collapsed, because 14 of them is a wall."""
if not orbits:
return "no orbits"
runs: list[list[int]] = [[orbits[0]]]
for n in orbits[1:]:
if n == runs[-1][-1] + 1:
runs[-1].append(n)
else:
runs.append([n])
return ", ".join(f"d{r[0]}" if len(r) == 1 else f"d{r[0]}-d{r[-1]}" for r in runs)
def desired_links(pairs: list[tuple[int, int]], lr: tuple[str, str],
client: str = SC_CLIENT) -> list[tuple[str, str]]:
left_in, right_in = lr
out = []
for left, right in pairs:
out.append((f"{client}:out_{left}", left_in))
out.append((f"{client}:out_{right}", right_in))
return out
# ── live graph ───────────────────────────────────────────────────────────────
def run(cmd: list[str], timeout: int = 5) -> tuple[int, str]:
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return p.returncode, (p.stdout or "") + (p.stderr or "")
except (OSError, subprocess.TimeoutExpired) as exc:
return 127, str(exc)
def default_sink() -> str | None:
_, out = run(["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"])
m = re.search(r'node\.name\s*=\s*"([^"]+)"', out)
return m.group(1) if m else None
def comm_alive(match) -> bool:
"""True if any process's /proc/<pid>/comm satisfies `match`.
/proc, not `pgrep`: pgrep costs 53-59 ms on this box (it reads every
cmdline) and matches its own command line often enough to have burned this
repo before.
"""
import os
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
try:
with open(f"/proc/{entry}/comm") as fh:
if match(fh.read().strip()):
return True
except OSError:
continue
return False
def main() -> int:
ap = argparse.ArgumentParser(
prog="fold-orbits",
description="Fold every SuperDirt orbit onto one stereo sink (no Ardour).")
ap.add_argument("--sink", default="auto",
help="PipeWire sink node.name, or 'auto' for the default sink")
ap.add_argument("--check", action="store_true", help="report only; exit 1 if not folded")
ap.add_argument("--undo", action="store_true", help="remove the fold links")
ap.add_argument("--force", action="store_true", help="fold even while Ardour runs")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
def emit(state: str, msg: str, **extra) -> int:
if args.json:
print(json.dumps({"state": state, "message": msg, **extra}))
else:
mark = {"ok": "✓", "warn": "!", "fail": "✗"}[state]
print(f"{mark} fold-orbits: {msg}")
for line in extra.get("lines", []):
print(f" {line}")
return 0 if state == "ok" else 1
if not shutil.which("pw-link"):
return emit("fail", "pw-link not found — this box has no PipeWire tooling")
if not comm_alive(lambda c: c == "scsynth"):
return emit("fail", "scsynth is not running — start the rig first "
"(systemctl --user start parvagues-sc)")
sink = default_sink() if args.sink == "auto" else args.sink
if not sink:
return emit("fail", "could not resolve the default sink (wpctl missing or no sink)")
_, outs = run(["pw-link", "-o"])
_, ins = run(["pw-link", "-i"])
indices = sc_out_indices(parse_ports(outs))
if not indices:
return emit("fail", f"no {SC_CLIENT}:out_* ports in the graph — "
"scsynth is up but not in PipeWire (wrong libjack?)")
lr = sink_stereo_ports(parse_ports(ins), sink)
if not lr:
return emit("fail", f"sink {sink!r} has no stereo playback pair")
pairs = orbit_pairs(indices)
want = desired_links(pairs, lr)
_, links_before = run(["pw-link", "-lo"])
have = parse_links(links_before)
if args.undo:
for src, dst in want:
if (src, dst) in have:
run(["pw-link", "-d", src, dst])
_, after = run(["pw-link", "-lo"])
left = [f"{s} -> {d}" for s, d in want if (s, d) in parse_links(after)]
if left:
return emit("fail", f"{len(left)} fold link(s) survived the undo", lines=left[:4])
return emit("ok", f"unfolded — {len(pairs)} orbit pair(s) no longer reach {sink}")
missing = [(s, d) for s, d in want if (s, d) not in have]
if args.check:
if missing:
dead = orbits_of(missing)
return emit("warn",
f"{len(want) - len(missing)}/{len(want)} fold links present — "
f"{fmt_orbits(dead)} unreachable without Ardour",
orbits=dead, fix="tools/fold-orbits.py")
return emit("ok", f"folded — {len(pairs)} orbit pair(s) summed onto {sink}")
if comm_alive(lambda c: c.startswith("ardour")) and not args.force:
return emit("warn", "Ardour is running — it owns the mix, and autoroute prunes "
"SC->hardware links every 2 s. Nothing to fold; listen through "
"Ardour's Master (--force to fold anyway).")
for src, dst in missing:
run(["pw-link", src, dst])
# VERIFY. pw-link exits 0 for "File exists" and for a port that vanished
# between our listing and our call, so its exit status proves nothing.
_, links_after = run(["pw-link", "-lo"])
now = parse_links(links_after)
still = [f"{s} -> {d}" for s, d in want if (s, d) not in now]
if still:
return emit("fail", f"{len(still)} of {len(want)} link(s) did not take", lines=still[:4])
return emit("ok",
f"folded {len(pairs)} orbit(s) (d1-d{len(pairs)}) onto {sink}",
lines=[f"summed at unity — {len(pairs)} orbits playing at once is "
f"{len(pairs)}x one orbit's level; pull gains if it clips",
"re-run after plugging headphones (the default sink changes)"])
if __name__ == "__main__":
sys.exit(main())
......@@ -430,10 +430,51 @@ def check_xruns() -> None:
"" if state == OK else "check the perf lines above first — they cause this")
def check_monitor_path() -> None:
"""Does anything the rig plays actually reach a speaker?
Two legitimate monitoring paths, and the failure is silent in both:
* STAGE — orbit pairs -> "Tidal NN" tracks -> Ardour Master. autoroute
reconciles this every 2 s.
* HEADPHONES — no Ardour, every orbit pair folded onto one sink by
tools/fold-orbits.py. Out of the box this path does not exist: the rig
is multichannel, so d2..d14 land on busses nothing is listening to,
and on 2026-09-07 a freshly booted laptop had no SC links at all.
The links are also mortal: a sink node that gets replaced (headphones
plugged in, a profile switch, wireplumber restarted) takes every link to
it, with no error printed anywhere.
"""
if not _pid_of("scsynth"):
add("monitor path", WARN, "scsynth down — nothing to route yet",
"systemctl --user start parvagues-sc")
return
ins = run(["pw-link", "-i"])
tidal_tracks = len(re.findall(r"ardour:Tidal \d+/audio_in 1", ins))
if tidal_tracks:
add("monitor path", OK, f"stage: Ardour receiving on {tidal_tracks} Tidal track(s)")
return
# No Ardour: the fold is the whole path. Ask the tool that owns it rather
# than re-deriving the port math here — one parser, one source of truth.
tool = Path(__file__).resolve().parent / "fold-orbits.py"
out = run([sys.executable, str(tool), "--check", "--json"], timeout=10)
try:
verdict = json.loads(out.strip().splitlines()[-1])
except (ValueError, IndexError):
add("monitor path", WARN, f"fold-orbits --check unreadable: {out.strip()[:80]}")
return
state = {"ok": OK, "warn": WARN, "fail": FAIL}.get(verdict.get("state"), WARN)
add("monitor path", state, f"headphones: {verdict.get('message', '?')}",
verdict.get("fix", "tools/fold-orbits.py"))
# --------------------------------------------------------------------------- #
CHECKS = (check_perf, check_regime_agreement, check_audio_rt,
check_superdirt_fresh, check_sample_integrity, check_midi_surface,
check_editor, check_background_load, check_xruns)
check_superdirt_fresh, check_monitor_path, check_sample_integrity,
check_midi_surface, check_editor, check_background_load, check_xruns)
COLOR = {OK: "\033[32m", WARN: "\033[33m", FAIL: "\033[31m"}
RESET = "\033[0m"
......
"""Tests for tools/fold-orbits.py — the no-Ardour headphones fold.
What matters here is that the tool never claims an orbit is audible when it is
not. Two of its four helpers parse `pw-link` output, and `pw-link -lo` marks
links by INDENTATION alone: the destination lines carry no source name, so a
grep of that output invents relationships. So the parser is tested against real
captured output, including the shapes that broke this repo before — a source
port with no links at all, and multi-colon client names (`Midi-Bridge:Midi
Through:(capture_0) …`).
The channel count is discovered, never assumed: scsynth's
`numOutputBusChannels` is a boot option (28 today, 12 in an older comment, 2 on
a stock SuperDirt), so a constant would rot silently into "orbits 8-14 are
inaudible and nobody said so".
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
TOOLS = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("fold_orbits", TOOLS / "fold-orbits.py")
fo = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fo)
SINK = "alsa_output.pci-0000_00_1f.3-platform-sof_sdw.HiFi__hw_sofsoundwire_2__sink"
# Captured from `pw-link -i` on the XPS24, 2026-09-07.
REAL_INPUTS = f"""
{SINK}:playback_FL
{SINK}:playback_FR
ardour:Tidal 01/audio_in 1
ardour:Tidal 01/audio_in 2
"""
# Captured from `pw-link -o`, trimmed to the shapes that matter.
REAL_OUTPUTS = """
Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0
v4l2_input.pci-0000_00_14.0-usb-0_6_1.0:capture_1
alsa_output.pci-0000_01_00.1.pro-output-3:monitor_AUX0
SuperCollider:out_1
SuperCollider:out_2
SuperCollider:out_3
SuperCollider:out_4
SuperCollider:out_10
SuperCollider:in_1
"""
# --------------------------------------------------------------------------- #
# discovery: how many orbits does this scsynth actually have?
# --------------------------------------------------------------------------- #
def test_sc_out_indices_ignores_inputs_and_other_clients():
assert fo.sc_out_indices(fo.parse_ports(REAL_OUTPUTS)) == [1, 2, 3, 4, 10]
def test_sc_out_indices_sorts_numerically_not_lexically():
ports = [f"SuperCollider:out_{n}" for n in (1, 2, 9, 10, 11, 28)]
assert fo.sc_out_indices(ports) == [1, 2, 9, 10, 11, 28]
def test_sc_out_indices_respects_the_client_name():
assert fo.sc_out_indices(["scsynth:out_1"], client="scsynth") == [1]
assert fo.sc_out_indices(["scsynth:out_1"]) == []
def test_orbit_pairs_drops_a_lone_channel():
# out_10 has no out_9 partner: half an orbit is not an orbit, and pairing it
# with anything would send one channel of d5 to the left ear only.
assert fo.orbit_pairs([1, 2, 3, 4, 10]) == [(1, 2), (3, 4)]
def test_orbit_pairs_scales_to_the_rigs_28_channels():
assert fo.orbit_pairs(list(range(1, 29))) == [(n, n + 1) for n in range(1, 29, 2)]
def test_orbit_pairs_of_nothing():
assert fo.orbit_pairs([]) == []
# --------------------------------------------------------------------------- #
# the sink's own port naming
# --------------------------------------------------------------------------- #
def test_sink_stereo_ports_finds_the_FL_FR_pair():
assert fo.sink_stereo_ports(fo.parse_ports(REAL_INPUTS), SINK) == (
f"{SINK}:playback_FL", f"{SINK}:playback_FR")
def test_sink_stereo_ports_falls_back_to_numbered_ports():
ports = ["s:playback_1", "s:playback_2"]
assert fo.sink_stereo_ports(ports, "s") == ("s:playback_1", "s:playback_2")
def test_sink_stereo_ports_refuses_a_mono_sink():
assert fo.sink_stereo_ports(["s:playback_FL"], "s") is None
def test_sink_stereo_ports_does_not_match_a_prefix_sibling():
# `..._2__sink` and `..._2__sink.monitor` differ by a suffix; matching on
# `startswith(sink)` without the colon would fold onto the wrong node.
ports = ["s2:playback_FL", "s2:playback_FR"]
assert fo.sink_stereo_ports(ports, "s") is None
# --------------------------------------------------------------------------- #
# links: indentation is the only thing that ties a dest to its source
# --------------------------------------------------------------------------- #
REAL_LINKS = f"""SuperCollider:out_1
|-> {SINK}:playback_FL
SuperCollider:out_2
|-> {SINK}:playback_FR
SuperCollider:out_3
SuperCollider:out_4
Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0
|-> SuperCollider:midi_in
"""
def test_parse_links_pairs_dests_with_their_indented_source():
assert fo.parse_links(REAL_LINKS) == {
("SuperCollider:out_1", f"{SINK}:playback_FL"),
("SuperCollider:out_2", f"{SINK}:playback_FR"),
("Midi-Bridge:Midi Through:(capture_0) Midi Through Port-0",
"SuperCollider:midi_in"),
}
def test_parse_links_does_not_invent_a_link_for_an_unlinked_port():
# out_3 and out_4 are listed with no indented child. This is the exact
# state the tool exists to fix, so reporting them as linked would make the
# fold a no-op that claims success.
linked = {src for src, _ in fo.parse_links(REAL_LINKS)}
assert "SuperCollider:out_3" not in linked
assert "SuperCollider:out_4" not in linked
def test_parse_links_survives_an_empty_graph():
assert fo.parse_links("") == set()
assert fo.parse_links("\n\n") == set()
def test_parse_links_handles_the_input_side_arrow():
# `pw-link -li` prints `|<-` instead of `|->`; same relationship, and the
# arrow must not end up glued to the port name.
text = "sink:playback_FL\n |<- SuperCollider:out_1\n"
assert fo.parse_links(text) == {("sink:playback_FL", "SuperCollider:out_1")}
# --------------------------------------------------------------------------- #
# the plan, and how it is reported
# --------------------------------------------------------------------------- #
def test_desired_links_sends_odd_left_even_right():
lr = ("sink:playback_FL", "sink:playback_FR")
assert fo.desired_links([(1, 2), (3, 4)], lr) == [
("SuperCollider:out_1", "sink:playback_FL"),
("SuperCollider:out_2", "sink:playback_FR"),
("SuperCollider:out_3", "sink:playback_FL"),
("SuperCollider:out_4", "sink:playback_FR"),
]
def test_orbits_of_maps_channels_to_d_numbers():
# orbit k is the pair (out_{2k-1}, out_{2k}) and is played by `dk`:
# out_1/out_2 -> d1, out_27/out_28 -> d14.
links = [("SuperCollider:out_1", "x"), ("SuperCollider:out_2", "x"),
("SuperCollider:out_27", "x"), ("SuperCollider:out_28", "x")]
assert fo.orbits_of(links) == [1, 14]
def test_fmt_orbits_collapses_runs():
assert fo.fmt_orbits([1, 2, 3, 5]) == "d1-d3, d5"
assert fo.fmt_orbits([7]) == "d7"
assert fo.fmt_orbits(list(range(1, 15))) == "d1-d14"
assert fo.fmt_orbits([]) == "no orbits"
def test_full_plan_for_the_real_rig_covers_every_orbit():
outs = "\n".join(f"SuperCollider:out_{n}" for n in range(1, 29))
pairs = fo.orbit_pairs(fo.sc_out_indices(fo.parse_ports(outs)))
want = fo.desired_links(pairs, (f"{SINK}:playback_FL", f"{SINK}:playback_FR"))
assert len(pairs) == 14 and len(want) == 28
assert fo.orbits_of(want) == list(range(1, 15))
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