Commit 077e1db1 by PLN (Algolia)

perf(audio): the rig was reading samples off disk while playing

Went looking for latency to shave in the PipeWire quantum. The quantum was not
the problem.

preload.scd did not exist on this box. start_and_midi.scd:259 tests for it with
File.exists and silently takes the other branch -- 'no preload.scd, lazy-loading
samples on demand.' Driving rose_rouge headlessly for three minutes logged 46
'reading soundfile as needed' lines, one every 5-12s, each a disk read landing on
the audio thread; the SuperCollider node's pw-top W/Q hit 1.300 at the stock 1024
and 1.390 at 512, i.e. missing its deadline regardless of buffer size.
check-preload.sh named it exactly: '60 bank(s) would be read from DISK on first
play (crackle, mid-transition)' -- the same sentence its own header attributes to
a crackle that debuted at a venue.

Generated with check-preload.sh --fix (60 banks, 16 tracks) and verified at the
next boot: 'PRELOAD: 60/61 banks OK in 2.6 s'. The mechanism was never broken;
gig-up.sh already regenerates the plan and the checker already exits non-zero.
gig-up.sh had simply never been run on this box.

Re-running the identical load after the fix left 29 lazy reads, ALL of them the
rose bank -- because rose_rouge is not in setlist_opal2026.txt. Zero of the 60
preloaded banks lazy-loaded, so the plan works; it warms the OPAL set while the
track this box actually plays sits outside it. Logged, with two more findings
from the same boot: preload's own COUNT MISMATCH banner firing on a
sixty-second-old plan, and three global mi-UGens FX synthdefs failing to load at
every boot while rig-doctor's mi-ugens check stays green.

