Commit 97b22c0b by PLN (Algolia)

feat(gig-log): record what was PLAYED, not what was looked at

#150, and the fix at the source of #148.

gig-log recorded tab-focus events -- which file PLN was LOOKING at. Album
track boundaries were then derived from those, and they were wrong, because a
livecoder tabs around constantly while a pattern keeps running. Looking at a
file is not playing it.

An EVAL is the moment a pattern actually changes. That is the honest boundary.

SIGNAL SOURCE

`atom.commands.onDidDispatch` in the HUD package, filtered to `tidalcycles:eval*`.
Confirmed ctrl+enter maps to `tidalcycles:eval-multi-line` in Pulsar's own
keymap, and that MULTI_LINE sends exactly the blank-line-delimited paragraph
around the cursor -- which is already this repo's block convention. onDidDispatch
rides Atom's EXISTING command routing, so it adds no new per-keystroke listener:
it cannot make the renderer burn worse.

Rejected: GHCi stdout (no stable per-eval marker without changing the REPL
protocol) and SuperDirt OSC sniffing (needs its own listener process -- closer
to the observer-perturbs trap this rig has already been burned by).

SHAPE

New `k:"eval"` record {t, path, d}, where d is a best-effort list of dN streams
found by a narrow regex: dN as the FIRST token of a line, inside the evaluated
block only. Documented as best-effort rather than dressed up as complete.

Transport is ~/.cache/parvagues/eval-events.jsonl, APPEND-only -- unlike
current-track, which overwrites. An eval is an EVENT, in the same category as a
MIDI note, never coalesced; current-track is STATE. Conflating the two is how
the focus lens got mistaken for a play lens in the first place.

`EvalTail` polls it once per tick with a byte-offset seek, buffers torn lines,
and rebaselines if the file shrinks or is recreated -- the same pattern
XrunReader already uses for node respawns. It never replays pre-session content.

`track` events are UNTOUCHED. This adds a lens; it does not replace one.
Boundary detection wants several (gap < orbit-flip < tempo), and focus is still
the cheapest of them.

BLAST RADIUS

Folded into load()'s existing marks list exactly as `track` is, so cmd_report
renders it for free and load()'s tuple signature does not change. The one real
consumer, tools/take-segments.py, reads the JSONL itself and already ignores
any `k` it does not recognise -- verified by reading it, not assumed.

VALIDATION

  111/111 python tests pass (91 pre-existing + 20 new), covering the pure
  normalizer, the tail's offset / torn-line / rebaseline behaviour, and a full
  write -> load round trip.

