Commit 3f81e14d by PLN (Algolia)

feat(rig): measure the chain, manage the server, seed the surface — three tools…

feat(rig): measure the chain, manage the server, seed the surface — three tools that end the silent-orbit era

Tonight cost an evening to "d4/d5/d9-d12 make no sound", and the reason it cost
an evening is that every layer was inspected by READING and none by MEASURING.
PLN's correction — "this is a great tidal-ears case, observe the audio streams"
— was right, and the measurement took six seconds.

## tools/probe-chain.py — where does the signal stop?

Taps the live chain at three points at once and reports peak+rms dBFS per orbit:

    SuperCollider:out_N/N+1   the orbit as SuperDirt renders it
    ardour:Tidal NN/out       after Ardour's fader/mute/plugins
    ardour:Master/out         the mix

Taps are ADDITIVE PipeWire links into `pw-record --target 0` capture streams, so
nothing existing is unlinked and the audio path is untouched — safe mid-set. The
`--target 0` is the trick that makes it possible to tap a mid-chain node instead
of only a device.

Orbit->port mapping is DERIVED, not hardcoded: `~dirt.start(57120, [0,2,4,...])`
means orbit k writes bus 2k, and PipeWire exposes bus b as out_(b+1)/out_(b+2).

First run settled in one shot what four sessions of hypothesising could not:

    d1  -28.6 rms   d2  -42.5   d3  -37.4   d7  -21.5   d8  -29.8   <- audible
    d4 d5 d6 d9 d10 d11 d12 = -inf, absolute digital silence

Exact match with what PLN could hear. **Ardour fully exonerated** — faders,
mutes, routing, all of it. The signal never existed. Every hypothesis that had
pointed downstream was wrong, and one reading killed them all.

It reports peak AND rms deliberately: a lone peak can be one stray sample or a
click, and judging an orbit "working" on peak alone is how you mistake a
transient for audio.

## parvagues-sc.service — SuperCollider becomes visible

The reason the real error was unreadable all evening: sclang's post window was a
bare /dev/pts/3. Not readable by tooling, gone when the terminal closes, gone on
a crash. The process was equally fragile — owned by a terminal, and killable by
a stray probe (which is exactly what happened: a probe script of mine attached to
the DEFAULT server, i.e. PLN's live one, and its `0.exit` sent `/quit`. `s` in
sclang is the default server; changing the SuperDirt OSC port isolates nothing).

As a systemd --user unit we get restart-by-command, survival across terminal
close and sandbox teardown, one place for The Bridge to show status — and the
post window in the JOURNAL, where it can be PARSED into readiness state instead
of squinted at. Within a minute of it landing:

    like_sugar (22)   the_revolution (36)   diams_dj (16)

— the "missing" banks were registered correctly all along, plus 106 real
`File reading failed` warnings on `zz._`-prefixed files that had never once been
visible. That is the #45 lead, handed over by a log we could finally read.

`Restart=no` on purpose: a failing boot script under Restart=always would spam
the MIDI graph and hide the failure. Audio gear should fail loudly, never flap.
`QT_QPA_PLATFORM=offscreen` is load-bearing, not cosmetic — a Qt-linked sclang
dies with the display stack, which is how an overnight suspend used to kill the
music (d8346672).

## tools/lcxl-init.py — fix the mute-bomb at its actual root

Verified against tidal-1.9.5 source rather than guessed: `"^51"` is `cF_ "51"` is
`_cX_ getF "51"`, and `_cX_` returns **`silence`** on a missing key. `#` cannot
emit without a right-hand value, so any stream touching an unmoved control is
silent — and `dN` being `xfade N` means you hear the PREVIOUS pattern's 4-cycle
tail dying, which reads as a filter closing. That illusion is what cost the
evening.

BootTidal.hs's two mitigations are both weaker than this one. `orDef` reaches
only the ~13 g* helpers — never the ~1860 inline `^NN` refs across 343 track
files, nor arithmetic sites like `midiOn ("^34" - "^18")` where
touched-minus-untouched is itself empty. The `setF` seed block is correct in
principle (same `sStateMV` the OSC `/ctrl` handler writes — traced in Stream.hs)
but depends on boot order and on that ghci having actually run it.

