Commit d3de9bd5 by PLN (Algolia)

feat(rig): tidal-remote — the CLI that finally closes the diagnostic loop

Companion to the `remote` channel just added to pulsar-tidalcycles. With this,
the whole cold-start-and-measure sequence runs unattended:

    tools/tidal-remote.py boot live/midi/nova/jazz/the_revolution_will_be_sampled.tidal
    tools/probe-chain.py -s 6

`boot` encodes the one ordering that actually works, which was learned the
expensive way:

    reboot  ->  wait for BootTidal.hs  ->  seed the surface  ->  eval

Every step of that order is load-bearing. Tidal's control map (`sStateMV`, where
`^NN` values live) is state INSIDE the ghci process, so a reboot empties it —
seeding before a reboot is wasted work. And evaluating before seeding is the
mute-bomb from #61: an untouched `^NN` resolves to `silence`, not 0, so any
stream referencing an unmoved control is completely silent with no error printed
anywhere. Getting these three steps in the wrong order is precisely how an
evening went into "d4/d5/d9-d12 make no sound".

Two things it refuses to do:

  * It does not claim success. UDP sends land in the void whether or not anyone
    is listening, so `channel_open()` tests the port by trying to BIND it — if
    the bind succeeds, nobody was there, and the command fails loudly instead of
    printing a cheerful nothing. A remote that reports its own success is how you
    end up with a green check on a silent rig.
  * It does not infer readiness. Every path ends by telling the caller to go
    measure with probe-chain. The rig has taught us repeatedly that static
    self-reports agree with each other while the audio disagrees with all of them.

OSC encoding is hand-rolled (~10 lines, all-string args) so this stays
dependency-free — it has to run from a bare launch script, where a pip install is
not part of the plan. Verified by round-tripping an encoded message back through
a decoder: 64 bytes, 4-byte aligned, address and typetag and args all recovered.