NOT VERIFIED: whether the running gig-log.service picks this up without a
restart. It was deliberately left alone -- PLN was performing.
parent e7961f57
...@@ -43,7 +43,15 @@ RECORD KINDS (the `k` field) ...@@ -43,7 +43,15 @@ RECORD KINDS (the `k` field)
s one per tick: thermals, freq, cpu, gear, xrun deltas s one per tick: thermals, freq, cpu, gear, xrun deltas
cc coalesced MIDI control-change, per (port, channel, controller) per second cc coalesced MIDI control-change, per (port, channel, controller) per second
note MIDI note, never coalesced (a note is an event, a CC is a state) note MIDI note, never coalesced (a note is an event, a CC is a state)
track the loaded .tidal changed (from Pulsar, #84) — written ONLY on change track the loaded .tidal changed (from Pulsar, #84) — written ONLY on change.
This is a FOCUS signal: which file was on screen. Looking at a file
is not playing it (#148) — do not derive track boundaries from this
alone.
eval a real ctrl+enter: `tidalcycles:eval*` fired and code was actually
sent to GHCi (from Pulsar, #150). Carries the file path and, if the
cheap regex found any, which dN stream(s) the evaluated block
defines. This is the honest boundary signal `track` isn't — an eval
means the pattern changed, not just that the tab was in focus.
mark a human annotation from `gig-log.py mark` mark a human annotation from `gig-log.py mark`
end once, at stop end once, at stop
...@@ -101,6 +109,22 @@ TRACK_FILE = Path(os.environ.get( ...@@ -101,6 +109,22 @@ TRACK_FILE = Path(os.environ.get(
"PARVAGUES_TRACK_FILE", "PARVAGUES_TRACK_FILE",
os.path.expanduser("~/.cache/parvagues/current-track"))) os.path.expanduser("~/.cache/parvagues/current-track")))
# Where Pulsar publishes real EVAL events (#150, pulsar-parvagues-hud). `track`
# above answers "what file was PLN LOOKING at" — a tab flicker changes it with
# zero code sent. #148 built take boundaries out of that signal and got them
# wrong, because a livecoder tabs around constantly while a pattern keeps
# running. `eval` is the actual ctrl+enter: the moment `tidalcycles:eval*`
# fires and code is actually sent to GHCi. That is the honest boundary signal.
#
# Unlike current-track (a STATE file, overwritten on change), this is an
# APPEND log: an eval is an EVENT, same category as a MIDI note (never
# coalesced away) and NOT like a CC (state, fine to overwrite) — two evals
# landing in the same 1 Hz tick must both survive, and a livecoder re-hitting
# ctrl+enter to fix a typo is exactly that case.
EVAL_FILE = Path(os.environ.get(
"PARVAGUES_EVAL_FILE",
os.path.expanduser("~/.cache/parvagues/eval-events.jsonl")))
def _read_track() -> str | None: def _read_track() -> str | None:
"""The repo-relative .tidal path Pulsar says is loaded, or None. """The repo-relative .tidal path Pulsar says is loaded, or None.
...@@ -115,6 +139,84 @@ def _read_track() -> str | None: ...@@ -115,6 +139,84 @@ def _read_track() -> str | None:
return None return None
return s or None return s or None
class EvalTail:
"""Poll-based tail of EVAL_FILE — the pure-append counterpart of `track`.
Same non-perturbing contract as everything else here: one stat+seek+read
per tick, no watch, no thread. Cheap for the same reason `_read_track` is —
evals are human-paced (at most a few per second on a frantic edit, usually
a few per MINUTE), nowhere near the MIDI CC rate this file already proves
is affordable to poll.
Starts from EOF at construction so a fresh recording never replays a
previous session's evals — the same reasoning as `_pkg_base`: a session
reports what happened DURING it, not the file's whole history.
"""
def __init__(self, path: Path = EVAL_FILE):
self.path = path
self._pos = 0
try:
self._pos = path.stat().st_size
except OSError:
pass
self._partial = ""
def poll(self) -> list[dict]:
try:
size = self.path.stat().st_size
except OSError:
return []
if size < self._pos:
# The publisher rotated/recreated the file (or it was cleared) —
# rebaseline rather than seek past EOF forever. Same rule as a
# PipeWire node reappearing with a fresh counter (XrunReader).
self._pos = 0
self._partial = ""
try:
with self.path.open("r", errors="replace") as f:
f.seek(self._pos)
chunk = f.read()
self._pos = f.tell()
except OSError:
return []
if not chunk:
return []
lines = (self._partial + chunk).split("\n")
self._partial = lines.pop() # keep a torn trailing line for next tick
out = []
for line in lines:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue # a half-written line — degrade, don't stop
return out
def _eval_record(raw: dict) -> dict | None:
"""Normalize one EVAL_FILE line into a gig-log `k:"eval"` record, or None.
Defensive on every field: this file is written by a DIFFERENT process
(Pulsar/Node), so a schema drift or a half-written line must drop this one
record, never the recorder — the same contract as `_read_track`. Do NOT
invent fields the publisher didn't actually send: `d` (the dN streams the
evaluated block defines) is best-effort and may legitimately be empty
(e.g. a `hush`-adjacent helper block, or a block the cheap regex misses).
"""
if not isinstance(raw, dict):
return None
t, path_ = raw.get("t"), raw.get("path")
if not isinstance(t, (int, float)) or not isinstance(path_, str) or not path_:
return None
d_raw = raw.get("d")
d = sorted({s for s in d_raw if isinstance(s, str)}) if isinstance(d_raw, list) else []
return {"t": round(float(t), 3), "k": "eval", "path": path_, "d": d}
# Gear we care about, by /proc/<pid>/comm. A value of `True` means "sum the whole # Gear we care about, by /proc/<pid>/comm. A value of `True` means "sum the whole
# process group" — pulsar is 8 processes (main, GPU, renderers) and the interesting # process group" — pulsar is 8 processes (main, GPU, renderers) and the interesting
# number is the total, with the worst single one called out separately. # number is the total, with the worst single one called out separately.
...@@ -517,6 +619,10 @@ class Recorder: ...@@ -517,6 +619,10 @@ class Recorder:
self._pkg_base = _package_throttle_sum() self._pkg_base = _package_throttle_sum()
self.xr = XrunReader() if (xruns and XrunReader.available()) else None self.xr = XrunReader() if (xruns and XrunReader.available()) else None
self.mid = MidiReader(midi_port) if (midi and MidiReader.available()) else None self.mid = MidiReader(midi_port) if (midi and MidiReader.available()) else None
# Always on, like track polling: EVAL_FILE may not exist yet (the HUD
# writes it lazily, same as current-track), and EvalTail degrades to
# "nothing new" rather than needing an availability check.
self.ev = EvalTail()
self.path = self._open() self.path = self._open()
self.samples = 0 self.samples = 0
self._stop = threading.Event() self._stop = threading.Event()
...@@ -630,6 +736,10 @@ class Recorder: ...@@ -630,6 +736,10 @@ class Recorder:
tr = self.poll_track() tr = self.poll_track()
if tr: if tr:
self._write(tr) self._write(tr)
for raw in self.ev.poll():
rec = _eval_record(raw)
if rec:
self._write(rec)
if self.mid: if self.mid:
cc, disc = self.mid.drain() cc, disc = self.mid.drain()
for r in disc: for r in disc:
...@@ -923,6 +1033,17 @@ def load(path: Path) -> tuple[dict, list[dict], list[dict], list[dict], dict]: ...@@ -923,6 +1033,17 @@ def load(path: Path) -> tuple[dict, list[dict], list[dict], list[dict], dict]:
r = dict(r) r = dict(r)
r["label"] = f"▸ {r.get('path') or '(none)'}" r["label"] = f"▸ {r.get('path') or '(none)'}"
marks.append(r) marks.append(r)
elif k == "eval":
# #150 — an eval is a mark too, and the MOST reliable one of
# all: not "PLN was looking at this file" (track, which #148
# got burned trusting for boundaries) but "PLN just sent this
# code to Tidal". Folded into `marks` the same way `track` is;
# `k` stays "eval" so a splitter can rank lenses (an eval beats
# a track-change beats a bare focus glance).
r = dict(r)
dn = ",".join(r.get("d") or []) or "?"
r["label"] = f"⏵ eval {dn} {r.get('path') or '(unknown)'}"
marks.append(r)
elif k == "end": elif k == "end":
end = r end = r
return hdr, samples, ccs, marks, end return hdr, samples, ccs, marks, end
......
...@@ -817,3 +817,158 @@ def test_a_stale_log_must_FAIL_preflight_rather_than_pass(tmp_path, capsys): ...@@ -817,3 +817,158 @@ def test_a_stale_log_must_FAIL_preflight_rather_than_pass(tmp_path, capsys):
) )
assert gl.cmd_preflight(log) == 2 assert gl.cmd_preflight(log) == 2
assert "NOT running" in capsys.readouterr().err assert "NOT running" in capsys.readouterr().err
# --------------------------------------------------------------------------- #
# eval events (#150) — the ctrl+enter signal, replacing focus-only boundaries
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("raw,want", [
({"t": 1785350744.4591, "path": "live/techno/do_it_right.tidal", "d": ["d1", "d3"]},
{"t": 1785350744.459, "k": "eval", "path": "live/techno/do_it_right.tidal",
"d": ["d1", "d3"]}),
# dN list absent entirely — the publisher's regex found nothing. Must not
# be invented; an empty list, not a guess.
({"t": 1785350744.0, "path": "live/techno/do_it_right.tidal"},
{"t": 1785350744.0, "k": "eval", "path": "live/techno/do_it_right.tidal", "d": []}),
# duplicates and out-of-order dN from the publisher's regex are normalized
({"t": 1.0, "path": "x.tidal", "d": ["d3", "d1", "d3"]},
{"t": 1.0, "k": "eval", "path": "x.tidal", "d": ["d1", "d3"]}),
])
def test_eval_record_normalizes_a_well_formed_line(raw, want):
assert gl._eval_record(raw) == want
@pytest.mark.parametrize("raw", [
{}, # empty
{"path": "x.tidal"}, # no t
{"t": 1.0}, # no path
{"t": "now", "path": "x.tidal"}, # t is not a number
{"t": 1.0, "path": ""}, # empty path
{"t": 1.0, "path": 123}, # path is not a string
"not a dict",
None,
])
def test_eval_record_drops_malformed_input_instead_of_crashing_the_recorder(raw):
# This file is written by a DIFFERENT process (Pulsar/Node). A schema
# mismatch must degrade this one record, exactly like _read_track
# degrades a half-written current-track file — never take the recorder
# down over a line it doesn't own the writing of.
assert gl._eval_record(raw) is None
def test_eval_record_ignores_non_string_entries_in_d_without_crashing():
raw = {"t": 1.0, "path": "x.tidal", "d": ["d1", 2, None, "d2"]}
assert gl._eval_record(raw) == {"t": 1.0, "k": "eval", "path": "x.tidal",
"d": ["d1", "d2"]}
def test_eval_record_degrades_a_malformed_d_to_empty_rather_than_dropping_the_event():
# t and path are the load-bearing fields; `d` is best-effort ("if cheaply
# available"). A publisher bug in the optional field must not cost the
# whole eval — that would be worse than the #148 bug this replaces.
raw = {"t": 1.0, "path": "x.tidal", "d": "d1"} # d is a string, not a list
assert gl._eval_record(raw) == {"t": 1.0, "k": "eval", "path": "x.tidal", "d": []}
def test_eval_tail_never_replays_lines_written_before_it_started(tmp_path):
"""Mirrors the `_pkg_base` rule: a fresh recording reports what happens
DURING the session, not the publish file's whole history."""
f = tmp_path / "eval-events.jsonl"
f.write_text('{"t": 1.0, "path": "old.tidal", "d": []}\n')
tail = gl.EvalTail(f)
assert tail.poll() == [] # nothing new yet
with f.open("a") as fh:
fh.write('{"t": 2.0, "path": "new.tidal", "d": ["d1"]}\n')
got = tail.poll()
assert len(got) == 1 and got[0]["path"] == "new.tidal"
def test_eval_tail_returns_every_line_appended_since_the_last_poll(tmp_path):
"""Two evals in the same 1 Hz tick must BOTH survive — an eval is an
EVENT (like a MIDI note), not a STATE (like current-track)."""
f = tmp_path / "eval-events.jsonl"
f.write_text("")
tail = gl.EvalTail(f)
with f.open("a") as fh:
fh.write('{"t": 1.0, "path": "a.tidal", "d": ["d1"]}\n')
fh.write('{"t": 1.2, "path": "a.tidal", "d": ["d2"]}\n')
got = tail.poll()
assert [g["t"] for g in got] == [1.0, 1.2]
assert tail.poll() == [] # drained, not re-read
def test_eval_tail_holds_a_torn_trailing_line_for_the_next_poll(tmp_path):
"""The HUD writes with a single fs.writeFileSync per line, but a tick can
still land mid-write on a slow disk. A torn line must cost one eval, not
desync the tail forever."""
f = tmp_path / "eval-events.jsonl"
f.write_text('{"t": 1.0, "path": "a.tidal", "d": []}\n')
tail = gl.EvalTail(f)
with f.open("a") as fh:
fh.write('{"t": 2.0, "path": "b.tidal",') # torn — no newline yet
assert tail.poll() == [] # nothing COMPLETE yet
with f.open("a") as fh:
fh.write(' "d": ["d4"]}\n') # completed next tick
got = tail.poll()
assert len(got) == 1 and got[0]["d"] == ["d4"]
def test_eval_tail_rebaselines_if_the_publish_file_is_recreated_smaller(tmp_path):
"""Same rule as XrunReader._baseline for a node that respawns with a
fresh counter: a size going BACKWARDS means a new file, not corruption."""
f = tmp_path / "eval-events.jsonl"
f.write_text('{"t": 1.0, "path": "a.tidal", "d": []}\n' * 5)
tail = gl.EvalTail(f)
f.write_text('{"t": 9.0, "path": "fresh.tidal", "d": ["d9"]}\n') # shorter
got = tail.poll()
assert len(got) == 1 and got[0]["path"] == "fresh.tidal"
def test_eval_tail_is_silent_when_the_publish_file_does_not_exist_yet(tmp_path):
"""The HUD writes it lazily (same as current-track) — absence must
degrade the recorder, never stop it."""
tail = gl.EvalTail(tmp_path / "does-not-exist.jsonl")
assert tail.poll() == []
def test_eval_events_reach_the_report_loader_as_ranked_marks(tmp_path):
"""The WRITE path (recorder -> jsonl) through the PARSE path (load()),
exactly as the recorder's run loop drives it — not just the helper
functions in isolation."""
eval_file = tmp_path / "eval-events.jsonl"
r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
r.ev = gl.EvalTail(eval_file) # point the recorder at our fixture
r._write(r.header())
r._write(r.sample())
# Written AFTER the tail exists, exactly like the live rig: the HUD
# appends while gig-log's recorder is already running and polling.
with eval_file.open("a") as fh:
fh.write('{"t": 100.5, "path": "live/techno/do_it_right.tidal", "d": ["d1"]}\n')
fh.write('{"t": 101.0, "path": "live/techno/do_it_right.tidal", "d": []}\n')
for raw in r.ev.poll(): # exactly what Recorder.run() does per tick
rec = gl._eval_record(raw)
if rec:
r._write(rec)
r.f.close()
_, _, _, marks, _ = gl.load(r.path)
evals = [m for m in marks if m.get("k") == "eval"]
assert len(evals) == 2
assert evals[0]["d"] == ["d1"]
assert evals[0]["label"] == "⏵ eval d1 live/techno/do_it_right.tidal"
# no dN found — must say so honestly (?), never fabricate one
assert evals[1]["label"] == "⏵ eval ? live/techno/do_it_right.tidal"
def test_track_and_eval_marks_coexist_the_new_lens_does_not_replace_the_old(tmp_path):
"""#150 ADDS a lens; `track` (focus) must still come through untouched —
a prior finding established boundary detection wants MULTIPLE lenses."""
r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
r._write(r.header())
r._write({"t": 10.0, "k": "track", "path": "a.tidal", "from": None})
r._write(gl._eval_record({"t": 11.0, "path": "a.tidal", "d": ["d2"]}))
r.f.close()
_, _, _, marks, _ = gl.load(r.path)
kinds = [m["k"] for m in marks]
assert kinds == ["track", "eval"]
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