lcxl-init instead emits real CC messages into the identical path a physical knob
takes: CC -> SC's `MIDIFunc.cc` -> `/ctrl` -> Tidal's map. By construction
indistinguishable from PLN sweeping the whole surface by hand — the one thing
already known to work. It cannot diverge from reality because it IS the reality
path, and it fixes helpers, inline refs and arithmetic sites at once, whatever
state ghci is in. PLN's framing: "most things that are templated lack an init
value" — so give them one, from the hardware side.

Encodes the corpus convention `range <neutral> <extreme>`, so 0 is neutral for
everything except the three DJ filters, whose neutral is the centre detent
(64/127 -> lpf 20000 / hpf 20 = true bypass).

D-row faders (77-84) are deliberately NOT seeded: they are MIDI-learned to Ardour
TRACK GAINS, so blasting values would move Ardour's faders while the physical
faders stay put — a surface/mix desync worse than the problem. `^77` is already
covered by `orDef 0.769`.

Two bugs found and fixed by verifying rather than assuming:
  * port resolution used `aconnect -i` (READABLE ports) and so returned SC's
    `out0` — a source. Sending to it goes nowhere. It must be `-o`, the writable
    destinations. Resolved BY NAME on every call, never cached: the LCXL has
    already moved 20:0 -> 24:0 across a replug, and a binding resolved once at
    boot then silently invalidated is this rig's most expensive bug class.
  * the first verification attempt tapped the destination port with aseqdump and
    saw 0 messages — but you cannot SUBSCRIBE to a destination. Delivery was
    then proven properly through Midi Through (which echoes in to out):
    controller 49 value 64 and controller 50 value 64 observed arriving. The
    send was always fine; the observation was broken. Report which one you
    proved.