Also resolves track names loosely (absolute path, repo-relative, or bare name
searched under live/ and copycat/), erroring on ambiguity rather than guessing —
the corpus has genuine duplicate basenames and picking one silently would be
worse than asking.
parent 3f81e14d
#!/usr/bin/env python3
"""tidal-remote — drive the Pulsar Tidal plugin from the command line.
Why
---
Every diagnostic on this rig used to dead-end at "now press ctrl-enter". The
measurement tools are all scriptable (probe-chain, check-mix, lcxl-init), but
the act of MAKING SOUND was reachable only through PLN's keyboard — so a
4-second measurement cost a human round-trip, and an unattended self-test (#44)
could not even be written.
This talks to the `remote` OSC channel in pulsar-tidalcycles (lib/remote.js,
default 127.0.0.1:3334), so a script can reboot the interpreter, load a track
and play it, and hush — in order, unattended.
The reboot ordering that matters
--------------------------------
Tidal's control map (`sStateMV`, where `^NN` values live) is state INSIDE the
ghci process. A reboot empties it. So the correct order is always:
reboot -> wait for BootTidal -> seed the surface -> eval
Seeding before a reboot is wasted work, and evaluating before seeding is the
mute-bomb (an untouched `^NN` is `silence`, not 0 — see #61). `boot` does the
whole dance for you, in that order, with the waits.
Usage
-----
tools/tidal-remote.py reboot
tools/tidal-remote.py eval-file live/midi/nova/jazz/the_revolution_will_be_sampled.tidal
tools/tidal-remote.py eval # whatever editor is active
tools/tidal-remote.py hush
tools/tidal-remote.py status # logged to the Pulsar console, not returned
tools/tidal-remote.py boot <track.tidal> # reboot + wait + seed + eval
No reply is expected: this is fire-and-forget UDP. Readiness and success are
confirmed by MEASUREMENT (probe-chain.py) and by the SC journal, never by this
tool's own say-so — a remote that reports its own success is how you get a green
check on a silent rig.
"""
from __future__ import annotations
import argparse
import socket
import struct
import subprocess
import sys
import time
from pathlib import Path
HOST = "127.0.0.1"
PORT = 3334
ADDRESS = "/editor/remote"
REPO = Path(__file__).resolve().parent.parent
# How long BootTidal.hs takes to load into a fresh ghci. Evaluating before this
# completes yields a wall of "Variable not in scope" and no sound.
BOOT_WAIT = 6.0
def _pad(b: bytes) -> bytes:
"""OSC pads every string/blob to a 4-byte boundary, with at least one NUL."""
return b + b"\0" * (4 - len(b) % 4)
def osc_message(address: str, args: list[str]) -> bytes:
"""Hand-rolled OSC encoding — all-string args, which is all we need.
Deliberately dependency-free: this tool has to run from a bare launch script
on a machine where a pip install is not part of the plan.
"""
out = _pad(address.encode())
out += _pad(("," + "s" * len(args)).encode())
for a in args:
out += _pad(a.encode())
return out
def send(pairs: dict[str, str], *, quiet: bool = False) -> None:
args: list[str] = []
for k, v in pairs.items():
args += [k, str(v)]
payload = osc_message(ADDRESS, args)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.sendto(payload, (HOST, PORT))
if not quiet:
desc = " ".join(f"{k}={v}" for k, v in pairs.items())
print(f"tidal-remote: sent {desc}")
def channel_open() -> bool:
"""Is anything actually bound to the remote port?
UDP sends succeed into the void, so without this check every command would
report success against a Pulsar that never loaded the channel. Binding the
port ourselves is the test: if the bind SUCCEEDS, nobody was listening.
"""
try:
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
probe.bind((HOST, PORT))
probe.close()
return False
except OSError:
return True
def resolve_track(raw: str) -> str:
"""Accept a repo-relative path, an absolute one, or a bare track name."""
p = Path(raw)
if p.is_absolute() and p.exists():
return str(p)
cand = REPO / raw
if cand.exists():
return str(cand.resolve())
if not raw.endswith(".tidal"):
raw += ".tidal"
hits = sorted((REPO / "live").rglob(raw)) + sorted((REPO / "copycat").rglob(raw))
if len(hits) == 1:
return str(hits[0].resolve())
if len(hits) > 1:
print(f"tidal-remote: ambiguous track '{raw}' — {len(hits)} matches; "
"pass a path", file=sys.stderr)
raise SystemExit(2)
print(f"tidal-remote: no such track '{raw}'", file=sys.stderr)
raise SystemExit(2)
def cmd_boot(track: str) -> int:
"""reboot -> wait -> seed -> eval, in the one order that works."""
path = resolve_track(track)
print("tidal-remote: [1/4] rebooting the interpreter (empties the control map)")
send({"cmd": "reboot"}, quiet=True)
print(f"tidal-remote: [2/4] waiting {BOOT_WAIT:g}s for BootTidal.hs to load")
time.sleep(BOOT_WAIT)
print("tidal-remote: [3/4] seeding the LCXL surface (lcxl-init)")
seed = subprocess.run([str(REPO / "tools" / "lcxl-init.py"), "-q"],
capture_output=True, text=True)
if seed.returncode != 0:
print(f"tidal-remote: WARN — seeding failed:\n{seed.stderr.strip()}",
file=sys.stderr)
print(" Continuing anyway, but expect silent orbits: an unmoved `^NN` is "
"`silence`, not 0.", file=sys.stderr)
else:
print(" seeded")
print(f"tidal-remote: [4/4] eval {Path(path).name}")
send({"cmd": "eval-file", "path": path}, quiet=True)
print("\n Now MEASURE it — do not trust that this printed without errors:\n"
" tools/probe-chain.py -s 6")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("cmd", choices=["reboot", "hush", "eval", "eval-file",
"status", "boot"])
ap.add_argument("track", nargs="?", help="track path or name (eval-file / boot)")
ap.add_argument("--force", action="store_true",
help="send even if nothing is listening on the port")
args = ap.parse_args()
if not channel_open() and not args.force:
print(f"tidal-remote: FAIL — nothing is listening on {HOST}:{PORT}.\n"
" The remote channel lives in pulsar-tidalcycles and loads at package\n"
" activation, so NEW package code needs one `Window: Reload` in Pulsar\n"
" before it exists. After that, this works unattended.",
file=sys.stderr)
return 2
if args.cmd == "boot":
if not args.track:
print("tidal-remote: boot needs a track", file=sys.stderr)
return 2
return cmd_boot(args.track)
if args.cmd == "eval-file":
if not args.track:
print("tidal-remote: eval-file needs a track", file=sys.stderr)
return 2
send({"cmd": "eval-file", "path": resolve_track(args.track)})
return 0
send({"cmd": args.cmd})
return 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