Commit a8c2af8c by PLN (Algolia)

Merge remote-tracking branch 'origin/claude/rig-streamline'

parents efdb09f1 71403a25
> **RESOLVED 2026-09-05** — see `042-the-instrument-was-the-bug.md`.
> The blocker's premise was wrong: `is_running()` measures correctly. The
> double-launch is now prevented structurally (`3c00a9c`) and the rewire is
> reimplemented and live (`9f8d900`) — the saved patch had gone stale against
> `90afa23` and was not used.
--- ---
log: 041 log: 041
title: "PARKED — GIG UP rewire, blocked on Ardour double-launch" title: "PARKED — GIG UP rewire, blocked on Ardour double-launch"
......
---
log: 042
title: "Four blockers, three broken instruments"
date: 2026-09-05
task: "n/a loose-thread sweep after the play session"
tags: [tooling, rig, gate, ardour, midi]
shareable: true
---
## Cap (what & why)
Close every loose thread left open by the gear-failure night: the Ardour
double-launch that killed both instances, midiviz dying with the board, the four
standing NO-GO blockers on the gate, and a suspected memory leak. The rig had to
stop being something you debug before you can play it.
## Manœuvre (how)
Reproduced before repairing, every time. That order is the whole story of this
log: **three of the four things reported as broken were the measuring tool, not
the rig.**
1. **The Ardour double-launch.** Parked as "fix `is_running()` first". Measured
it cold with Ardour up: it returns `True`. argv0 is `ardour-9.7.0` (the
`/usr/bin/ardour9` wrapper is a shell script that exec's the real binary) and
the regex matches it fine. The false negative is not reproducible and nothing
logged the decision, so the cause is gone for good. So the fix stopped being
"repair the probe" and became "remove the probe's veto over an irreversible
action": a pidfile spawn guard in `$XDG_RUNTIME_DIR` (a file, not a module
global — the tray and the Bridge are two processes), identity-rechecked so a
recycled pid can't jam it shut, plus a log line per decision.
2. **"boot helpers FAIL".** `check-boot.sh` alone passed every line green while
gig-up called the same check FAIL. Under gig-up it crashed on
`int('6C1o')` — two output lines interleaved character-wise. stdout and
stderr are deliberately one merged pipe (separate capture would attribute
every error to the last block), but GHC block-buffers stdout and writes
errors unbuffered, so a marker got flushed into a chunk a stderr write had
already cut into.
3. **"surface grid drift".** Ruled out the CosmicFest false positive first —
`check-drift.sh` passes and clears all five of PLN's modified files
individually. The real failure was a migration debt: `b5ad8b6` ("finish the
#94 column remap") never touched four files, three last edited two days
before it. Direction, not volume.
4. **The 2.6 GB perf-tray leak.** systemd's own accounting: 59.6 M peak over
2h15m. The hand-off had misread virtual size as RSS. Nothing to fix.
## Prise (findings / artifacts)
- `3c00a9c` — spawn guard + launch logging + superseded-`.pending` sweep;
`tools/ardour-tidal-live.desktop` kills the session chooser at every entry
point (the default click can no longer open the ARCHIVE session by mistake).
- `e9d1631` — midiviz survives an unplug **in-process**: main PID unchanged,
`NRestarts` 0, a fresh `aseqdump` child inside one 2 s tick. It had been
exiting with status 0, which is why `Restart=on-failure` never fired.
- `e47361c` — two typos in `something_about_drums.tidal`: a stray `d1` after a
closing paren (killing the whole d2 block) and `d8 $ gF1 $ gM1` (d8 wired to
d1's mute family, so d8's mute would have taken d1 with it, live). Plus the
buffering fix, with a planted unbalanced paren proving the checker still
catches and correctly attributes a real error.
- `9381e22` — the #94 remap finished on three files. Two were **swaps**
(`^52<->^32`, `^89<->^57`); verified the rewriter resolves each line in one
`re.sub` pass against the original text, so a swap is atomic and doesn't
collapse onto one control.
- `9f8d900` — 🌊 Launch Gig → **GIG UP**, driving the same reconciler as the
Bridge. Verified: `ardour: running → focus`, ArdourGUI count 1 before and
after. The line this thread existed to produce.
- `tools/ardour-drop-missing-sources.py` — new. Ardour's "Missing File" modal
can't be suppressed by a flag, and "skip all missing" doesn't fix it: silent
stubs load, the dead names stay in the session file, the modal returns every
launch. 24 dead sources found, all one take, 12 orbits × L/R.
- Gate: **4 blockers → 2**, both now waiting on PLN's judgement rather than on
code.
## Sel (the shareable learning)
- A green check run by hand and a red check run by the gate can both be honest
about the same rig. The difference was buffering: **the merged pipe that makes
error attribution possible is the same merged pipe that lets a marker line get
torn in half.** The fix isn't to unmerge, it's to make each write atomic and
the marker impossible to misread when it isn't.
- When a probe wrongly vetoes an irreversible action *once*, unrecoverably and
unlogged, repairing the probe is the wrong instinct. Measure it: if it's fine,
the defect is that a single probe had that authority at all.
- A safety rule that makes the safe path invisible protects nothing. #116 kept
Launch Gig editor-only; the name still promised a gig, so it read as broken,
and the real launcher went unpressed for 6.7 days while the launch moved to a
hand-typed command. Arming beats crippling.
- The first version of the missing-source tool rglobbed the Freebox once per
missing file — 24 full network traversals. It never finished. One indexed walk
instead, with the deadline checked per directory, because "I didn't find it"
and "I didn't finish looking" are different answers and only one of them
justifies dropping the last reference to a lost take.
## Hameçon (hook)
Four things were broken. Three of them were the instruments. The night's real
bug count was one stray `d1`.
## Sillage (what it unlocks)
GIG UP is now a button that means it. The gate's remaining two failures are
taste calls, not defects: one fader 5.5 dB off baseline, and one track whose
grid migration would comment out live `d3` lines. Both are PLN's to make.
---
log: 043
title: "Four decisions parked for PLN, and where the branch actually is"
date: 2026-09-05
task: "n/a resume point"
tags: [rig, gate, ardour, resume]
shareable: false
---
## Cap (what & why)
PLN folded tired and asked for the open questions to be written down rather than
answered. These are the things that are NOT code problems — every one of them
needs a human judgement, and guessing at any of them would change how the rig
sounds or what audio exists.
## Prise — the four decisions
**1. Fader baseline: `Tidal 10` is 5.5 dB down.**
`tools/fader-baseline.py --check``Tidal 10 0.1 dB baseline 5.6 dB`. One
fader, nothing else drifted. Two answers, both one command:
- it was a mistake → `tools/fader-baseline.py --restore` (Ardour must be closed)
- those are the levels you want → `tools/fader-baseline.py --capture`
Do NOT auto-restore. The last time a fader report was trusted without asking, it
was measuring the archive session. Blocks gig-up.
**2. `something_about_drums.tidal`: the grid migration would silence part of d3.**
`migrate-columns.py --plan` wants 5 moves in this file plus **1 overflow**
d3's `^44` has no slot in the new grid. The tool's remedy for an overflow is to
comment out every live line of that orbit and head it with a FIXME. That is a
musical change, not a renumbering, so it was deliberately left alone (the other
three files were migrated in `9381e22`). Either rewire d3's `^44` onto a free
control by hand, or accept the FIXME. Blocks gig-up's "surface grid intact".
**3. The Missing File modal: 24 dead sources, ready to drop, needs Ardour closed.**
Confirmed unrecoverable: all 24 are Take101 (12 orbits × L/R), absent locally and
absent from the Freebox mirror. That take's `.mid` files survive; only the audio
is gone. One command, once Ardour is quit:
python3 tools/ardour-drop-missing-sources.py \
~/Work/Sound/Ardour/"Tidal Live"/"Tidal Live.ardour" --apply
It backs the session up first, refuses while Ardour is running, and refuses if the
Freebox is unmounted. Dry run verified: `would drop: 24 source(s), 24 region(s)`.
**4. The Freebox is 7 days behind, and today's take is not on it.**
The mirror's audiofiles dir last changed **2026-08-29 22:41** and holds up to
Take100. Take102 was recorded today (13:36) and exists ONLY on the laptop. Given
that the Freebox is the declared SSOT for audio, that is the real risk in this
list — and it is why nothing in the export/peaks space-reclaim thread was touched.
Run `fbk` (see `/mnt/freebox/PLN/_setup/CLAUDE-RUNBOOK.md`, `fbk doctor` first).
## Sel — where the branch is, exactly
**This is the part a cold reader will get wrong.** The shared checkout and the
branch are out of step on purpose:
- `origin/claude/rig-streamline` = **779cf17**, which includes the merge of
master (6678acd) and everything from this session.
- The **main working tree** (`~/Work/Sound/Tidal`) is still at **5468507**,
pre-merge. It was NOT fast-forwarded, because it holds another session's
uncommitted edits to `tools/check-mix.py` and `tools/gig-log.py`, and pulling
master in would have demanded stashing their in-flight work.
- The merge was done and tested in a worktree at `../Tidal-wt-merge`
(`git worktree list`). 476 tests pass there; midiviz selftest passes.
- **The live rig runs from the MAIN tree** via `~/.local/bin` symlinks, so what is
playing tonight is 5468507 — verified working. The peer's midiviz close-X and
close-latch are on the branch but not yet live locally.
**To resolve — one command, no waiting on anyone.** The first version of this log
said to wait for the peer session to commit `check-mix.py` / `gig-log.py`. That
was wrong, and the peer corrected it: those two are **not** in-flight edits. They
read as `M` only because the shared tree's HEAD (5468507) predates master's
shared-ladder refactor (`27f2351`, which changed both files to import
`tools/ardour_session.py`) — the working tree already holds the POST-refactor
content. Verified independently before amending: for both files the working-tree
blob equals `origin/master` equals `f56775a`, while only `HEAD` differs. So there
is nothing to lose and nobody to wait for.
git checkout -- tools/check-mix.py tools/gig-log.py
git merge --ff-only claude/rig-streamline
That `checkout` restores HEAD's older copy for a moment and the fast-forward
immediately replaces it with the merged version — the same content the tree holds
right now. Also checked: `HEAD` is a true ancestor of `f56775a`, and the branch
touches **none** of PLN's five modified `.tidal` files, so his set prep survives
the fast-forward untouched.
**But do it with eyes on the rig, not tonight.** The live rig runs from this tree
via `~/.local/bin` symlinks, so the fast-forward swaps code under a running
system and then wants `parvagues-bridge`, `midiviz` and `perf-tray` restarted.
Doing that with nobody watching is PLN's call, not a housekeeping step. Until it
happens, further work belongs in the worktree.
## Sillage
Everything mechanical from the gear-failure night is closed (log 042). What is
left is four judgements, one backup run, and one fast-forward.
...@@ -10,7 +10,7 @@ d1 $ gF1 $ gM1 -- Kick ...@@ -10,7 +10,7 @@ d1 $ gF1 $ gM1 -- Kick
# gain 1.2 # gain 1.2
# gain 1.8 # gain 1.8
d2 $ gF1 $ gM2 -- Snare melancolie fromagere d2 $ gF1 $ gM2 -- Snare melancolie fromagere
$ midiOff "^42" (<| "~ <s s <s!3 ~> <~!7 [~ s]>>")d1 $ midiOff "^42" (<| "~ <s s <s!3 ~> <~!7 [~ s]>>")
$ midiOn "^42" (<| "~ s ~ s*<1!3 2>") $ midiOn "^42" (<| "~ s ~ s*<1!3 2>")
$ "[drumtraks:9]" $ "[drumtraks:9]"
# gain 1.45 # gain 1.45
...@@ -78,7 +78,7 @@ d5 $ gF2 $ gM3 $ slow 2 -- V2 -- TODO : Feedback: reverb? un peu creux? ...@@ -78,7 +78,7 @@ d5 $ gF2 $ gM3 $ slow 2 -- V2 -- TODO : Feedback: reverb? un peu creux?
# modIndex (range 0 2 "^54") # modIndex (range 0 2 "^54")
# room 0.4 # room 0.4
# gain 1.7 # gain 1.7
d8 $ gF1 $ gM1 d8 $ gF1 $ gM2
$ midiOn "^92" (ply "2 <2!3 4>") $ midiOn "^92" (ply "2 <2!3 4>")
$ midiOff "^60" (mask "f(4,8)" . chop 8) $ midiOff "^60" (mask "f(4,8)" . chop 8)
$ midiOn "^36" (# "jungle_breaks:45") $ midiOn "^36" (# "jungle_breaks:45")
......
...@@ -44,7 +44,7 @@ d4 $ gF2 $ gM3 ...@@ -44,7 +44,7 @@ d4 $ gF2 $ gM3
# gain 1.4 # gain 1.4
-- # room 0.3 # sz 0.3 -- # room 0.3 # sz 0.3
-- # delay 0.6 # delayt 0.01 # delayfb 0.2 -- # delay 0.6 # delayt 0.01 # delayfb 0.2
# crushbus 41 (range 16 3.5 "^52") # crushbus 41 (range 16 3.5 "^32")
-- # bandf (range 50 1200 "^52") -- # bandf (range 50 1200 "^52")
d5 $ gF3 $ gM3 d5 $ gF3 $ gM3
$ slow 2 -- Adjusted to Sugar rhytm <3 $ slow 2 -- Adjusted to Sugar rhytm <3
......
...@@ -40,8 +40,8 @@ d4 $ gF2 $ gM3 ...@@ -40,8 +40,8 @@ d4 $ gF2 $ gM3
# room 0.4 # room 0.4
# sz 0.4 # sz 0.4
# octersub 0.4 # octersub 0.4
# crushbus 41 (range 16 4.5 "^52") -- BURN # crushbus 41 (range 16 4.5 "^32") -- BURN
# octersubbus 42 (range 0 2.5 "^32") -- DEEP # octersubbus 42 (range 0 2.5 "^52") -- DEEP
# cut 4 # cut 4
# gain 1.5 # gain 1.5
d5 $ gM3 $ gF3 -- SuperStars d5 $ gM3 $ gF3 -- SuperStars
......
...@@ -36,16 +36,16 @@ d4 -- CRIMINAL BASSLINE ...@@ -36,16 +36,16 @@ d4 -- CRIMINAL BASSLINE
# octersubbus 42 (range 0 2.13 "^52") # octersubbus 42 (range 0 2.13 "^52")
# room 0.3 # sz 0.3 # dry 1.13 # room 0.3 # sz 0.3 # dry 1.13
d5 $ gM3 $ gF3 -- THIS SYNTH IS A CRIME <3 d5 $ gM3 $ gF3 -- THIS SYNTH IS A CRIME <3
$ midiOn "^89" (struct "t(8,8)" . chop 2) $ midiOn "^57" (struct "t(8,8)" . chop 2)
$ slice 16 (slow 4 $ run 16) $ slice 16 (slow 4 $ run 16)
$ midiOn "^57" (-- LZRTAG CRIMINAL TAKEOVER <3 <3 $ midiOn "^89" (-- LZRTAG CRIMINAL TAKEOVER <3 <3
("e" ~>) . ("e" ~>) .
(>| n (slow 4 "<11!4 12!4 13!4 14!4>")) (>| n (slow 4 "<11!4 12!4 13!4 14!4>"))
. (# cps (140/60/4)) . (# cps (140/60/4))
) )
$ midiOff "^57" (# cps (120/60/4)) $ midiOff "^89" (# cps (120/60/4))
-- $ midiOn "^90" (ply 4) . (# begin 0.25) -- $ midiOn "^90" (ply 4) . (# begin 0.25)
$ midiOff "^57" ( -- Standard Crimes $ midiOff "^89" ( -- Standard Crimes
>| n (slow 4 $ "<0 0 0 1 0 1 1 2>") >| n (slow 4 $ "<0 0 0 1 0 1 1 2>")
) )
$ "crimewave" # cut 5 $ "crimewave" # cut 5
......
...@@ -31,9 +31,12 @@ Design notes ...@@ -31,9 +31,12 @@ Design notes
* PyQt5 because it ships in the Arch repos (python-pyqt5) — no extra deps. * PyQt5 because it ships in the Arch repos (python-pyqt5) — no extra deps.
""" """
import json
import subprocess import subprocess
import sys import sys
import threading
import time import time
import urllib.request
from pathlib import Path from pathlib import Path
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
...@@ -58,6 +61,7 @@ BRIDGE_URL = "http://127.0.0.1:8773/" ...@@ -58,6 +61,7 @@ BRIDGE_URL = "http://127.0.0.1:8773/"
# menu can do, so it gets its own confirm step (see PerfTray.on_sc_clicked). # menu can do, so it gets its own confirm step (see PerfTray.on_sc_clicked).
SC_UNIT = "parvagues-sc" SC_UNIT = "parvagues-sc"
SC_CONFIRM_MS = 10_000 # how long an armed restart stays armed SC_CONFIRM_MS = 10_000 # how long an armed restart stays armed
GIG_CONFIRM_MS = 10_000 # same, for a GIG UP that would start the sound
SC_READY_S = 30 # measured 2026-08-14: active→responder armed in 27 s SC_READY_S = 30 # measured 2026-08-14: active→responder armed in 27 s
...@@ -311,12 +315,26 @@ class PerfTray: ...@@ -311,12 +315,26 @@ class PerfTray:
# not focus a window. It can, through KWin's scripting bus; see # not focus a window. It can, through KWin's scripting bus; see
# launchers.focus_window. So this is a top-level entry AND a real focus. # launchers.focus_window. So this is a top-level entry AND a real focus.
# #
# Deliberately editor-only: it does not start SuperCollider, boot Tidal # PLN, 2026-09-05: "click launch gig started pulsar, not ardour??", then
# or arm Ardour. #116 is the standing rule that the hot path must not be # "is our launch gig now autodoing?".
# able to start the sound by accident — one wrong click before a set is #
# a stuck scsynth, not a convenience. # It opened Pulsar and nothing else, and that was deliberate — #116 says
self.gig_act = QAction("🌊 Launch Gig", self.menu) # the hot path must not be able to start the sound by accident. But the
self.gig_act.triggered.connect(lambda: self._launch("pulsar")) # name promised a gig, so the button read as broken rather than as
# careful, and the rig's real launcher (the Bridge's RIG UP, which
# converges every unit and then opens the apps) had not been pressed in
# 6.7 days. A safety rule that makes the safe path invisible protects
# nothing; it just moves the launch to a hand-typed command.
#
# So this now drives the SAME reconciler as the Bridge, and #116 is
# honoured by ARMING rather than by crippling — the idiom already proven
# two entries below on Restart SuperDirt. With SuperDirt already up the
# press only converges and focuses, so it is one click. With it DOWN the
# press would start the sound, so the first click arms and says so and a
# second (which means reopening the menu) commits. No confirmation
# dialog: a modal stealing focus mid-set is its own hazard (#136).
self.gig_act = QAction("🌊 GIG UP", self.menu)
self.gig_act.triggered.connect(self.on_gig_clicked)
self.menu.addAction(self.gig_act) self.menu.addAction(self.gig_act)
# PLN, 2026-08-14: "add a restart SC command in parvagues tray gui menu". # PLN, 2026-08-14: "add a restart SC command in parvagues tray gui menu".
...@@ -341,6 +359,7 @@ class PerfTray: ...@@ -341,6 +359,7 @@ class PerfTray:
self.sc_act.triggered.connect(self.on_sc_clicked) self.sc_act.triggered.connect(self.on_sc_clicked)
self.menu.addAction(self.sc_act) self.menu.addAction(self.sc_act)
self._sc_armed = False self._sc_armed = False
self._gig_armed = False
self._sc_restart_at = None # monotonic time of the last restart we fired self._sc_restart_at = None # monotonic time of the last restart we fired
self.menu.aboutToShow.connect(self.refresh_sc) self.menu.aboutToShow.connect(self.refresh_sc)
...@@ -478,13 +497,17 @@ class PerfTray: ...@@ -478,13 +497,17 @@ class PerfTray:
act.setEnabled(item["available"]) act.setEnabled(item["available"])
self.rig_menu.setTitle(f"Rig ▸ {up}/{total} up") self.rig_menu.setTitle(f"Rig ▸ {up}/{total} up")
if getattr(self, "gig_act", None) is not None: if getattr(self, "gig_act", None) is not None:
pulsar = next((i for i in snap["apps"] if i["key"] == "pulsar"), None) # Three states, because the click means three different things and the
# Say which of the two things the click will do. "Focus" vs "Open" is # dangerous one has to announce itself BEFORE it is pressed.
# the difference between a no-op-looking click and a confident one. if self._gig_armed:
self.gig_act.setText("🌊 Launch Gig — focus ParVagues" self.gig_act.setText("⚠ GIG UP — click again to START THE RIG")
if pulsar and pulsar["running"] else:
else "🌊 Launch Gig — open ParVagues") try:
self.gig_act.setEnabled(bool(pulsar and pulsar["available"])) sc_up = bool(sc_state()[0])
except Exception:
sc_up = False
self.gig_act.setText("🌊 GIG UP — converge & focus" if sc_up
else "🌊 GIG UP — boot the rig")
# ------------------------------------------------------------ SuperDirt # ------------------------------------------------------------ SuperDirt
# #
...@@ -533,6 +556,63 @@ class PerfTray: ...@@ -533,6 +556,63 @@ class PerfTray:
self._sc_armed = False self._sc_armed = False
self.refresh_sc() self.refresh_sc()
# ------------------------------------------------------------ GIG UP
def on_gig_clicked(self):
# "Would this press start the sound?" is answered by SuperDirt's unit
# state, not by asking the Bridge: it is the cheap local truth, it is the
# same question #116 is about, and it keeps an HTTP round trip out of the
# front of a button press.
try:
active, _ = sc_state()
except Exception:
active = False
if not active and not self._gig_armed:
self._gig_armed = True
QTimer.singleShot(GIG_CONFIRM_MS, self._disarm_gig)
self.tray.showMessage(
"perf-tray: GIG UP armed",
"SuperDirt is DOWN. This will boot the rig and START THE SOUND.\n"
"Open the menu and click again within 10 s to confirm.",
QSystemTrayIcon.Warning, GIG_CONFIRM_MS,
)
return
self._disarm_gig()
self._rig_up()
def _disarm_gig(self):
self._gig_armed = False
def _rig_up(self):
"""Ask the Bridge to converge the rig, then open it.
POSTed from a worker thread and never waited on: a cold converge takes
~100 s, and the reason PLN launched Ardour by hand mid-evening — believing
RIG UP had failed — was a face that showed a spinner and no window. The
Bridge answers the POST immediately and runs the job in the background,
so the only thing that must not block is this thread. Nothing in the
worker touches Qt, which is not thread-safe.
"""
self.tray.showMessage(
"perf-tray: GIG UP",
"Converging the rig — the Bridge is opening.",
QSystemTrayIcon.Information, 4000,
)
def post():
body = json.dumps({"launch_apps": True}).encode()
req = urllib.request.Request(
BRIDGE_URL + "api/rig", data=body, method="POST",
headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=10).read()
except Exception as e:
print(f"perf-tray: GIG UP post failed: {e}", file=sys.stderr,
flush=True)
threading.Thread(target=post, daemon=True).start()
self._open_url(BRIDGE_URL)
def _sc_restart(self, was_up): def _sc_restart(self, was_up):
# QProcess, not subprocess.run: a restart takes a couple of seconds to # QProcess, not subprocess.run: a restart takes a couple of seconds to
# return and the tray must not freeze while it does. # return and the tray must not freeze while it does.
......
#!/usr/bin/env python3
"""Drop references to Ardour sources whose audio files are GONE.
Why this exists
---------------
2026-09-05, PLN: "ardour is at the screen choce Tidal Live Tidal Multi, this
must go for leaner moves" — and then, one modal later, "then again had a missing
file screen still open ardour...".
Ardour's "Missing File" dialog appears once per unreadable source and cannot be
suppressed by a flag. Answering "skip all missing" does not fix anything: Ardour
substitutes silent stubs so the session can load, but the session file keeps the
dead names, so the dialog returns on every single launch. That is a stack of
focus-stealing modals standing between a click and a playable rig — exactly what
the hot path must not contain (#136).
The honest fix is to stop referring to files that do not exist.
What it will and will not do
----------------------------
It removes, for each missing source: the <Source> entry, every <Region> that
names it (in <Regions> and inside every <Playlist>), and reports what went.
It does NOT delete audio. It cannot: the files are already gone. But it refuses
to run at all unless it can prove the missing files are missing EVERYWHERE it
knows to look, including the Freebox, which is the single source of truth for
audio in this rig. A reference is the last breadcrumb pointing at a lost take;
dropping one while the take is recoverable would turn a restore into a
forensics job. --allow-archived overrides that, deliberately loudly.
It also refuses to run while Ardour is open, because Ardour would save its
in-memory session over the edit on quit and silently undo the whole thing.
Every write is preceded by a timestamped backup beside the session file.
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
MIRRORS = [Path("/mnt/freebox/PLN")] # converged $HOME mirrors; SSOT for audio
def ardour_running() -> list[int]:
"""PIDs of any running Ardour. /proc scan, never pgrep -f.
(`pgrep -f`/`pkill -f` match the calling shell's own argv in some harnesses,
which has killed the caller here before now.)
"""
out = []
me = os.getpid()
for p in Path("/proc").glob("[0-9]*"):
if p.name == str(me):
continue
try:
if (p / "comm").read_text().strip() == "ArdourGUI":
out.append(int(p.name))
except OSError:
continue
return out
def source_path(session: Path, src: ET.Element) -> Path:
"""Where Ardour would look for this source."""
name = src.get("name") or ""
origin = src.get("origin") or ""
if origin and Path(origin).is_absolute():
return Path(origin)
sub = "midifiles" if (src.get("type") == "midi") else "audiofiles"
return session.parent / "interchange" / session.stem / sub / name
def mirror_candidates(path: Path) -> list[Path]:
"""Where a mirrored backup would keep `path`, by DERIVATION not search.
The Freebox is a converged mirror of $HOME (see fbk), so an archived file's
location is computable: mirror root + the path relative to $HOME. That makes
the question one stat instead of a filesystem walk.
The first version of this tool searched instead — `rglob(name)` per missing
file, 24 full traversals of a 25 G network mount. It ran for over eight
minutes without finishing and would have reported "not found" purely because
it ran out of budget. When the archive's layout is known, derive the path;
only fall back to searching when it is not.
"""
out = []
try:
rel = path.relative_to(Path.home())
except ValueError:
return out
for root in MIRRORS:
if root.exists():
out.append(root / rel)
return out
def find_elsewhere(missing: list[tuple[str, Path]]) -> dict[str, str]:
"""{name: path} for every missing source that survives in a mirror.
`missing` is [(name, where Ardour looked)]. Also checks the session's own
tree, since a file can be moved aside into dead/ rather than deleted.
"""
found: dict[str, str] = {}
for name, wanted in missing:
for cand in mirror_candidates(wanted):
if cand.exists():
found[name] = str(cand)
break
else:
# Moved aside rather than removed? Cheap, local, bounded.
for alt in (wanted.parent.parent / "dead", wanted.parent):
hit = alt / name
if hit.exists() and hit != wanted:
found[name] = str(hit)
break
return found
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("session", type=Path, help="path to the .ardour file")
ap.add_argument("--apply", action="store_true",
help="write the session (default is a dry run)")
ap.add_argument("--allow-archived", action="store_true",
help="drop references even if the file survives elsewhere")
a = ap.parse_args(argv)
session = a.session.expanduser()
if not session.exists():
print(f"no such session: {session}", file=sys.stderr)
return 2
tree = ET.parse(session)
root = tree.getroot()
sources = root.findall(".//Sources/Source")
missing = [s for s in sources if not source_path(session, s).exists()]
print(f"{session.name}: {len(sources)} source(s), {len(missing)} missing")
if not missing:
print("nothing to do — no modal to remove.")
return 0
# Group the report by take, because that is how these actually go missing:
# one take's worth of orbits at a time.
takes: dict[str, list[ET.Element]] = {}
for s in missing:
takes.setdefault((s.get("name") or "?").split("_")[0], []).append(s)
for take, ss in sorted(takes.items()):
print(f" {take}: {len(ss)} source(s)")
# Prove they are gone everywhere before touching a reference.
pairs = [((s.get("name") or ""), source_path(session, s)) for s in missing]
recoverable = find_elsewhere(pairs)
if recoverable:
print(f"\n{len(recoverable)} missing file(s) SURVIVE elsewhere:", file=sys.stderr)
for name, hit in list(recoverable.items())[:5]:
print(f" {name} -> {hit}", file=sys.stderr)
if not a.allow_archived:
print("\nRefusing: restore these instead of dropping the reference "
"(or pass --allow-archived).", file=sys.stderr)
return 1
else:
# NB: not `root` — that name holds the XML tree here, and shadowing it
# made the region sweep below fail with "'PosixPath' has no attribute
# 'iter'". A local rebinding quietly eating an outer name is this repo's
# recurring self-inflicted wound.
for mroot in MIRRORS:
if not mroot.exists():
print(f"\nWARNING: {mroot} is not mounted — 'gone' is unproven.",
file=sys.stderr)
if not a.allow_archived:
print("Refusing on an unverifiable archive: mount it, or pass "
"--allow-archived.", file=sys.stderr)
return 1
print("\nconfirmed gone: absent locally and absent from every mounted mirror.")
# Say how current the mirror is. An empty answer from a STALE mirror is a
# weaker fact than it looks, and the operator should see which they have.
for cand in mirror_candidates(source_path(session, missing[0])):
d = cand.parent
if not d.exists():
continue
try:
newest = max((f.stat().st_mtime for f in d.iterdir()), default=0)
except OSError:
continue
print(f" mirror {d} last changed "
f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(newest))} — if "
"that predates the take, its absence there proves nothing about "
"whether it was ever archived, only that it is not there now.")
ids = {s.get("id") for s in missing}
# Regions naming a dead source, in <Regions> and in every <Playlist>.
dropped_regions = 0
for parent in list(root.iter()):
for reg in list(parent.findall("Region")):
refs = {v for k, v in reg.attrib.items() if "source-" in k}
if refs & ids:
parent.remove(reg)
dropped_regions += 1
dropped_sources = 0
for parent in list(root.iter("Sources")):
for s in list(parent.findall("Source")):
if s.get("id") in ids:
parent.remove(s)
dropped_sources += 1
print(f"\nwould drop: {dropped_sources} source(s), {dropped_regions} region(s)")
if not a.apply:
print("dry run — pass --apply to write.")
return 0
pids = ardour_running()
if pids:
print(f"\nRefusing to write: Ardour is running (pid {pids}). It holds this "
"session in memory and would save over the edit on quit.",
file=sys.stderr)
return 1
backup = session.with_suffix(session.suffix + f".missing-sweep-{time.strftime('%Y%m%d-%H%M%S')}.bak")
shutil.copy2(session, backup)
tree.write(session, encoding="UTF-8", xml_declaration=True)
print(f"written. backup: {backup.name}")
return 0
if __name__ == "__main__":
sys.exit(main())
[Desktop Entry]
# Overrides /usr/share/applications/ardour9.desktop (a user-local file of the
# same basename wins). Reason: stock Exec=ardour9 with no argument always opens
# the session chooser, so every launch from the menu asked "Tidal Live or Tidal
# Multi?" — a modal in the hot path (design rule #136) and, worse, a chance to
# open the ARCHIVE session by mistake. "Tidal Multi" holds the historical
# per-orbit recordings; auditing it as if it were live already produced a
# confidently wrong fader report on 2026-07-28. So the default click is now
# unambiguous, and the chooser survives as a right-click action for the rare
# time PLN actually wants the archive.
Name=Ardour — Tidal Live
Comment=THE performing session (records every orbit as a stem)
Exec=ardour9 "/home/pln/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour"
Icon=ardour9
Terminal=false
MimeType=application/x-ardour;
Type=Application
Categories=AudioVideo;Audio;AudioEditing;X-Recorders;X-Multitrack;X-Jack;
StartupWMClass=Ardour
X-NSM-Capable=true
X-NSM-Exec=ardour9
Actions=chooser;
[Desktop Action chooser]
Name=Open the session chooser (archive, other sessions)…
Exec=ardour9
...@@ -16,6 +16,8 @@ import re ...@@ -16,6 +16,8 @@ import re
import shutil import shutil
import socket import socket
import subprocess import subprocess
import sys
import time
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
...@@ -62,7 +64,7 @@ LAUNCHERS = [ ...@@ -62,7 +64,7 @@ LAUNCHERS = [
"candidates": ["pulsar"], "args": [str(TIDAL)], "exe": r"pulsar$"}, "candidates": ["pulsar"], "args": [str(TIDAL)], "exe": r"pulsar$"},
# `ardour9` was missing from the candidates, so this entry could never actually # `ardour9` was missing from the candidates, so this entry could never actually
# start the installed Ardour (9.2.0) — it reported "unavailable" while Ardour was # start the installed Ardour (9.2.0) — it reported "unavailable" while Ardour was
# running. Newest first, and the version-suffixed real binary (`ardour-9.2.0`) is # running. Newest first, and the version-suffixed real binary (`ardour-9.7.0` as of 2026-09-05) is
# what the exe check has to match. # what the exe check has to match.
{"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Live session (records the stems)", {"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Live session (records the stems)",
"candidates": ["ardour9", "ardour8", "ardour7", "ardour6", "ardour"], "candidates": ["ardour9", "ardour8", "ardour7", "ardour6", "ardour"],
...@@ -269,6 +271,131 @@ def _argv(spec): ...@@ -269,6 +271,131 @@ def _argv(spec):
return _term_argv(inner) if spec.get("terminal") else inner return _term_argv(inner) if spec.get("terminal") else inner
# ---------------------------------------------------------------------------
# Spawn guard
#
# 2026-09-05: RIG UP answered a press with "ardour: launched — launched Ardour"
# while Ardour was already up. Two instances then collided over the session lock
# and BOTH exited, taking all twelve orbit links with them. The obvious suspect
# was is_running()'s `exe` regex, so it was parked as the blocker — but measured
# cold with Ardour running it returns True (argv0 is `ardour-9.7.0`, and
# `[Aa]rdour[-\d.]*$` matches it). The false negative is NOT reproducible, and
# there was no log of the decision, so the cause cannot be recovered.
#
# That is the actual lesson: a single presence probe decided whether to start a
# DAW, and when it was wrong the mistake was unrecoverable and unrecorded. So
# rather than repair a probe that measures fine, make a duplicate spawn
# structurally impossible whatever the probe says, and write down every decision.
#
# The guard is a pidfile in the runtime dir, not a module global, because the two
# faces are two PROCESSES: the web Bridge and the perf-tray both call launch(),
# so an in-memory note in one is invisible to the other. It blocks while the pid
# we spawned is alive, plus a short window after the spawn to cover the gap
# between Popen returning and exec landing the new argv0 in /proc — the one
# interval in which is_running() is legitimately blind.
_RUNDIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / "parvagues" / "launch"
SPAWN_GRACE = 20.0 # seconds; covers fork→exec→/proc visibility
def _log(msg):
"""Every launch decision, to stderr → the unit's journal.
The double-launch that killed both Ardours left no trace anywhere; the whole
incident had to be reconstructed from a chat transcript. A launcher that can
start a DAW is allowed to cost one line of journal per press.
"""
print(f"launchers: {msg}", file=sys.stderr, flush=True)
def _pid_alive(pid, spec):
"""Is `pid` alive AND still the thing we spawned?
The identity re-check matters: pids are recycled, and a stale pidfile whose
number now belongs to an unrelated process would refuse to ever launch again
— a guard that fails closed forever is its own outage.
"""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
line = f.read().replace(b"\x00", b" ").decode("utf-8", "replace").strip()
except OSError:
return False
if not line:
return False
argv0 = line.split(" ", 1)[0].rsplit("/", 1)[-1]
exe, pat = spec.get("exe"), spec.get("pgrep")
if exe:
try:
return bool(re.match(exe, argv0))
except re.error:
return False
if pat:
try:
return bool(re.search(pat, line))
except re.error:
return False
return False
def _recent_spawn(spec):
"""(pid, age) if WE started this launcher and it may still be coming up."""
f = _RUNDIR / f"{spec['key']}.pid"
try:
pid_s, _, ts_s = f.read_text().partition(" ")
pid, ts = int(pid_s), float(ts_s)
except (OSError, ValueError):
return None
age = time.time() - ts
if _pid_alive(pid, spec):
return (pid, age)
if age < SPAWN_GRACE:
return (pid, age) # exec may not have landed in /proc yet
return None
def _note_spawn(spec, pid):
try:
_RUNDIR.mkdir(parents=True, exist_ok=True)
(_RUNDIR / f"{spec['key']}.pid").write_text(f"{pid} {time.time()}")
except OSError as e:
_log(f"could not record spawn of {spec['key']}: {e}") # non-fatal
def _sweep_stale_pending(spec):
"""Move a SUPERSEDED Ardour crash-recovery file aside so no modal appears.
No Ardour flag suppresses the "Recover from crash?" dialog — only the absence
of the `.pending` file does (a clean quit removes it). That dialog is exactly
the kind of focus-stealing modal the hot path must not contain (#136).
But a `.pending` is sometimes real recovery data, so this deliberately does
NOT delete unconditionally: it moves one aside only when the session was
SAVED AFTER the pending was written, which means a later save already
superseded it and the dialog can only offer staler state. A `.pending` newer
than the save holds unsaved captures — that one deserves a human, so the
dialog is left to appear.
Moved, never deleted, and into the session's own `dead/`: this runs
unattended and an unattended process does not get to destroy takes.
"""
session = Path(spec["args"][0]) if spec.get("args") else None
if not session or not session.exists():
return
try:
saved = session.stat().st_mtime
except OSError:
return
dead = session.parent / "dead"
for pend in sorted(session.parent.glob("*.pending")):
try:
if pend.stat().st_mtime >= saved:
_log(f"keeping {pend.name}: newer than the last save — a human should decide")
continue
dead.mkdir(exist_ok=True)
dest = dead / f"{pend.name}.superseded-{int(time.time())}"
pend.rename(dest)
_log(f"moved superseded {pend.name} to dead/{dest.name} (no crash modal)")
except OSError as e:
_log(f"could not sweep {pend.name}: {e}")
def _clear_latch(spec): def _clear_latch(spec):
"""Drop a unit's user-close latch, because we are starting it on purpose.""" """Drop a unit's user-close latch, because we are starting it on purpose."""
unit = spec.get("latch") unit = spec.get("latch")
...@@ -305,21 +432,43 @@ def launch(key): ...@@ -305,21 +432,43 @@ def launch(key):
if not spec.get("terminal") and is_running(spec): if not spec.get("terminal") and is_running(spec):
# Already up: FOCUS it rather than reporting at it. Spawning a duplicate # Already up: FOCUS it rather than reporting at it. Spawning a duplicate
# would be worse than useless during a set — a second Pulsar means a # would be worse than useless during a set — a second Pulsar means a
# second GHCi and a fight over port 6010. # second GHCi and a fight over port 6010, and a second Ardour kills BOTH
# over the session lock.
_log(f"{key}: running → focus")
if focus_window(spec.get("focus") or spec["key"]): if focus_window(spec.get("focus") or spec["key"]):
return {"ok": True, "status": "focused", return {"ok": True, "status": "focused",
"msg": f"{spec['name']} brought to front"} "msg": f"{spec['name']} brought to front"}
return {"ok": True, "status": "already-running", return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} is already running (could not focus)"} "msg": f"{spec['name']} is already running (could not focus)"}
# is_running() said no. Before believing it, ask whether WE started this thing
# moments ago — the one question a /proc snapshot cannot answer, and the one
# that would have prevented the 2026-09-05 double-Ardour whatever went wrong
# upstream of it.
recent = _recent_spawn(spec)
if recent:
pid, age = recent
_log(f"{key}: NOT spawning — we started pid {pid} {age:.1f}s ago")
if focus_window(spec.get("focus") or spec["key"]):
return {"ok": True, "status": "focused",
"msg": f"{spec['name']} was already started ({age:.0f}s ago) — brought to front"}
return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} was already started {age:.0f}s ago (pid {pid})"}
if key == "ardour":
_sweep_stale_pending(spec)
argv = _argv(spec) argv = _argv(spec)
if not argv: if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"} return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
_clear_latch(spec) _clear_latch(spec)
try: try:
subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True, proc = subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e: except OSError as e:
_log(f"{key}: spawn failed — {e}")
return {"ok": False, "status": "error", "msg": str(e)} return {"ok": False, "status": "error", "msg": str(e)}
_note_spawn(spec, proc.pid)
_log(f"{key}: SPAWNED pid {proc.pid} — {' '.join(argv)}")
return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"} return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"}
......
...@@ -50,6 +50,7 @@ import argparse ...@@ -50,6 +50,7 @@ import argparse
import math import math
import os import os
import random import random
import re
import signal import signal
import subprocess import subprocess
import sys import sys
...@@ -87,6 +88,73 @@ WATCH_PREFERENCE: tuple[str, ...] = ( ...@@ -87,6 +88,73 @@ WATCH_PREFERENCE: tuple[str, ...] = (
"ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL", "Midi Through", "ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL", "Midi Through",
) )
# ── unplug/replug survival ─────────────────────────────────────────────────
# 2026-09-05: midiviz resolved its source port ONCE at startup, and when the
# LCXL was unplugged `aseqdump`'s stdout simply hit EOF (the ALSA client it
# was subscribed to vanished), the reader thread returned, and `app.exec()`
# had nothing keeping it alive -- the window closed with exit 0. Because that
# is a CLEAN exit, `Restart=on-failure` never fired: the rig's #1 recurring
# failure mode ("stale binding pattern" -- a binding resolved once, killed by
# a replug, never re-resolved) applied to the window itself, not just to a
# port variable.
#
# The fix mirrors `lcxl-leds.py`'s `find_seq_port`/`invalidate_ports`: never
# trust a resolved port past the moment it might have gone stale. A QTimer
# every RECONNECT_MS (matching `midi-autoconnect.sh`'s own reconcile cadence)
# re-resolves from scratch and swaps the Reader in place; the window and its
# QApplication never see a reason to exit.
#
# The re-resolve is gated on HARDWARE presence, checked independently of
# WATCH_PREFERENCE's name match. Reason: `lcxl3-driver.service` publishes a
# VIRTUAL ALSA port named literally 'ParVagues LCXL3' -- the translated,
# corpus-numbered stream `resolve_watch_port()` prefers ON PURPOSE, because
# that is the numbering the grid and every `.tidal` file actually speak (see
# `tools/lcxl3-driver.py` and the WATCH_PREFERENCE comment above). But that
# virtual client can outlive the physical unplug for a beat if the driver
# hasn't noticed yet, and a pure name match would then report "still
# connected" while the port is a ghost carrying nothing -- exactly the trap
# `midi-autoconnect.sh`'s `DIRECT_LEG_AWK` was written to avoid for its own
# purpose (`hw = ($0 ~ /type=kernel/ && $0 ~ /Launch Control XL|LCXL/)`).
# Reusing that discrimination here: `_hardware_present()` requires a
# `type=kernel` client whose name matches the board, and the rebind tick
# treats the source as gone whenever that is false, regardless of what
# WATCH_PREFERENCE would otherwise resolve to. Content still comes from the
# preferred (possibly virtual/translated) port; liveness is judged by
# hardware, so a lingering ghost can no longer read as "connected".
RECONNECT_MS = 2000
HW_NAME_RE = re.compile(r"launch\s*control\s*xl|\blcxl\d*\b", re.I)
def _hardware_present() -> bool:
"""True iff a REAL (kernel-backed) LCXL client is on the ALSA seq bus.
Independent of `resolve_watch_port()` on purpose -- see the module note
above. `aconnect -l`'s client header line carries both the type tag and
the name, e.g. `client 20: 'LCXL3 1' [type=kernel]`.
"""
try:
out = subprocess.run(["aconnect", "-l"], capture_output=True,
text=True, timeout=3).stdout
except (OSError, subprocess.SubprocessError):
return False
for line in out.splitlines():
if (line.startswith("client ") and "type=kernel" in line
and HW_NAME_RE.search(line)):
return True
return False
def _port_listed(pid: str) -> bool:
"""Is this exact CLIENT:PORT address still in `aseqdump -l`'s listing?
Used only for a user-pinned `-p` port: a replug can renumber the address
(20:0 -> 24:0 is the recorded history -- see reference_lcxl_led_stall), so
a pin surviving a replug is not guaranteed, but this at least notices when
the pinned address itself has gone away rather than silently reading a
dead port forever.
"""
return any(p["addr"] == pid for p in list_ports())
def list_ports() -> list[dict]: def list_ports() -> list[dict]:
"""[{addr, client, port}] from `aseqdump -l`, or [] if aseqdump/ALSA is absent.""" """[{addr, client, port}] from `aseqdump -l`, or [] if aseqdump/ALSA is absent."""
...@@ -241,6 +309,18 @@ class Reader: ...@@ -241,6 +309,18 @@ class Reader:
self.total += 1 self.total += 1
except (OSError, ValueError): except (OSError, ValueError):
self.error = "eof" self.error = "eof"
finally:
# The loop also ends when `aseqdump` exits on its own -- which is
# exactly what happens when the ALSA client it was subscribed to
# disappears (unplug). No exception fires for that: stdout just
# reaches EOF. Distinguish it from an intentional `close()` so the
# rebind tick knows this reader is dead and needs replacing.
if not self._stop.is_set() and self.error is None:
self.error = "closed"
def alive(self) -> bool:
"""False once the `aseqdump` child has exited, for any reason."""
return self._proc is not None and self._proc.poll() is None
def drain(self) -> list[dict]: def drain(self) -> list[dict]:
with self._lock: with self._lock:
...@@ -309,7 +389,8 @@ def _latch_close(): ...@@ -309,7 +389,8 @@ def _latch_close():
file=sys.stderr) file=sys.stderr)
def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pinned_port: str | None = None, watch: bool = False):
QtCore, QtGui, QtWidgets = _qt() QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt Qt = QtCore.Qt
...@@ -335,6 +416,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -335,6 +416,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.reader = reader self.reader = reader
self.port_label = port_label self.port_label = port_label
self._pinned = pinned_port # explicit -p, or None for auto-resolve
self.last_cc = None # (cc, value, channel, t) -- header readout self.last_cc = None # (cc, value, channel, t) -- header readout
self.scale = scale self.scale = scale
self.paused = False self.paused = False
...@@ -363,6 +445,56 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -363,6 +445,56 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.timer.timeout.connect(self.tick) self.timer.timeout.connect(self.tick)
self.timer.start(self._interval) self.timer.start(self._interval)
# Losing the surface must never be fatal -- see the module note
# by RECONNECT_MS. `watch` is off for --selftest (headless, fed
# synthetic events by hand) so the suite never shells out to
# `aconnect`/`aseqdump` on its own clock.
self.rebind_timer = None
if watch:
self.rebind_timer = QtCore.QTimer(self)
self.rebind_timer.setTimerType(Qt.TimerType.CoarseTimer)
self.rebind_timer.timeout.connect(self._rebind_tick)
self.rebind_timer.start(RECONNECT_MS)
# ── reconnect ──────────────────────────────────────────────────────
def _rebind_tick(self):
"""Re-resolve the source and swap the Reader if it moved or died.
Runs every RECONNECT_MS regardless of whether anything is wrong --
re-asserting is cheaper than detecting drift, the same call this
rig makes for the LCXL LED port and for `midi-autoconnect.sh`'s
wiring. Never raises, never closes the window.
"""
if self._pinned:
# A pin names an exact address, never an LCXL alias by name,
# so the ghost distrust below does not apply to it -- only
# "has this address disappeared from the listing at all".
pid = self._pinned if _port_listed(self._pinned) else None
label = self._pinned if pid else "%s (gone)" % self._pinned
else:
pid, label = resolve_watch_port()
# Distrust the match ONLY when it is itself an LCXL alias
# (the ghost case) -- a "Midi Through" catch-all match is not
# LCXL-named and needs no hardware to be a legitimate source.
if pid is not None and HW_NAME_RE.search(label) and not _hardware_present():
pid, label = None, "%s (ghost, no hardware) — no MIDI source found" % label
alive = self.reader is not None and self.reader.alive()
same = self.reader is not None and pid is not None and self.reader.port == pid
if pid is None:
if self.reader is not None:
self.reader.close()
self.reader = None
self.port_label = label
return
if self.reader is None or not alive or not same:
if self.reader is not None:
self.reader.close()
self.reader = Reader(pid).start()
self.port_label = label
# ── palette / fonts / geometry ───────────────────────────────────── # ── palette / fonts / geometry ─────────────────────────────────────
def _build_palette(self): def _build_palette(self):
self.bg = QtGui.QColor(*BG) self.bg = QtGui.QColor(*BG)
...@@ -911,6 +1043,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0): ...@@ -911,6 +1043,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
def closeEvent(self, e): def closeEvent(self, e):
self.timer.stop() self.timer.stop()
if self.rebind_timer is not None:
self.rebind_timer.stop()
if self.reader: if self.reader:
self.reader.close() self.reader.close()
# "if i close i wanna close it". A clean exit is not a failure, so # "if i close i wanna close it". A clean exit is not a failure, so
...@@ -1036,14 +1170,19 @@ def main(argv=None) -> int: ...@@ -1036,14 +1170,19 @@ def main(argv=None) -> int:
return selftest(a.seconds, a.show) return selftest(a.seconds, a.show)
_install_signals() _install_signals()
port, label = a.port, "" pinned = a.port # explicit -p pin, or None for auto-resolve
port, label = pinned, ""
if port is None: if port is None:
port, label = resolve_watch_port() port, label = resolve_watch_port()
# The window itself stays wordless; where it is listening goes to stderr, so # The window itself stays wordless; where it is listening goes to stderr, so
# `-l`-free debugging is still possible without putting prose on the canvas. # `-l`-free debugging is still possible without putting prose on the canvas.
print("⚓ midiviz — %s · q/Esc/Ctrl-C to quit" % (label or "port %s" % port), print("⚓ midiviz — %s · q/Esc/Ctrl-C to quit" % (label or "port %s" % port),
file=sys.stderr) file=sys.stderr)
reader = Reader(port).start() # A blind `aseqdump` (no -p) subscribes to NOTHING -- see the
# WATCH_PREFERENCE comment above -- so start a Reader only once a real
# port resolved. Otherwise the window opens idle and the RECONNECT_MS
# rebind tick picks up the surface the moment it (or its driver) appears.
reader = Reader(port).start() if port else None
_QtCore, _QtGui, QtWidgets = _qt() _QtCore, _QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
...@@ -1055,12 +1194,14 @@ def main(argv=None) -> int: ...@@ -1055,12 +1194,14 @@ def main(argv=None) -> int:
# worse, match EVERY python3 GUI on the box. Give the window its own # worse, match EVERY python3 GUI on the box. Give the window its own
# identity so the compositor can be told about this window and no other. # identity so the compositor can be told about this window and no other.
app.setDesktopFileName("midiviz") app.setDesktopFileName("midiviz")
w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale))) w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)),
pinned_port=pinned, watch=True)
w.show() w.show()
w.raise_() w.raise_()
w.activateWindow() w.activateWindow()
rc = app.exec() rc = app.exec()
reader.close() if w.reader is not None:
w.reader.close()
return rc return rc
......
...@@ -53,11 +53,13 @@ Exit 0 = every block parses as a single statement. ...@@ -53,11 +53,13 @@ Exit 0 = every block parses as a single statement.
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import re
import sys import sys
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
MARKER = "@@PVBLOCK" MARKER = "@@PVBLOCK"
MARKER_END = "@@" # closes the marker so a spliced one cannot parse as intact
def pulsar_blocks(text: str) -> list[tuple[int, str]]: def pulsar_blocks(text: str) -> list[tuple[int, str]]:
...@@ -84,11 +86,18 @@ def pulsar_blocks(text: str) -> list[tuple[int, str]]: ...@@ -84,11 +86,18 @@ def pulsar_blocks(text: str) -> list[tuple[int, str]]:
def build_script(blocks: list[tuple[int, str]]) -> str: def build_script(blocks: list[tuple[int, str]]) -> str:
parts = [] # Line-buffer stdout FIRST. stdout and stderr are deliberately one merged pipe
# (see the comment in main()), and GHC block-buffers stdout while writing
# errors to stderr unbuffered — so a marker could be flushed in a chunk that
# a stderr write had already cut into. On 2026-09-05 that produced the marker
# fragment '6C1o' (two lines interleaved) and an unhandled ValueError, which
# made gig-up report "boot helpers FAIL" while check-boot.sh run alone passed
# clean. Line buffering makes each marker one atomic write, in order.
parts = ["import System.IO", "hSetBuffering stdout LineBuffering"]
for i, (start, chunk) in enumerate(blocks): for i, (start, chunk) in enumerate(blocks):
if not chunk.strip(): if not chunk.strip():
continue continue
parts.append(f'putStrLn "{MARKER} {i} {start}"') parts.append(f'putStrLn "{MARKER} {i} {start} {MARKER_END}"')
if chunk.startswith(":set") or chunk.startswith(":"): if chunk.startswith(":set") or chunk.startswith(":"):
parts.append(chunk) # ghci directives are not wrappable parts.append(chunk) # ghci directives are not wrappable
else: else:
...@@ -123,13 +132,34 @@ def main(argv: list[str]) -> int: ...@@ -123,13 +132,34 @@ def main(argv: list[str]) -> int:
# Attribute each parse error to the block whose marker preceded it. # Attribute each parse error to the block whose marker preceded it.
cur: tuple[int, int] | None = None cur: tuple[int, int] | None = None
bad: dict[tuple[int, int], list[str]] = {} bad: dict[tuple[int, int], list[str]] = {}
torn: list[str] = []
marker_rx = re.compile(re.escape(MARKER) + r"\s+(\d+)\s+(\d+)\s+" + re.escape(MARKER_END))
for ln in r.stdout.splitlines(): for ln in r.stdout.splitlines():
if MARKER in ln: if MARKER in ln:
i, start = ln.split(MARKER)[1].split()[:2] m = marker_rx.search(ln)
cur = (int(i), int(start)) if not m:
# A torn marker means we have LOST TRACK of which block we are in.
# Carrying on would attribute the next parse error to the previous
# block — the exact mis-attribution this tool was built to avoid —
# and skipping it silently could hide a real error behind a green
# verdict. So record it and report inconclusive below.
torn.append(ln.strip())
cur = None
continue
cur = (int(m.group(1)), int(m.group(2)))
continue continue
if "parse error" in ln and cur is not None: if "parse error" in ln:
bad.setdefault(cur, []).append(ln.strip()) if cur is None:
torn.append(ln.strip()) # an error we cannot place
else:
bad.setdefault(cur, []).append(ln.strip())
if torn:
print("check-boot-blocks: INCONCLUSIVE — the ghci transcript could not be "
f"read cleanly ({len(torn)} unplaceable line(s)); rerun.", file=sys.stderr)
for t in torn[:5]:
print(f" {t!r}", file=sys.stderr)
return 2
if not bad: if not bad:
print("check-boot-blocks: OK — every block parses as a single statement.") print("check-boot-blocks: OK — every block parses as a single statement.")
......
...@@ -9,14 +9,28 @@ Documentation=file:///home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py ...@@ -9,14 +9,28 @@ Documentation=file:///home/pln/Work/Sound/Tidal/tools/bridge/midiviz.py
# it is headless and has no display to wait for. # it is headless and has no display to wait for.
After=graphical-session.target After=graphical-session.target
PartOf=graphical-session.target PartOf=graphical-session.target
# Belongs to [Unit], not [Service] -- systemd silently ignores it in the
# wrong section (caught 2026-09-05: "Unknown key 'StartLimitIntervalSec' in
# section [Service], ignoring" in the journal after the first install here).
# 0 = no rate-limit window can ever accumulate a start count and refuse to
# restart; paired with Restart=always below.
StartLimitIntervalSec=0
[Service] [Service]
# Inherits the Wayland/Plasma session env from the user manager. # Inherits the Wayland/Plasma session env from the user manager.
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/midiviz.py ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/midiviz.py
# The lens is decoration with a job: if it dies it should come back, but a # 2026-09-05: an unplugged LCXL used to make midiviz.py exit CLEANLY (the
# crash loop must not spin -- it holds no audio ports and nothing waits on it. # `aseqdump` it shelled out to hit EOF and nothing kept the window open), and
Restart=on-failure # `Restart=on-failure` never fires on a clean exit -- PLN's window was gone
# for the rest of the session ("midiviz should always be open"). The .py fix
# is to never exit for that reason at all (see the RECONNECT_MS note in
# midiviz.py); this is the belt-and-braces layer underneath it, for whatever
# future failure mode isn't in-process-recoverable. `Restart=always` (paired
# with [Unit]'s StartLimitIntervalSec=0 above) means it always comes back --
# it holds no audio ports and nothing waits on it, so there is no real
# crash-loop cost to guard against here.
Restart=always
RestartSec=5 RestartSec=5
[Install] [Install]
......
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