Adds tools/latency-lens.py, which implements the documented quantum method
(lowest quantum holding zero xrun delta and max W/Q under 0.75) against pw-top,
sets clock.force-quantum at runtime and restores it, and never edits a config.
It refuses to run unless the default sink is muted and re-checks that every
second for the whole run: a valid quantum test needs the real hardware device
driving the graph but does not need the speakers.
parent b9564095
......@@ -4,6 +4,83 @@ Sprint entries, newest first. Player-facing: what changed about *playing*, not
about the code. Task IDs reference the L'Armada board; `n/a` where the work was
unplanned (which, on a gig night, is most of it).
## Sprint 6 — 2026-09-07 · the crackle that was waiting at the venue
Went looking for latency to shave. Found that the thing making the rig miss its
deadline was not the buffer size at all — it was reading samples off the disk
while playing, because the plan that prevents exactly that had never been
generated on this laptop.
### Fixed
- **60 banks no longer read off disk mid-set** (n/a). `preload.scd` did not
exist here, and `start_and_midi.scd` tests for it with `File.exists` and
quietly takes the other branch — *"no preload.scd — lazy-loading samples on
demand."* Driving `rose_rouge` headlessly logged **46** `reading soundfile as
needed` lines in three minutes, one every 5-12 seconds, each a disk read on
the audio thread. `check-preload.sh` named it exactly: *"60 bank(s) would be
read from DISK on first play (crackle, mid-transition)"* — the same sentence
its own header attributes to a crackle that "debuted at the venue". Generated
with `check-preload.sh --fix`: 60 banks, all 16 setlist tracks.
The mechanism was never broken — `gig-up.sh` already regenerates the plan and
the checker already exits non-zero — **`gig-up.sh` had simply not been run on
this box since the setlist grew.** Which is the real lesson: the only thing
that keeps a generated artifact fresh is the one path nobody had taken yet.
### Found, not yet fixed
- **The plan is now fresh and pointed at the wrong set** (n/a). Re-running the
identical load after the fix still logged 29 lazy reads — and every one was
the `rose` bank, because `rose_rouge` is not in `setlist_opal2026.txt`. Zero
of the 60 preloaded banks lazy-loaded, so the mechanism is provably working:
it is warming the 16-track OPAL set while the track this box actually plays
sits outside it. A freshness check that compares the plan to the setlist
cannot catch that, because the plan and the setlist agree — it is the setlist
that has drifted from reality.
- **Preload cries wolf on a plan sixty seconds old** (n/a): `60/61 banks OK`
followed by its own `PRELOAD COUNT MISMATCH — whitelist is STALE, regenerate
it`. A warning that fires when nothing is wrong is a warning nobody reads on
the night it is right.
- **Three global FX synthdefs fail at every boot** (n/a) — `global_mi_verb2`,
`global_mi_clouds2`, `global_mi_ripples2`, all `SynthDef not found`. Any send
routed to the mi-UGens global buses is silence. `rig-doctor`'s mi-ugens check
is green, so it is testing that the plugins exist rather than that the
synthdefs load.
### Added
- **`tools/latency-lens.py`** (n/a) — choose the PipeWire quantum by
measurement. Nothing had ever chosen 1024; there is no PipeWire config on
this box at all, so it is the raw upstream default, which belongs to
mixing/mastering rather than live monitoring. The lens implements the
documented method (lowest quantum holding zero xrun delta and max W/Q below
0.75), sets `clock.force-quantum` at runtime and restores it, and never edits
a config — `--print-config Q` prints the drop-in for when a choice is made.
It refuses to run unless the default sink is muted, **and keeps checking every
second for the whole run**, because a valid test needs the real hardware
device driving the graph but does not need the speakers. Written to a
constraint PLN gave at midnight: a friend was asleep in the flat.
- **`rig-doctor` should FAIL on an absent or stale preload plan** — logged in
`backlog.md`, not built. One `check-preload.sh` call, exactly like the
`parvagues-protect --check` fix, and it stands between a cold boot and a
crackle.
### Doctrine
- **Measure the thing, then check what you measured.** The quantum sweep's
first run came back with three clean-looking rows and a `no-data` verdict,
because `WATCH` listed the PROCESS name `scsynth` while the PipeWire node is
called `SuperCollider`. Every dwell had sampled only the ALSA device.
`gig-log.py` already had the right names in `XRUN_NODES`; the tool now keeps
them in step and prints which nodes it actually sampled, so absent data
cannot look like good data.
- **An error is not a negative.** The mute guard read a transient wireplumber
`Translate ID error` as "the sink was unmuted" and aborted a six-minute sweep
on candidate three, naming a cause that had not happened. It now separates
"definitely not muted" from "could not tell" — same action, different
sentence — and retries once.
- **Read the exit code of the command, not of the pipe.** `check-preload.sh` was
briefly accused of exiting 0 while reporting STALE, which would have made
gig-up's auto-fix dead code. It exits 1. The 0 belonged to the `tail` on the
end of the pipeline. Third time in two days that a measurement bug wore the
costume of a code bug — see also `grep -c` printing 0 and exiting 1.
## Sprint 5 — 2026-09-06 (latest) · nothing restarts a boot that is going fine
Sprint 4 measured the guard and found it fidgeting. Measuring it also turned up
......
......@@ -2110,7 +2110,109 @@ scattered through this file resolve locally.
**Still to verify:** reinstall and watch a restart re-protect on its own, which
is the only test that proves it (`systemctl --user restart parvagues-sc`, then
`parvagues-protect --check` — expect `oom:200->-1000` naming the NEW pid).
- 🔴 **`preload.scd` did not exist on the XPS24 — 60 banks would have been read
off disk mid-set.** Found 2026-09-07 while trying to measure the quantum, and
it is the same failure `tools/check-preload.sh`'s header calls out as having
"debuted as a crackle at the venue" — except on xps22 the plan existed and
warmed the wrong list, while here there was no plan at all.
`start_and_midi.scd:259` tests `File.exists("preload.scd")` and silently
takes the other branch: *"preload: no preload.scd — lazy-loading samples on
demand."*
**Measured, not inferred.** Driving `rose_rouge` headlessly for ~3 min logged
**46** `reading soundfile as needed` lines — `rose:28.0`, `rose:29.0`,
`rose:30.0`, one every 5-12 s, each one a disk read landing on the audio
thread. In the same window the SuperCollider node's `pw-top` **W/Q reached
1.300 at the rig's stock 1024** and 1.390 at 512, i.e. exceeding its deadline
regardless of quantum. `check-preload.sh` then said it plainly: *"60 bank(s)
would be read from DISK on first play (crackle, mid-transition)"*.
**Fixed by running `tools/check-preload.sh --fix`** — 60 banks, all 16
setlist tracks, 7.5 KB, gitignored (correctly, it is generated). Verified at
the next boot: `preload: warming the set's samples…` /
`=== PRELOAD: 60/61 banks OK in 2.6 s ===`.
- 🔴 **The preload plan is fresh and aimed at the wrong set.** Re-running the
same load after the fix still logged 29 lazy reads — and **all 29 were the
`rose` bank**, because `rose_rouge` is NOT in `armada/setlist_opal2026.txt`.
Zero of the 60 preloaded banks lazy-loaded, so the mechanism is provably
working; it is warming the 16-track OPAL set while the track this box has
actually been playing (see the rig-state memory) is outside it.
This is `check-preload.sh`'s own documented failure one layer up: there, the
plan was stale because two tools disagreed about the setlist (10 vs 13
tracks). Here the plan matches its setlist exactly and **the setlist is stale
relative to what gets played**. A freshness check that compares the plan to
the setlist cannot see this — it needs to compare against what is actually
loaded. Cheapest honest fix: add current material to the setlist (or a
second "current" list the generator also reads), then regenerate.
- **Preload reports `60/61` and its own `PRELOAD COUNT MISMATCH — whitelist is
STALE, regenerate it`** on a plan generated sixty seconds earlier. So either
the generator counts one bank the server cannot load, or the two count
different things. Worth ten minutes: the mismatch banner is a real signal that
currently cries wolf on a fresh plan, and a warning that fires when nothing is
wrong is a warning nobody reads on the night it matters.
- **Three global FX synthdefs are missing at every boot**: `global_mi_verb2`,
`global_mi_clouds2`, `global_mi_ripples2` — `*** ERROR: SynthDef ... not
found` / `FAILURE IN SERVER /s_new SynthDef not found`, seen 2026-09-07 on a
clean boot. These are the mi-UGens global effect buses, so any track routing
to them gets silence from that send. `rig-doctor`'s mi-ugens check passes,
which means it is checking that the plugins are present rather than that the
synthdefs load — the same presence-versus-function gap as everywhere else this
week.
**The mechanism was never broken**: `gig-up.sh:182` already calls the checker
and auto-fixes on a non-zero exit, and the checker does exit 1 correctly.
`gig-up.sh` simply has not been run on this box since the setlist grew. So
the gap is not the code, it is that the gig-up path is the only thing that
regenerates the plan and nothing else notices. **`rig-doctor` should FAIL on a
stale-or-absent preload plan** — it is 46 disk reads and a venue crackle away
from being a real preflight item, and it costs one `check-preload.sh` call,
exactly like the `parvagues-protect --check` fix.
- **Block size 1024 (~21 ms)** — high for livecoding. Worth tuning the PipeWire quantum.
**Cannot be answered until preload is warm** — see the entry above. Both
candidates measured on a cold cache rejected (1024: W/Q 1.300, +6,982 ERR in
60 s; 512: W/Q 1.390, +17,348), and W/Q > 1 at the *stock* setting means the
measurement was reading disk stalls, not DSP headroom. Re-run after a warm
boot before drawing any conclusion about quantum.
**Groundwork done 2026-09-07: `tools/latency-lens.py`.** Nothing had ever
chosen 1024 — there is no PipeWire config on this box at all
(`~/.config/pipewire/` and `/etc/pipewire/pipewire.conf.d/` both absent), so
it is the raw upstream default, which current guidance places in the
mixing/mastering bracket (no live input); live monitoring sits near 256
(5.3 ms period, ~10.7 ms round trip). It only became worth measuring once
scsynth reliably ran at SCHED_FIFO/90 — the guidance is blunt that without RT
scheduling every other tuning is marginal, and until `a828d5f` protect was
handing scsynth back at `SCHED_OTHER/0`.
The lens implements the documented method rather than a guess: accept the
LOWEST quantum holding **ERR delta 0** and **max W/Q < 0.75** under a
representative load, sampled from `pw-top -b`. It sets `clock.force-quantum`
at runtime and restores it; it never edits a config (`--print-config Q`
prints the drop-in to write when a choice is actually made).
**It refuses to run unless the default sink is muted, continuously.** A valid
quantum test needs the real hardware device driving the graph (a null sink is
driven by a software timer and answers far too optimistically), but it does
not need the speakers: mute sits downstream of all DSP. The mute is
re-checked ~1/s for the whole run and the sweep dies the instant it stops
holding — because a one-shot check is a snapshot, not a guarantee, and this
was proven twice in ten minutes (see the learnings below).
**Still to do:** the real acceptance run — `--dwell 600`, UMC202HD plugged
in, Ardour recording. USB has its own floor and the gig path is the UMC, so
no number measured on internal audio should be trusted for a venue.
- **Nothing on this rig counts SuperCollider `late` messages**, and they are the
single best timing-health signal a Tidal rig has. 136 of them on 2026-09-06
went unnoticed: `gig-log` records thermals, freq, cpu, gear, xrun deltas,
MIDI, track and eval — not `late`. Found only by grepping the journal by hand.
They came in **two bursts** (64 at 19:58, 72 at 20:03), values *decaying*
8.1 s → 0.65 s like a drained backlog, not the steady trickle that means
"latency too low". Both bursts sit against SC restarts, and the 20:03 one is
the watchdog bug fixed in `b956409`, caught in the act:
`20:03:35 scsynth GONE (3 polls) … restarting (1 prior in window)` six
seconds into a healthy boot. `late` belongs in gig-log's `s` record as a
delta, next to the xruns.
- **`s.latency = 0.3` is probably padding for a cause that was misdiagnosed.**
`start_and_midi.scd:349`, comment "increase this if you get late messages",
history `1` → `0.3` (`d008a31`) and never revisited. SC's documented default
is 0.2, and 0.3 s is what sits between ctrl+enter and hearing the change. The
documented reason to pad it — late messages — was two restart bursts, not
insufficient latency. **Not changed blind**: walking it down wants the `late`
counter above so the rollback signal is visible, plus PLN's ears. Cheap and
reversible once both exist.
- **No ld.so.conf.d preference for PipeWire's libjack**, so `libjack.so.0` resolves to
jackd2's. `pw-jack` works around it per-app; a system-wide preference would also make
qjackctl show the graph the rig is actually in, instead of an empty jackd2 one. Needs
......
#!/usr/bin/env python3
"""latency-lens — choose the PipeWire quantum by measurement, not by default.
Why this exists (2026-09-06)
----------------------------
This box has been playing at `clock.quantum = 1024` — 21.3 ms at 48 kHz — and
nobody ever chose it. There is no PipeWire config on the machine at all:
~/.config/pipewire/ does not exist
/etc/pipewire/pipewire.conf.d/ does not exist
1024 is the upstream default, and current guidance puts it in the
mixing/mastering bracket, for work with no live input. Live performance with
monitoring sits around 256 (~10.7 ms round trip). Halving the latency PLN feels
between ctrl+enter and the speaker is worth a measurement.
It became worth measuring only recently. The precondition for a low quantum is
realtime scheduling on the audio thread — without it every other tuning is
marginal — and until 2026-09-06 `parvagues-protect` was handing scsynth back at
`SCHED_OTHER/0` after every restart, because its systemd unit had dropped
CAP_DAC_OVERRIDE and it could not write a single `oom_score_adj`. Now that
scsynth reliably runs at SCHED_FIFO/90, the headroom a lower quantum needs
actually exists.
THE MEASUREMENT, AND WHY IT IS SILENT
-------------------------------------
A valid quantum test needs the REAL hardware device as the graph's driver: xruns
come from the device's timing constraints, and a null sink driven by a software
timer is far more forgiving, so it would hand back a falsely optimistic answer.
But it does not need the SPEAKERS. Mute is applied in the mixer, downstream of
all DSP, so a muted sink still clocks the graph at the quantum while emitting
nothing. This tool therefore REFUSES TO RUN unless the default sink is muted
(override with --allow-sound, deliberately awkward). Born of a real constraint:
PLN, 2026-09-06, 00:0x — "keep laptop muted physically cant make sound friend
sleeping!!!"
DECISION RULE (not invented here; this is the documented method)
----------------------------------------------------------------
Accept the LOWEST quantum that, under a representative load, holds:
ERR delta == 0 no xruns on any watched node
max W/Q < 0.75 wait time as a fraction of the quantum period
W/Q is how close the node runs to its deadline. Above 0.8 is living dangerously;
0.75 is the margin that survives a workload getting heavier mid-set.
pw-top's batch table is S ID QUANT RATE WAIT BUSY W/Q B/Q ERR [FORMAT...] NAME
FORMAT is variable-width or absent, which is why ERR is indexed from the LEFT
(token 8) and NAME from the RIGHT — the same rule gig-log.py's XrunReader uses,
and for the same reason.
CAVEATS, STATED PLAINLY
-----------------------
* A dwell of a minute or two is a SCREEN, not the acceptance run. The method
asks for 10+ minutes per candidate before you trust it.
* The result is only valid for the interface that was driving the graph. The gig
path is the UMC202HD over USB, which has its own floor — re-run this with it
plugged in before believing any number for a venue.
* This tool never edits a config. It sets `clock.force-quantum` at runtime and
puts it back. Making a choice permanent is a separate, deliberate act; see
--print-config.
tools/latency-lens.py # 1024, 512, 256, 128
tools/latency-lens.py --quanta 512,256 --dwell 120
tools/latency-lens.py --print-config 256 # the drop-in to write
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
# Nodes whose xruns actually mean something for this rig. Anything else on the
# graph (a browser tab, a v4l2 camera) can drop frames all night without
# costing a take.
#
# THESE ARE PIPEWIRE NODE NAMES, NOT PROCESS NAMES, and the difference cost this
# tool its first run. The audio server's process is `scsynth`, but the node it
# publishes is named `SuperCollider` (SC's JACK client name). A WATCH list of
# ("scsynth", ...) therefore matched NOTHING, every dwell sampled only the alsa
# device, and the sweep came back `maxW/Q=0.000` — no data at all, dressed as
# three clean runs. gig-log.py already had this right in XRUN_NODES; the names
# below are kept in step with it deliberately rather than imported, because
# importing gig-log drags in its sysfs readers and threads for three strings.
WATCH = ("SuperCollider", "ardour", "ArdourGUI", "Line__sink", "alsa_output")
SETTLE_S = 6.0 # let the graph renegotiate and the numbers stop moving
SAMPLE_HZ = 8 # pw-top -b emits ~8 tables/second
def run(cmd: list[str], timeout: int = 10) -> tuple[int, str]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout + r.stderr
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
return 127, str(e)
# --------------------------------------------------------------------------- #
# preflight
# --------------------------------------------------------------------------- #
def sink_is_muted() -> tuple[bool, str]:
"""True if the default sink is muted. wpctl prints e.g.
Volume: 0.63 [MUTED]
Distinguishes "definitely not muted" from "could not tell", because the two
deserve the same ACTION (stop) but not the same SENTENCE. On 2026-09-07 a
transient `Translate ID error: '-1' is not a valid ID (returned by
default-nodes-api)` from wireplumber contained no '[MUTED]', so the naive
check reported the sink had been unmuted and the run aborted claiming
"the sink stopped being muted" — a confident wrong cause, mid-sweep, on
candidate 3 of 4. Retried once, because a one-off wireplumber hiccup should
not cost a six-minute measurement; still fails safe if it persists.
"""
for attempt in (1, 2):
rc, out = run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"])
text = out.strip()
if rc == 0 and "Volume:" in text:
return ("[MUTED]" in text), text
if attempt == 1:
time.sleep(0.4)
return False, f"UNDETERMINED — wpctl said: {text[:120]}"
def driven_nodes(seconds: float = 4.0) -> dict[str, str]:
"""Watched nodes that PipeWire is actually DRIVING, name -> evidence.
A node with no path to a driver is never scheduled, so it reports zero
xruns and zero load — measuring it would be a false green of the purest
kind, which is the whole failure family this rig keeps re-learning.
Asks pw-top, not `pw-link -l`. That was the first implementation and it was
the wrong oracle: on 2026-09-07 `pw-link -l` listed no SuperCollider port
lines at all while pw-top showed the very same node as
`R 193 ... WAIT 12.7ms W/Q 0.60 + SuperCollider` — running, scheduled, and
flagged linked. The gate cried wolf on a perfectly valid measurement. State
`R` plus a numeric WAIT is what "being driven" actually means; a port
listing is a proxy for it, and proxies are how presence stands in for
function.
"""
found: dict[str, str] = {}
p = subprocess.Popen(["pw-top", "-b"], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
deadline = time.time() + seconds
try:
while time.time() < deadline:
line = p.stdout.readline()
if not line:
break
t = line.split()
if len(t) < 10 or not t[1].isdigit():
continue
name = t[-1].lstrip("+")
if not any(w in name for w in WATCH):
continue
# t[0] is the state column: R = running. t[4] is WAIT; '---' means
# the node is idle and nothing is asking it for samples.
if t[0] == "R" and t[4] != "---":
found[name] = f"state={t[0]} wait={t[4]} W/Q={t[6]}"
finally:
p.terminate()
try:
p.wait(timeout=3)
except subprocess.TimeoutExpired:
p.kill()
return found
def quantum_state() -> dict[str, str]:
rc, out = run(["pw-metadata", "-n", "settings"])
st = {}
for m in re.finditer(r"key:'([^']+)' value:'([^']*)'", out):
st[m.group(1)] = m.group(2)
return st
def set_force_quantum(q: int) -> None:
run(["pw-metadata", "-n", "settings", "0", "clock.force-quantum", str(q)])
# --------------------------------------------------------------------------- #
# sampling
# --------------------------------------------------------------------------- #
class Sample:
"""One dwell's worth of pw-top, reduced to what the decision rule needs."""
def __init__(self) -> None:
self.err_first: dict[str, int] = {}
self.err_last: dict[str, int] = {}
self.wq_max: dict[str, float] = {}
self.quant_seen: set[str] = set()
self.rows = 0
def feed(self, line: str) -> None:
t = line.split()
# Header and separators have no numeric ID in token 1.
if len(t) < 10 or not t[1].isdigit():
return
name = t[-1].lstrip("+")
if not any(w in name for w in WATCH):
return
try:
err = int(t[8])
except ValueError:
return
wq = t[6]
quant = t[2]
self.rows += 1
self.err_first.setdefault(name, err)
self.err_last[name] = err
if quant.isdigit() and quant != "0":
self.quant_seen.add(quant)
try:
self.wq_max[name] = max(self.wq_max.get(name, 0.0), float(wq))
except ValueError:
pass # '---' while a node is idle
def err_delta(self) -> dict[str, int]:
return {n: self.err_last[n] - self.err_first[n] for n in self.err_last}
class Unmuted(Exception):
"""The sink stopped being muted while we were making a rig play."""
def dwell(seconds: float, enforce_mute: bool = True) -> Sample:
"""Sample pw-top, re-checking the mute the whole time.
The preflight gate used to check the mute ONCE and then run for six
minutes. That is not a guarantee, it is a snapshot — and on 2026-09-07 it
proved it: the volume was being adjusted (0.63 -> 0.15) exactly as the gate
sampled, the mute flag read off for that instant, and the run aborted. The
same race in the other direction would have put a full SuperDirt mix through
the speakers at 00:1x with someone asleep in the flat. So the mute is now a
CONTINUOUS precondition: checked about once a second for as long as the rig
is being driven, and the run dies the moment it stops holding.
"""
s = Sample()
p = subprocess.Popen(["pw-top", "-b"], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
deadline = time.time() + seconds
next_check = time.time()
try:
while time.time() < deadline:
if enforce_mute and time.time() >= next_check:
next_check = time.time() + 1.0
muted, vol = sink_is_muted()
if not muted:
raise Unmuted(vol)
line = p.stdout.readline()
if not line:
break
s.feed(line)
finally:
p.terminate()
try:
p.wait(timeout=3)
except subprocess.TimeoutExpired:
p.kill()
return s
# --------------------------------------------------------------------------- #
# report
# --------------------------------------------------------------------------- #
def print_config(q: int) -> None:
print(f"""
# ~/.config/pipewire/pipewire.conf.d/10-parvagues-quantum.conf
#
# Chosen by measurement, not by default — see tools/latency-lens.py.
# {q} frames at 48 kHz = {q / 48000 * 1000:.1f} ms per period.
#
# force-quantum PINS it: clients cannot negotiate away from this, which is what
# you want on a gig box where a browser tab must not be able to raise the
# latency of the set. Use `quantum` + min/max instead if you want clients to
# have a say.
context.properties = {{
default.clock.force-quantum = {q}
}}
# Apply: systemctl --user restart pipewire pipewire-pulse wireplumber
# Revert: delete the file, same restart. Runtime-only equivalent, no restart:
# pw-metadata -n settings 0 clock.force-quantum {q}
# pw-metadata -n settings 0 clock.force-quantum 0 # back to default
""".rstrip())
def main() -> int:
ap = argparse.ArgumentParser(
description="Choose the PipeWire quantum by measurement.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__)
ap.add_argument("--quanta", default="1024,512,256,128",
help="comma-separated candidates, high to low")
ap.add_argument("--dwell", type=float, default=60.0,
help="seconds to sample per candidate (method asks 600+)")
ap.add_argument("--allow-sound", action="store_true",
help="run even if the sink is NOT muted (it will be audible)")
ap.add_argument("--print-config", type=int, metavar="Q",
help="print the drop-in for quantum Q and exit")
ap.add_argument("--json", metavar="PATH", help="also write raw results here")
a = ap.parse_args()
if a.print_config:
print_config(a.print_config)
return 0
for tool in ("pw-top", "pw-metadata", "pw-link", "wpctl"):
if not shutil.which(tool):
print(f"latency-lens: need {tool} (pipewire + wireplumber)", file=sys.stderr)
return 2
# --- gate 1: silence -------------------------------------------------- #
muted, vol = sink_is_muted()
print(f"latency-lens: default sink -> {vol}")
if not muted and not a.allow_sound:
print("latency-lens: REFUSING — the default sink is not muted.\n"
" A valid quantum test needs the real hardware device driving the\n"
" graph, but it does NOT need the speakers: mute sits downstream of\n"
" all DSP, so a muted sink still clocks the graph at the quantum.\n"
" Mute it (wpctl set-mute @DEFAULT_AUDIO_SINK@ 1) or pass\n"
" --allow-sound if you actually want to hear this.", file=sys.stderr)
return 3
# --- gate 2: is anything actually being driven? ----------------------- #
driven = driven_nodes()
for name, ev in sorted(driven.items()):
print(f"latency-lens: driven -> {name} ({ev})")
if not any("SuperCollider" in n for n in driven):
print("latency-lens: WARNING — SuperCollider is not being DRIVEN (no R\n"
" state with a numeric WAIT). A node with no path to a driver is\n"
" never scheduled, so it reports zero xruns and zero load. That is\n"
" a false green, not a good result.\n"
" Note tidal-ardour-autoroute PRUNES SuperCollider -> hardware by\n"
" design (Ardour is meant to be the monitor path), so for a\n"
" SC-only test you must either run Ardour or stop that service and\n"
" let SuperDirt's own orbit-0 monitor link stand.", file=sys.stderr)
st0 = quantum_state()
orig_force = st0.get("clock.force-quantum", "0")
rate = st0.get("clock.rate", "48000")
print(f"latency-lens: rate={rate} quantum={st0.get('clock.quantum')} "
f"force-quantum={orig_force} (will be restored)")
candidates = [int(q) for q in a.quanta.split(",") if q.strip()]
results = []
try:
for q in candidates:
set_force_quantum(q)
time.sleep(SETTLE_S)
s = dwell(a.dwell, enforce_mute=not a.allow_sound)
deltas = s.err_delta()
worst_wq = max(s.wq_max.values(), default=0.0)
total_err = sum(deltas.values())
results.append({
"quantum": q,
"period_ms": q / int(rate) * 1000,
"rows": s.rows,
"err_delta": deltas,
"err_total": total_err,
"wq_max": s.wq_max,
"wq_worst": worst_wq,
"quant_seen": sorted(s.quant_seen),
"verdict": "ok" if (total_err == 0 and 0.0 < worst_wq < 0.75)
else ("no-data" if s.rows == 0 or worst_wq == 0.0
else "reject"),
})
worst_node = max(s.wq_max, key=lambda n: s.wq_max[n], default="-")
results[-1]["worst_node"] = worst_node
results[-1]["nodes_seen"] = sorted(s.err_last)
print(f" {q:>5} frames ({q/int(rate)*1000:5.1f} ms) "
f"xruns+{total_err:<4} maxW/Q={worst_wq:.3f} on {worst_node:<16} "
f"samples={s.rows:<6} -> {results[-1]['verdict']}", flush=True)
except Unmuted as e:
why = ("could not be confirmed as muted" if "UNDETERMINED" in str(e)
else "stopped being muted")
print(f"\nlatency-lens: ABORTED MID-RUN — the sink {why}.\n {e}\n"
f" Something may be about to be audible. Stopping now rather "
f"than finishing the sweep.", file=sys.stderr)
return 4
finally:
set_force_quantum(int(orig_force))
print(f"latency-lens: restored clock.force-quantum={orig_force}")
print()
print(f"{'quantum':>8} {'period':>8} {'xruns':>6} {'maxW/Q':>7} {'worst node':<18} verdict")
for r in results:
print(f"{r['quantum']:>8} {r['period_ms']:>7.1f}ms {r['err_total']:>6} "
f"{r['wq_worst']:>7.3f} {r.get('worst_node','-'):<18} {r['verdict']}")
seen = sorted({n for r in results for n in r.get("nodes_seen", [])})
print(f"\nnodes actually sampled: {', '.join(seen) if seen else 'NONE'}")
if not any("SuperCollider" in n for n in seen):
print(" ^ SuperCollider is absent from that list, so nothing here says\n"
" anything about the audio server. Do not read a verdict off it.")
good = [r for r in results if r["verdict"] == "ok"]
print()
if not good:
print("latency-lens: NO candidate passed. Either the load was not actually\n"
" running (check 'samples' and the SuperCollider link above) or this\n"
" box needs 1024. Read the numbers, not the verdict.")
else:
best = min(good, key=lambda r: r["quantum"])
print(f"latency-lens: lowest passing quantum = {best['quantum']} "
f"({best['period_ms']:.1f} ms), xruns 0, maxW/Q {best['wq_worst']:.3f}")
print(f" This is a {a.dwell:.0f}s SCREEN. Before trusting it: re-run with\n"
f" --dwell 600, with the UMC202HD plugged in, and with Ardour\n"
f" recording — that is the real gig path and USB has its own floor.")
print(f" Then: tools/latency-lens.py --print-config {best['quantum']}")
if a.json:
Path(a.json).write_text(json.dumps(results, indent=2))
print(f"latency-lens: raw results -> {a.json}")
return 0
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment