Commit 90e2cea6 by PLN (Algolia)

take-rec: arm, verify and summarise an Ardour take from the stem files

Verify reads the capture files, never a self-report: missing or flat
per orbit. Never binds UDP 3820 (gig-log owns Ardour's OSC feedback);
reads gig-log's rec lines instead. The master-rec toggle is only sent
when that stream says OFF, the orbit list comes from the session XML,
and halt stops the transport a stop must never forget.
parent 8831e216
#!/usr/bin/env python3
"""take-rec — arm, verify and summarise an Ardour take, from one button.
WHY THIS EXISTS
---------------
2026-09-28, DevCon26 prep. Recording a set used to be four legs started by
hand: Ardour stems, screen, camera, and gig-log (always on). The D8 lesson
(Take103, the AI ENGINEER set: one orbit silently never recorded) is the
reason this file's centre of gravity is VERIFY, not arm — an arm command
that says "ok" is a self-report, and self-reports are exactly what the rig
must never trust (see the take-lens header: "a confident wrong boundary is
worse than no boundary").
TWO SOURCES OF TRUTH, BOTH BORROWED
-----------------------------------
* The STEM FILES. Ardour creates one capture file per armed track at roll
start and writes until the transport stops. A track whose file is absent
or not growing is NOT recording, no matter what any UI says. This is the
same lens take-lens uses to read a take after the fact, pointed at the
take while it happens.
* gig-log's jsonl. The service owns UDP 3820 (Ardour's OSC feedback — ONE
reply port, Manual mode, see gig-log.py's OSC lens). take-rec therefore
NEVER binds that port; it reads gig-log's rec lines (1 Hz while rolling,
written on every arm/roll transition) for Ardour's own opinion instead.
A second OSC listener would be the "one grid, five copies" lesson again.
ARMING is best-effort with a guaranteed safety net: strip IDs in Ardour's
OSC are the session's route order, which we read from the session XML, and
every arm claim is then verified against the files. If the XML-order guess
is wrong for this Ardour build, verify screams per orbit and PLN arms by
hand in the Mixer — the alarm path works no matter what, and says exactly
which tracks to touch.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import socket
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
SESSION = Path(os.environ.get(
"PARVAGUES_ARDOUR_SESSION",
os.path.expanduser("~/Work/Sound/Ardour/Tidal Live")))
LOG_DIR = Path(os.environ.get(
"GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log")))
OSC_HOST, OSC_SEND_PORT = "127.0.0.1", 3819
DEFAULT_ORBITS = range(1, 15) # the 14 orbits of Tidal Live
ARM_SETTLE_S = 3.0 # how long we wait for Ardour to act
def _load(dash_name: str, py_name: str):
"""Import a sibling tool whose filename has a dash (unimportable the
normal way). Same trick tests/test_gig_log.py uses."""
p = Path(__file__).resolve().parent / dash_name
spec = importlib.util.spec_from_file_location(py_name, p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_gig_log = _load("gig-log.py", "_take_rec_gig_log")
_take_lens = _load("take-lens.py", "_take_rec_take_lens")
osc_encode = _gig_log.osc_encode
# ---- the session's strip order (for OSC strip ids) -----------------------
def tidal_strip_ids(session: Path = SESSION) -> dict[int, int]:
"""{orbit n: OSC strip id} — strip ids are the route ORDER, read from
the session XML in document order, 1-based over ALL routes. This is the
one guess in the file; `arm` never trusts it (verify decides), and the
fallback — arm by hand — is always available."""
f = session / (session.name + ".ardour")
ids: dict[int, int] = {}
if not f.is_file():
return ids
n = 0
for _ev, el in ET.iterparse(f, events=("end",)):
if el.tag == "Route":
n += 1
name = el.get("name") or ""
m = re.fullmatch(r"Tidal (\d+)", name)
if m:
ids.setdefault(int(m.group(1)), n)
el.clear()
return ids
def _osc_send(msg: bytes) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.sendto(msg, (OSC_HOST, OSC_SEND_PORT))
# ---- gig-log's opinion (the OSC lens we must NOT duplicate) --------------
def latest_rec_line() -> dict | None:
"""The most recent `rec` record from the CURRENT gig-log jsonl (newest
mtime — the service starts a file per launch). None = no opinion."""
logs = sorted(LOG_DIR.glob("gig-*.jsonl"), key=lambda p: p.stat().st_mtime)
if not logs:
return None
try:
with open(logs[-1], "rb") as fh:
fh.seek(0, os.SEEK_END)
size = fh.tell()
fh.seek(max(0, size - 65536))
lines = fh.read().splitlines()
for line in reversed(lines):
try:
d = json.loads(line)
except Exception:
continue
if d.get("k") == "rec":
return d
except OSError:
return None
return None
def rec_opinion(t0: float, timeout: float = ARM_SETTLE_S) -> dict | None:
"""Wait up to `timeout` for a rec line NEWER than t0 (a transition we
just caused). None = gig-log saw nothing new."""
end = time.time() + timeout
while time.time() < end:
d = latest_rec_line()
if d and d.get("t", 0) >= t0 - 1.0:
return d
time.sleep(0.5)
return None
# ---- arm ------------------------------------------------------------------
def arm(orbits: list[int], session: Path = SESSION, dry: bool = False) -> int:
"""Arm the Tidal strips, master record, roll — then check the files.
The master rec-enable is a TOGGLE with no queryable state (gig-log's
OSC lens cannot report arm under feedback=9 — see its header), so the
first lesson of the first live REC test applies: NEVER toggle blind.
gig-log's latest rec line IS the state we track: rec=True means leave
it alone. Only when the stream says OFF (or has never recorded) do we
toggle, once, and then re-check the transition before ever considering
a second toggle."""
ids = tidal_strip_ids(session)
if not ids:
print("take-rec arm: no session XML — cannot map strips; "
"arm the Tidal tracks by hand in the Mixer", file=sys.stderr)
for n in orbits:
if n in ids:
if not dry:
_osc_send(osc_encode("/strip/recenable", ids[n], 1))
print(f"arm orbit {n:2d} strip id {ids[n]}")
else:
print(f"arm orbit {n:2d} NO STRIP — arm it by hand", file=sys.stderr)
if dry:
return 0
last = latest_rec_line()
master_on = bool(last and last.get("rec"))
if master_on:
print("arm: master rec already ON (gig-log saw rec=True) — "
"toggle untouched")
else:
t0 = time.time()
_osc_send(osc_encode("/rec_enable_toggle"))
d = rec_opinion(t0, timeout=2.0)
# A transition to rec=False under our toggle means the flip went
# the wrong way; one corrective toggle, then stop guessing.
if d is not None and not d.get("rec"):
_osc_send(osc_encode("/rec_enable_toggle"))
d = rec_opinion(time.time() - 1.0, timeout=2.0)
if d is not None and d.get("rec"):
master_on = True
_osc_send(osc_encode("/transport_play", 1))
if master_on:
print("arm: Ardour RECORDING (master on + transport rolling)")
return 0
print("arm: master rec state unconfirmed — check Ardour's record "
"button. The stem checks below still decide.", file=sys.stderr)
return 1
# ---- verify: the anti-D8 net ----------------------------------------------
def _stem_snapshot(session: Path) -> dict[int, tuple[Path, int]]:
"""{orbit: (newest %L file, size)} among capture files touched in the
last 10 min — old takes must not read as "growing"."""
audiofiles = session / "interchange" / session.name / "audiofiles"
out: dict[int, tuple[Path, int]] = {}
if not audiofiles.is_dir():
return out
cutoff = time.time() - 600
for f in audiofiles.glob("*.wav"):
m = _take_lens.SRC_RE.search(f.name)
if not m or m.group(4) != "L":
continue
st = f.stat()
if st.st_mtime < cutoff:
continue
orbit = int(m.group(2))
prev = out.get(orbit)
if prev is None or st.st_mtime > prev[0].stat().st_mtime:
out[orbit] = (f, st.st_size)
return out
def verify(orbits: list[int], session: Path = SESSION,
delta_s: float = 2.0) -> int:
"""Per-orbit verdict from the only truth that matters: does the capture
file EXIST and is it GROWING. Exit 1 if any orbit fails.
Lessons of the first live REC test (2026-09-28, Take105): Ardour writes
capture in BLOCKS (~512 KB per 3 s here), so the caller settles 5 s after
the roll and watches 4 s -- a flat 4 s window is a real STALE. And
gig-log's `rec=True src=files` is NOT a per-orbit verdict: it said True
while 8 of 12 stems were digital zero. Growth says "armed"; only the
samples say "sound"."""
snap1 = _stem_snapshot(session)
time.sleep(delta_s)
snap2 = _stem_snapshot(session)
rec = latest_rec_line()
if rec:
age = time.time() - rec.get("t", 0)
print(f"gig-log: rec={rec.get('rec')} arm={rec.get('arm')} "
f"roll={rec.get('roll')} src={rec.get('src')} "
f"({age:.0f}s ago, by={rec.get('by', 0)/1e6:.0f} MB)")
else:
print("gig-log: no rec line — Ardour has not rolled since the "
"service started", file=sys.stderr)
bad = 0
for n in orbits:
f1 = snap1.get(n)
f2 = snap2.get(n)
if f2 is None:
print(f"orbit {n:2d}: MISSING — no capture file: not armed or "
"not routed (the D8 case). Arm it in the Mixer.")
bad += 1
continue
path, grew = f2[0], f2[1] - (f1[1] if f1 and f1[0] == f2[0] else 0)
if grew > 0:
print(f"orbit {n:2d}: ok +{grew} B in {delta_s:.0f}s "
f"({path.name})")
else:
print(f"orbit {n:2d}: STALE — file exists, nothing growing: "
f"{path.name}")
bad += 1
if bad:
print(f"verify: {bad}/{len(orbits)} orbit(s) NOT recording", file=sys.stderr)
return 1
print(f"verify: all {len(orbits)} orbits recording")
return 0
# ---- summary --------------------------------------------------------------
def summary(session: Path = SESSION) -> int:
takes = _take_lens.find_takes(session)
if not takes:
print("summary: no takes found", file=sys.stderr)
return 1
n = max(takes)
t = takes[n]
have = sorted(t["orbits"])
missing = [x for x in expected_orbits(session) if x not in have]
print(f"Take{n}: {t['dur_s']:.1f}s "
f"window {time.strftime('%H:%M:%S', time.localtime(t['start']))}"
f"–{time.strftime('%H:%M:%S', time.localtime(t['end']))} "
f"orbits {len(have)}: {have}")
if missing:
print(f" MISSING orbits: {missing}")
return 1
return 0
def expected_orbits(session: Path = SESSION) -> list[int]:
"""The orbits this SESSION actually has — Take105's first live REC test
proved 1-14 is a guess: Tidal Live holds 12 Tidal tracks, so 13/14
false-alarmed MISSING on a healthy take. The session XML decides."""
ids = tidal_strip_ids(session)
return sorted(ids) if ids else list(DEFAULT_ORBITS)
def halt() -> int:
"""Stop the transport — the one thing rec-up's stop must never forget:
a rolling transport with armed tracks IS still recording, whatever the
video legs are doing (measured live 2026-09-28: PLN stopped 'the rec',
Ardour kept rolling)."""
_osc_send(osc_encode("/transport_stop"))
print("take-rec halt: transport_stop sent")
return 0
def main(argv: list[str]) -> int:
import argparse
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("cmd", choices=("arm", "verify", "summary", "halt"))
ap.add_argument("--orbits", default=None,
help="comma list of orbit numbers (default: the session's "
"Tidal strips)")
ap.add_argument("--session", type=Path, default=SESSION)
ap.add_argument("--delta", type=float, default=2.0,
help="verify: seconds to watch file growth")
ap.add_argument("--dry", action="store_true",
help="arm: map strips and print, send nothing")
a = ap.parse_args(argv)
orbit_src = a.orbits or ",".join(str(i) for i in expected_orbits(a.session))
orbits = [int(x) for x in orbit_src.split(",") if x.strip()]
if a.cmd == "arm":
return arm(orbits, session=a.session, dry=a.dry)
if a.cmd == "verify":
return verify(orbits, session=a.session, delta_s=a.delta)
if a.cmd == "halt":
return halt()
return summary(session=a.session)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
"""Tests for tools/take-rec.py — the REC button's brain.
The three properties that actually matter:
1. STRIP MAPPING: the session XML's route order is the one guess in the
file, so it must survive busses and non-Tidal routes — an off-by-one
here arms the WRONG track, which is worse than arming none.
2. VERIFY IS FILES, NOT HOPES: a missing stem must say MISSING even when
every UI would say fine (the D8 case), and a growing file must say ok.
3. NO PORT WARS: nothing here may bind UDP 3820 — gig-log owns it.
"""
import importlib.util
import json
import os
import sys
import threading
import time
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
spec = importlib.util.spec_from_file_location(
"_take_rec", HERE.parent / "take-rec.py")
tr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tr)
# ---- 1. strip mapping -------------------------------------------------------
ARDOUR_XML = """<?xml version="1.0"?>
<Mixer>
<Routes>
<Route name="Click"/>
<Route name="Tidal 01"/><Route name="Tidal 02"/>
<Route name="Mixbus A"/>
<Route name="Tidal 08"/>
</Routes>
</Mixer>
"""
def test_strip_ids_skip_non_tidal_routes(tmp_path):
sess = tmp_path / "Tidal Live" # the .ardour file is <dirname>.ardour
sess.mkdir()
(sess / "Tidal Live.ardour").write_text(ARDOUR_XML)
ids = tr.tidal_strip_ids(sess)
# 1-based over ALL routes: Click=1, Tidal 01=2, Tidal 02=3, Mixbus=4, 08=5
assert ids == {1: 2, 2: 3, 8: 5}
def test_strip_ids_absent_session_is_empty_not_crash(tmp_path):
assert tr.tidal_strip_ids(tmp_path) == {}
# ---- 2. verify reads files --------------------------------------------------
def make_session(tmp_path, orbit_files):
sess = tmp_path / "Sess"
af = sess / "interchange" / "Sess" / "audiofiles"
af.mkdir(parents=True)
now = time.time()
for name in orbit_files:
f = af / name
f.write_bytes(b"\0" * 1000)
os.utime(f, (now, now))
return sess
def test_verify_screams_missing_orbit_per_number(tmp_path, capsys):
sess = make_session(tmp_path, ["Take9_Tidal 01-1%L.wav"])
rc = tr.verify([1, 8], session=sess, delta_s=0.1)
captured = capsys.readouterr()
out, err = captured.out, captured.err
assert rc == 1
# orbit 1's file exists but nothing grows it — STALE is the honest verdict
assert "STALE" in out and "orbit 1" in out
assert "orbit 8: MISSING" in out
assert "2/2 orbit(s) NOT recording" in err
def test_verify_sees_growth_not_stale(tmp_path, capsys):
sess = make_session(tmp_path, ["Take9_Tidal 03-1%L.wav"])
def grow():
time.sleep(0.05)
f = sess / "interchange" / "Sess" / "audiofiles" / "Take9_Tidal 03-1%L.wav"
with open(f, "ab") as fh:
fh.write(b"\0" * 5000)
threading.Thread(target=grow).start()
rc = tr.verify([3], session=sess, delta_s=0.2)
out = capsys.readouterr().out
assert rc == 0
assert "ok" in out and "+5000" in out
def test_verify_ignores_old_takes(tmp_path, capsys):
sess = make_session(tmp_path, ["Take3_Tidal 02-1%L.wav"])
old = sess / "interchange" / "Sess" / "audiofiles" / "Take3_Tidal 02-1%L.wav"
os.utime(old, (time.time() - 3600, time.time() - 3600)) # an hour ago
rc = tr.verify([2], session=sess, delta_s=0.1)
assert rc == 1 # stale files are not recording
# ---- gig-log lens (no port binding) ------------------------------------------
def test_latest_rec_line_reads_newest_log(tmp_path, monkeypatch):
old = tmp_path / "gig-20260101-000000.jsonl"
new = tmp_path / "gig-20260102-000000.jsonl"
old.write_text(json.dumps({"k": "rec", "rec": True, "t": 1.0}) + "\n")
new.write_text(json.dumps({"k": "cc", "v": 3}) + "\n"
+ json.dumps({"k": "rec", "rec": False, "t": 2.0}) + "\n")
os.utime(old, (1_000, 1_000)) # mtime picks the file; ties are
os.utime(new, (2_000, 2_000)) # otherwise filesystem luck
monkeypatch.setattr(tr, "LOG_DIR", tmp_path)
d = tr.latest_rec_line()
assert d is not None and d["rec"] is False and d["t"] == 2.0
def test_latest_rec_line_no_logs_is_none(tmp_path, monkeypatch):
monkeypatch.setattr(tr, "LOG_DIR", tmp_path)
assert tr.latest_rec_line() is None
def test_nothing_binds_udp_3820():
"""The one hard rule: gig-log owns 3820. The mechanical guard is not the
string (docs mention the port) but the SOCKET calls: this file sends,
it never binds."""
src = (HERE.parent / "take-rec.py").read_text()
assert ".bind(" not in src
assert ".sendto(" in src # the only socket verb allowed
# ---- summary -----------------------------------------------------------------
def test_summary_reports_take_and_missing_orbits(tmp_path, capsys):
sess = make_session(tmp_path, ["Take12_Tidal 01-1%L.wav",
"Take12_Tidal 02-1%L.wav"])
rc = tr.summary(session=sess)
out = capsys.readouterr().out
assert "Take12" in out and "orbits 2" in out
assert rc == 1 # 12 of 14 missing
def test_summary_no_takes(tmp_path, capsys):
(tmp_path / "Sess").mkdir()
rc = tr.summary(session=tmp_path / "Sess")
assert rc == 1
assert "no takes" in capsys.readouterr().err
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