parent 3d628ff4
[Unit]
# SuperDirt / SuperCollider as a managed unit rather than "a thing running in a
# terminal somewhere".
#
# Why (2026-07-27): an evening was lost to silent orbits, and the one place the
# real error was printed — sclang's post window — was a bare /dev/pts/3. Not
# readable by tooling, not readable after the terminal closes, gone on a crash.
# Meanwhile the process itself was fragile: owned by a terminal, killable by a
# stray probe, unrestartable without PLN's hands.
#
# As a unit we get, for free:
# * `systemctl --user restart parvagues-sc` — safe, repeatable, scriptable
# * the post window in the JOURNAL: `journalctl --user -u parvagues-sc`
# so the boot log can be PARSED into readiness state (midi claimed,
# synthdefs compiled, sample banks registered) instead of squinted at
# * survives terminal close, sandbox teardown, and SSH logout
# * one obvious place for The Bridge to show status and offer restart
#
# QT_QPA_PLATFORM=offscreen is load-bearing, not cosmetic: a Qt-linked sclang
# dies with the display stack, which is how an overnight suspend used to kill
# the music (fixed in d834667). Headless sclang has no such dependency.
Description=ParVagues SuperDirt (sclang start_and_midi.scd, headless)
After=pipewire.service
Wants=pipewire.service
[Service]
Type=simple
WorkingDirectory=/home/pln/Work/Sound/Tidal
Environment=QT_QPA_PLATFORM=offscreen
# scsynth wants a real-time-capable environment; systemd's default limits are
# lower than a login shell's.
LimitRTPRIO=95
LimitMEMLOCK=infinity
LimitNICE=-20
ExecStart=/usr/bin/sclang start_and_midi.scd
# Deliberately NOT Restart=always: if the boot script fails, a restart loop
# would spam the MIDI graph and hide the failure. Fail loudly, restart on
# purpose. Audio gear should never flap.
Restart=no
# Give the post window to the journal, tagged so it is easy to filter.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=parvagues-sc
[Install]
WantedBy=default.target
#!/usr/bin/env python3
r"""lcxl-init — give every templated LCXL control an initial value, from the hardware side.
The problem this exists to kill
------------------------------
In Tidal, `"^51"` is sugar for `cF_ "51"`, which is `_cX_ getF "51"`:
_cX_ f s = Pattern $ \(State a m) -> queryArc (maybe SILENCE (...) $ Map.lookup s m) a
^^^^^^^
An untouched control is **`silence`**, not 0. (Verified against tidal-1.9.5
source, not inferred.) And `#` / `|>` cannot emit an event without a right-hand
value — so ANY stream referencing a control that has never been moved is
COMPLETELY SILENT. Not quiet. Silent.
That is the rig's single worst failure mode, because:
* it produces no error anywhere — not in ghci, not in the SC post window;
* `dN` is `xfade N`, so a re-eval fades the OUTGOING pattern out over ~4
cycles. You hear the previous pattern dying and read it as "a filter is
closing" or "Ardour is broken" — which cost a full evening on 2026-07-27;
* the workaround was ritual: sweep every knob and tap every button after each
boot. Miss one and that orbit is dead on stage.
Why do it from HERE and not in BootTidal.hs
-------------------------------------------
BootTidal.hs already carries two mitigations: an `orDef` combinator on the g*
helpers, and a `setF`-based seed block. Both are real, but both are weaker than
this:
* `orDef` can only wrap the ~13 g* helpers. It cannot reach the ~1860 DIRECT
control references written inline across 343 track files
(`# crushbus 41 (range 16 3.5 "^53")`), and it cannot fix the arithmetic
call sites (`midiOn ("^34" - "^18")`) where `touched - untouched` is itself
empty.
* the seed block writes Tidal's `sStateMV` via `setF`. Correct in principle
(same map the OSC `/ctrl` handler writes — checked in Stream.hs), but it
depends on boot ORDER and on that ghci actually having run the block. When a
stale ghci predates the edit, it silently does nothing.
This tool instead emits real MIDI CC messages into the same path a physical
knob move takes: CC -> SC's `MIDIFunc.cc` -> `osc.sendMsg("/ctrl", num, val/127)`
-> Tidal's control map. It is therefore, by construction, indistinguishable from
PLN sweeping the whole surface by hand — the one thing already known to work. It
cannot diverge from reality, because it IS the reality path. It fixes helpers and
inline refs and arithmetic sites in one shot, and it does not care what state
ghci is in.
Convention it encodes
---------------------
The corpus writes `range <neutral> <extreme> "^NN"`, so **0 is the neutral value**
for almost everything, and the exceptions are the three DJ filters, whose neutral
is the centre detent (0.5 = true bypass: lpf 20000 / hpf 20).
Usage
-----
tools/lcxl-init.py # seed everything, verify, report
tools/lcxl-init.py --dry-run # show what would be sent
tools/lcxl-init.py --faders # ALSO seed D-row faders (see the warning below)
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import time
# --- the control map (see reference_controller_map memory / lib/lcxl-map.js) ---
KNOBS_A = list(range(13, 21)) # row A
KNOBS_B = list(range(29, 37)) # row B
KNOBS_C = list(range(49, 57)) # row C (C1-C3 = the DJ filters)
FADERS_D = list(range(77, 85)) # row D — NOT seeded by default
BUTTONS_E = list(range(41, 45)) + list(range(57, 61))
BUTTONS_F = list(range(73, 77)) + list(range(89, 93))
DJ_FILTERS = [49, 50, 51]
PANIC = 93
def build_seed(include_faders: bool) -> list[tuple[int, int, str]]:
"""(cc, 7-bit value, why) for every control we initialise."""
seed: list[tuple[int, int, str]] = []
for cc in KNOBS_A + KNOBS_B:
seed.append((cc, 0, "knob, neutral end of its range"))
for cc in KNOBS_C:
if cc in DJ_FILTERS:
# 64/127 = 0.504 -> gDJF maps this to lpf 20000 / hpf 20, i.e. true
# bypass. The centre detent is the only value that means "no filter".
seed.append((cc, 64, "DJ filter — CENTRE detent = bypass"))
else:
seed.append((cc, 0, "effect knob, neutral"))
for cc in BUTTONS_E + BUTTONS_F:
seed.append((cc, 0, "button, released"))
seed.append((PANIC, 0, "panic toggle, off"))
if include_faders:
# Off by default ON PURPOSE. The D-row faders are MIDI-learned to Ardour
# TRACK GAINS. Blasting a value at them moves Ardour's faders while the
# PHYSICAL faders stay where they are — instant desync between what the
# surface shows and what the mix is doing, which is worse than the
# problem we are solving. Tidal's own `^77` is already covered by
# `orDef 0.769` in BootTidal.hs.
for cc in FADERS_D:
seed.append((cc, 127, "fader, open (Ardour gain — see warning)"))
return seed
def sc_midi_input_port() -> str | None:
"""Resolve SuperCollider's MIDI input port BY NAME, every time.
Never cache or hardcode this. Client numbers move (the LCXL has already
gone 20:0 -> 24:0 across a replug), and a binding resolved once at boot and
silently invalidated by a device event is this rig's most expensive recurring
bug class. Resolve at use, verify by behaviour.
We target SuperCollider DIRECTLY rather than Midi Through, so these seeding
messages never reach Ardour — where the same CCs could nudge MIDI-learned
track faders.
"""
# `aconnect -o` lists WRITABLE (destination) ports. This matters: `-i` lists
# readable ports, and SuperCollider exposes BOTH — its inputs (in0..) and its
# outputs (out0..) live under the same client. Asking `-i` returns `out0`,
# which is a source; sending to it silently goes nowhere. Got this wrong
# once already, so the flag is the whole correctness argument here.
try:
out = subprocess.run(["aconnect", "-o"], capture_output=True, text=True,
timeout=5).stdout
except Exception:
return None
client = None
for line in out.splitlines():
m = re.match(r"^client (\d+): '([^']*)'", line)
if m:
client = m.group(1) if "SuperCollider" in m.group(2) else None
continue
if client:
pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line)
if pm:
return f"{client}:{pm.group(1)}"
return None
def send_cc(port: str, cc: int, value: int) -> bool:
"""One CC message on channel 1. B0 = CC, status byte for channel 1."""
hexmsg = f"B0 {cc:02X} {value:02X}"
try:
r = subprocess.run(["aseqsend", "-p", port, hexmsg],
capture_output=True, text=True, timeout=5)
return r.returncode == 0
except Exception:
return False
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--faders", action="store_true",
help="also seed D-row faders (they drive Ardour gains — read the source)")
ap.add_argument("-q", "--quiet", action="store_true")
args = ap.parse_args()
seed = build_seed(args.faders)
if args.dry_run:
print(f"lcxl-init: would send {len(seed)} CC messages:")
for cc, val, why in seed:
print(f" CC {cc:3d} = {val:3d} ({val/127:.3f}) {why}")
return 0
port = sc_midi_input_port()
if not port:
print("lcxl-init: FAIL — no SuperCollider MIDI input port found.\n"
" Is SuperDirt running? systemctl --user status parvagues-sc",
file=sys.stderr)
return 2
ok = 0
for cc, val, _ in seed:
if send_cc(port, cc, val):
ok += 1
# A tiny gap keeps us from overrunning the ALSA seq queue; the whole
# sweep still finishes in well under a second.
time.sleep(0.002)
failed = len(seed) - ok
if not args.quiet:
print(f"lcxl-init: seeded {ok}/{len(seed)} controls via SuperCollider MIDI in ({port})")
print(f" C1/C2/C3 (CC 49/50/51) -> 64 = centre detent = filter BYPASS")
print(f" everything else -> 0 = the neutral end of its `range`")
if not args.faders:
print(" D-row faders (77-84) deliberately NOT seeded — they drive Ardour gains")
if failed:
print(f"lcxl-init: WARN — {failed} message(s) failed to send", file=sys.stderr)
return 1
if not args.quiet:
print("\n Every `^NN` now resolves to a value instead of `silence`.\n"
" Verify end-to-end (not just 'the maths is right'): play a track that\n"
" was dead and run tools/probe-chain.py -d <orbit> — SC rms must leave -inf.")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""probe-chain — measure the LIVE signal at every stage of the ParVagues chain.
Why this exists (2026-07-27, J-8 to OPAL)
-----------------------------------------
An entire evening went into "d4/d5/d9-d12 make no sound". Every layer was
inspected by hand and pronounced healthy: Tidal patterns, the g* helpers,
SuperDirt's sample banks (all 723 registered), libsndfile readability, orbit
count, PipeWire links, Ardour routing, the session's saved faders. All static
analysis. All of it agreed the rig was fine, while the rig was demonstrably not.
PLN's correction was the right one: we have audio tooling, so LOOK AT THE AUDIO.
A silent orbit is not a mystery if you can measure where the signal stops.
This probe taps the chain at three points simultaneously, without disturbing it:
SuperCollider:out_N/N+1 -> the orbit as SuperDirt renders it
ardour:Tidal NN/out -> after Ardour's fader/mute/plugins
ardour:Master/out -> the mix
and reports peak + RMS dBFS for each. The stage where level goes to -inf is the
stage that is eating the sound, and that single reading replaces a whole evening
of hypotheses:
SC silent -> Tidal or SuperDirt (pattern, or no buffer)
SC hot, Tidal NN silent -> Ardour: fader, mute, or a plugin
Tidal NN hot, Master silent -> Ardour bus routing
all hot but nothing audible -> past Ardour: device sink, monitoring, cabling
Taps are additive PipeWire links into capture streams, so nothing existing is
unlinked and the audio path is untouched. Safe to run mid-set.
Usage
-----
tools/probe-chain.py # probe d1..d12, 4 seconds
tools/probe-chain.py -d 4 5 9 10 11 12 # only the suspects
tools/probe-chain.py -s 8 # longer window for sparse patterns
Play the track FIRST, then run this — it measures what is happening now.
Orbit -> port mapping is derived, not hardcoded: SuperDirt is started with
`~dirt.start(57120, [0, 2, 4, ...])` so orbit k (0-indexed) writes bus 2k, and
PipeWire exposes bus b as out_(b+1)/out_(b+2). dN is orbit N-1.
"""
from __future__ import annotations
import argparse
import math
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import time
import wave
from pathlib import Path
SC_NODE = "SuperCollider"
RATE = 48000
def sh(cmd: list[str], timeout: float = 10.0) -> str:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout
except Exception:
return ""
def sc_ports_for(dn: int) -> tuple[str, str]:
"""dN -> the pair of SuperCollider output ports carrying that orbit."""
bus = 2 * (dn - 1) # orbit dN-1 writes bus 2*(dN-1)
return f"{SC_NODE}:out_{bus + 1}", f"{SC_NODE}:out_{bus + 2}"
def existing_ports() -> set[str]:
out = set()
for line in sh(["pw-link", "-o"]).splitlines():
out.add(line.strip())
return out
class Tap:
"""A 2-channel pw-record capture with explicitly linked inputs.
--target=0 keeps pw-record from auto-connecting to the default source; we
then link exactly the ports we want. That is the whole trick, and it is why
this can tap a mid-chain node instead of only a device.
"""
def __init__(self, label: str, srcs: tuple[str, str], outdir: Path):
self.label = label
self.srcs = srcs
self.path = outdir / f"{re.sub(r'[^A-Za-z0-9]+', '_', label)}.wav"
self.proc: subprocess.Popen | None = None
self.linked = 0
def start(self, node_name: str) -> None:
self.proc = subprocess.Popen(
["pw-record", "--target", "0", "--rate", str(RATE), "--channels", "2",
"-P", '{ node.name = "%s" }' % node_name, str(self.path)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def link(self, node_name: str) -> None:
# pw-record names its inputs input_FL / input_FR.
for src, dst in zip(self.srcs, (f"{node_name}:input_FL", f"{node_name}:input_FR")):
r = subprocess.run(["pw-link", src, dst], capture_output=True, text=True)
if r.returncode == 0:
self.linked += 1
def stop(self) -> None:
if self.proc:
self.proc.terminate()
try:
self.proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.proc.kill()
def levels(self) -> tuple[float, float]:
"""(peak dBFS, rms dBFS); -inf for digital silence."""
try:
with wave.open(str(self.path), "rb") as w:
n, sw = w.getnframes(), w.getsampwidth()
raw = w.readframes(n)
except Exception:
return (-math.inf, -math.inf)
if not raw or sw != 2:
# pw-record defaults to s16; anything else we simply don't judge.
return (-math.inf, -math.inf)
vals = struct.unpack(f"<{len(raw)//2}h", raw[: (len(raw) // 2) * 2])
if not vals:
return (-math.inf, -math.inf)
peak = max(abs(v) for v in vals) / 32768.0
rms = math.sqrt(sum(v * v for v in vals) / len(vals)) / 32768.0
to_db = lambda x: -math.inf if x <= 0 else 20 * math.log10(x)
return (to_db(peak), to_db(rms))
def fmt(db: float) -> str:
return " -inf " if db == -math.inf else f"{db:+7.1f}"
def verdict(sc: float, trk: float, mst: float) -> str:
SIL = -70.0 # below this is silence for our purposes
if sc <= SIL and trk <= SIL and mst <= SIL:
return "SILENT AT SOURCE -> Tidal/SuperDirt (pattern, or no buffer)"
if sc > SIL and trk <= SIL:
return "Ardour eats it -> fader/mute/plugin on this track"
if trk > SIL and mst <= SIL:
return "Ardour bus routing -> track not reaching Master"
if sc > SIL:
return "signal present through the chain"
return "no signal at source, but downstream shows level (bleed?)"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-d", "--orbits", nargs="+", type=int,
default=list(range(1, 13)), help="dN orbits to probe")
ap.add_argument("-s", "--seconds", type=float, default=4.0)
args = ap.parse_args()
for tool in ("pw-record", "pw-link"):
if not shutil.which(tool):
print(f"probe-chain: FAIL — {tool} not found", file=sys.stderr)
return 2
ports = existing_ports()
if not any(p.startswith(f"{SC_NODE}:out_") for p in ports):
print("probe-chain: FAIL — SuperCollider has no output ports; is it running?",
file=sys.stderr)
return 2
tmp = Path(tempfile.mkdtemp(prefix="probe-chain-"))
taps: list[tuple[int, Tap, Tap]] = []
master = Tap("Master", ("ardour:Master/audio_out 1", "ardour:Master/audio_out 2"), tmp)
print(f"probe-chain: tapping {len(args.orbits)} orbit(s) for {args.seconds:g}s "
f"— play the track NOW if you have not already\n")
all_taps: list[tuple[Tap, str]] = []
for dn in args.orbits:
sc = Tap(f"d{dn}_sc", sc_ports_for(dn), tmp)
trk = Tap(f"d{dn}_track",
(f"ardour:Tidal {dn:02d}/audio_out 1", f"ardour:Tidal {dn:02d}/audio_out 2"),
tmp)
taps.append((dn, sc, trk))
all_taps += [(sc, f"probe_d{dn}_sc"), (trk, f"probe_d{dn}_trk")]
all_taps.append((master, "probe_master"))
try:
for tap, node in all_taps:
tap.start(node)
time.sleep(1.2) # let the streams appear in the graph
for tap, node in all_taps:
tap.link(node)
time.sleep(args.seconds)
finally:
for tap, _ in all_taps:
tap.stop()
m_peak, m_rms = master.levels()
# Peak AND rms: a lone peak can be one stray sample (or a click), while rms
# says whether there is actually sustained content. Judging on peak alone is
# how you mistake a transient for a working orbit.
print(f" {'orbit':6s} {'SC peak':>8s} {'SC rms':>8s} {'trk peak':>9s} "
f"{'trk rms':>8s} diagnosis")
unlinked = []
for dn, sc, trk in taps:
s_peak, s_rms = sc.levels()
t_peak, t_rms = trk.levels()
if sc.linked < 2:
unlinked.append(f"d{dn} SC")
if trk.linked < 2:
unlinked.append(f"d{dn} track")
print(f" d{dn:<5d} {fmt(s_peak)} {fmt(s_rms)} {fmt(t_peak):>9s} "
f"{fmt(t_rms):>8s} {verdict(s_peak, t_peak, m_peak)}")
print(f"\n Master bus: peak {fmt(m_peak).strip()} dBFS, rms {fmt(m_rms).strip()} dBFS")
if unlinked:
print(f"\n NOTE: could not tap {len(unlinked)} point(s): {', '.join(unlinked[:8])}"
"\n (a missing Ardour track, or a port name that differs — those rows read"
"\n -inf because nothing was captured, NOT because the signal is absent.)")
print(f"\n captures kept in {tmp} for listening / spectral work")
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