Commit 81eb4dc0 by PLN (Algolia)

feat(lint): pvlint — the bugs that silenced this rig, encoded as rules

Three evenings this week went to bugs that were VISIBLE IN THE TEXT and that
no eye caught mid-set, because none of them produce an error message. They
produce silence, on stage:

  * desire.tidal — `off 0.125 (|+ note 12 . (|* gain 0.6))`. `.` binds looser
    than application, so this composes a FUNCTION into a ValueMap. GHC refuses
    the whole block, and since a blank line is Tidal's block separator, ONE
    character silenced nine orbits while the file looked perfect on screen.
  * you_my_sunshine.tidal — a local `let gF3 = (# djfbus 3 (range 0.05 0.95
    "^51"))` shadowing the global helper we had already fixed. SuperDirt maps
    djf through linexp(0, 0.5, 20, 10000): bypass is 0.5, and 0.05 is a ~26 Hz
    low-pass. Four orbits (d5/d7/d9/d11) inaudible until a knob crossed centre.
  * the #55 mute-bomb — an untouched `^NN` yields NO events, so `range a b
    "^NN"` is `silence`, not a number.

All three are static properties of the file, so they belong in a linter.

Seven rules, each earned by a real failure: PV001 local djfbus shadow (with an
autofix that deletes the line so the track inherits the corrected global),
PV002 operator-section composition (autofix adds the missing parens), PV003
double-applied global gain, PV004 shared cut group, PV005 duplicate orbit in
one block, PV006 sample index past the end of its folder, PV007 orbit
inventory (the input to the orphan-orbit transition check, `--setlist`).

Three of my own rules were wrong on first contact with the corpus, and the
corpus caught all three — which is the argument for running a linter against
694 real files before trusting it:

  * PV002 was string-blind and called four mini-notation `.` separators
    compile-killers (`"k ~ ~ k . ~"` is grouping, not composition). 13 findings
    -> 0, all of them false.
  * PV003 claimed the whole 77-84 fader bank was "owned by Ardour" and raised
    1109 errors. BootTidal itself reads `^77` (midiGGlobal); reading a fader CC
    is the documented convention. Narrowed to the actual hazard — ^77 applied
    twice — and demoted to a warning.
  * PV005 flagged every re-declaration of an orbit, but keeping several
    evaluable versions per file is normal live practice. Scoped to one block:
    576 findings -> 32.
  * PV006 pooled all indices in an orbit against all folders and invented a
    finding on d8 (n=24 is org_jungle_breaks', not breaks165's).

Also fixed the parser: ORBIT_RE demanded a same-line `$` and so dropped d5
from you_my_sunshine's inventory WHILE THE TRACK WAS PLAYING IT — PLN writes
`d5  -- comment` with the `$` on the continuation line, which Haskell's layout
rule allows. A linter that under-reports is worse than none.

Corpus after the corrections: 694 tracks, 316 errors (all PV001 shadows, in
archive material), 281 warnings. First real run found a live one — d5 and d11
of you_my_sunshine share cut group 5, so the voice and the chop truncate each
other.

33 unit tests, every negative case a real corpus line that an earlier rule
version wrongly flagged.
parent 2788722c
"""pvlint — static checks for ParVagues .tidal tracks.
Every rule here encodes a bug that has silenced this rig on stage or in
rehearsal. See rules.py for the catalogue.
"""
from .core import Finding, Track, load # noqa: F401
from .rules import RULES, check # noqa: F401
"""pvlint — the ParVagues track linter.
python3 tools/pvlint live/**/*.tidal # check
python3 tools/pvlint --fix live/foo.tidal # black-style autofix in place
python3 tools/pvlint --setlist live/a.tidal live/b.tidal # + orphan check
Exit codes: 0 clean, 1 errors found, 2 warnings only (with --strict), 3 usage.
Design note — why autofix is conservative
-----------------------------------------
This runs on files that are performed live. A fix that is *usually* right is
worse than no fix, because the failure mode is silence in front of an audience.
So only rules that carry an exact replacement offer `--fix`, and a rule offers
one only when the correct output is unambiguous (delete a shadowing `let`, add
the missing parens around an operator section). Everything else reports and
lets a human decide.
"""
from __future__ import annotations
import argparse
import os
import sys
try: # `python3 -m tools.pvlint` / imported as a package
from .core import Track, load
from .rules import check
except ImportError: # `python3 tools/pvlint …` — dir-as-script, no parent package
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pvlint.core import Track, load
from pvlint.rules import check
COLOR = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m"}
RESET = "\033[0m"
BOLD = "\033[1m"
def render(track: Track, findings: list, show_info: bool, use_color: bool) -> None:
shown = [f for f in findings if show_info or f.severity != "info"]
if not shown:
return
c = (lambda s, k: f"{COLOR[k]}{s}{RESET}") if use_color else (lambda s, k: s)
b = (lambda s: f"{BOLD}{s}{RESET}") if use_color else (lambda s: s)
print(b(track.path))
for f in shown:
loc = f"{f.line}:"
sev = c(f.severity.upper().ljust(7), f.severity)
print(f" {loc:>6} {sev} {f.rule} {f.message}")
if f.detail:
for ln in f.detail.split("\n"):
print(f" {ln}")
print()
def apply_fixes(track: Track, findings: list) -> tuple[str, int]:
"""Apply line-replacement fixes bottom-up so earlier line numbers stay valid."""
lines = track.text.splitlines()
fixable = [f for f in findings if f.fix is not None]
applied = 0
for f in sorted(fixable, key=lambda f: -f.line):
idx = f.line - 1
if idx < 0 or idx >= len(lines):
continue
if f.fix == "":
del lines[idx]
else:
lines[idx] = f.fix
applied += 1
text = "\n".join(lines)
if track.text.endswith("\n"):
text += "\n"
return text, applied
def orphan_report(tracks: list[Track]) -> int:
"""Cross-track check: what does each transition leave playing?"""
print(f"{BOLD}orphan-orbit transitions{RESET}")
worst = 0
for a, b in zip(tracks, tracks[1:]):
left = sorted(a.declared_orbits() - b.declared_orbits())
if left:
worst = max(worst, len(left))
names = ", ".join(f"d{o}" for o in left)
print(f" {a.path.split('/')[-1]} -> {b.path.split('/')[-1]}: "
f"{COLOR['warning']}{names} keep playing{RESET}")
else:
print(f" {a.path.split('/')[-1]} -> {b.path.split('/')[-1]}: clean")
print()
return worst
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="pvlint", description=__doc__.split("\n")[0])
ap.add_argument("paths", nargs="+", help=".tidal files to check")
ap.add_argument("--fix", action="store_true", help="apply safe autofixes in place")
ap.add_argument("--info", action="store_true", help="also show info findings")
ap.add_argument("--strict", action="store_true", help="warnings fail too")
ap.add_argument("--setlist", action="store_true",
help="treat paths as an ordered setlist and report orphan orbits")
ap.add_argument("--quiet", action="store_true", help="only print a summary")
ap.add_argument("--no-color", action="store_true")
args = ap.parse_args(argv)
use_color = not args.no_color and sys.stdout.isatty()
tracks: list[Track] = []
errors = warnings = fixed = 0
for path in args.paths:
try:
track = load(path)
except OSError as e:
print(f"{path}: cannot read ({e})", file=sys.stderr)
errors += 1
continue
tracks.append(track)
findings = check(track)
if args.fix:
text, n = apply_fixes(track, findings)
if n:
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
fixed += n
track = load(path)
tracks[-1] = track
findings = check(track)
if not args.quiet:
render(track, findings, args.info, use_color)
errors += sum(1 for f in findings if f.severity == "error")
warnings += sum(1 for f in findings if f.severity == "warning")
if args.setlist and len(tracks) > 1:
orphan_report(tracks)
n = len(tracks)
summary = f"pvlint: {n} track(s), {errors} error(s), {warnings} warning(s)"
if args.fix:
summary += f", {fixed} autofix(es) applied"
print(summary)
if errors:
return 1
if warnings and args.strict:
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
"""pvlint core — parse a .tidal track into the shapes the rules reason about.
Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
Three separate evenings were lost to bugs that were *visible in the text* and
that no human eye caught mid-set:
* `desire.tidal` -> `off 0.125 (|+ note 12 . (|* gain 0.6))` parsed as a
function composed into a ValueMap. One character. NINE orbits silent, and
because a blank line is Tidal's block separator, the whole track failed to
compile while looking perfectly fine on screen.
* `you_my_sunshine.tidal` -> a local `let gF3 = (# djfbus 3 (range 0.05 0.95
"^51"))` shadowing the fixed global helper. `djf` 0.05 is a ~26 Hz
low-pass, i.e. silence, and that is where a low knob rests. FOUR orbits
(d5/d7/d9/d11) inaudible until a knob crossed centre.
* An untouched `"^NN"` yields NO events, so `range a b "^NN"` is `silence`,
not a number — the #55 mute-bomb.
Every one of these is a STATIC property of the file. None of them produce an
error message; they produce silence, on stage, with a crowd waiting. So they
belong in a linter, not in a runbook.
The parse model
---------------
A `.tidal` file is a sequence of BLOCKS separated by blank lines. Tidal
evaluates a block atomically: one syntax error anywhere in a block takes the
entire block down, which is why "9 orbits went silent" is a *compile* symptom
and not an audio one. Inside a block, an orbit is declared by `dN $ ...` at
column 0 and continues until the next such declaration or the end of the block.
That three-level model (file -> block -> orbit) is all the rules need.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Iterator
SEVERITIES = ("error", "warning", "info")
# `d1 $ ...` at column 0 — but the `$` need NOT be on the same line. Haskell's
# layout rule makes this legal, and PLN writes it live all the time:
#
# d5 -- The Voice of Love
# $ midiOn "^58" (...)
#
# An earlier version of this regex required a same-line `$` and silently dropped
# d5 from the orbit inventory of a track that was playing it — a linter that
# under-reports is worse than none, so the lookahead accepts `$`, a comment, or
# end-of-line.
ORBIT_RE = re.compile(r"^(d(\d{1,2}))(?=\s*(?:\$|--|$))")
# A Haskell line comment. Not string-aware on purpose: `--` inside a mini-notation
# string is not valid Tidal anyway, and being conservative here costs nothing.
COMMENT_RE = re.compile(r"--.*$")
@dataclass
class Finding:
"""One lint result, anchored to a 1-indexed line."""
rule: str
severity: str
line: int
message: str
detail: str = ""
# Autofix: replace line `line` (1-indexed) entirely with `fix`. None = no fix.
# A fix of "" means "delete this line".
fix: str | None = None
def __post_init__(self) -> None:
if self.severity not in SEVERITIES:
raise ValueError(f"bad severity {self.severity!r}")
@dataclass
class Orbit:
"""One `dN $ ...` declaration and its continuation lines."""
number: int
start: int # 1-indexed line of the `dN $`
lines: list[str] = field(default_factory=list)
@property
def text(self) -> str:
return "\n".join(self.lines)
def code(self) -> str:
"""The orbit's text with comments stripped — what Tidal actually sees."""
return "\n".join(strip_comment(ln) for ln in self.lines)
@dataclass
class Block:
"""A blank-line-delimited unit. Tidal compiles this atomically."""
start: int # 1-indexed line of the first line
lines: list[str] = field(default_factory=list)
@property
def text(self) -> str:
return "\n".join(self.lines)
@dataclass
class Track:
path: str
text: str
@property
def lines(self) -> list[str]:
return self.text.splitlines()
def blocks(self) -> list[Block]:
out: list[Block] = []
cur: Block | None = None
for i, ln in enumerate(self.lines, start=1):
if ln.strip() == "":
cur = None
continue
if cur is None:
cur = Block(start=i)
out.append(cur)
cur.lines.append(ln)
return out
def orbits(self) -> list[Orbit]:
"""Every `dN $` declaration in the file, with its continuation lines.
An orbit ends at the next column-0 `dN $`, at a blank line, or at EOF.
Later declarations of the same N are kept separately: re-declaring `d5`
in one file is itself worth flagging, and silently merging them would
hide it.
"""
out: list[Orbit] = []
cur: Orbit | None = None
for i, ln in enumerate(self.lines, start=1):
m = ORBIT_RE.match(ln)
if m:
cur = Orbit(number=int(m.group(2)), start=i, lines=[ln])
out.append(cur)
continue
if ln.strip() == "":
cur = None
continue
if cur is not None:
cur.lines.append(ln)
return out
def declared_orbits(self) -> set[int]:
return {o.number for o in self.orbits()}
def strip_comment(line: str) -> str:
"""Remove a trailing Haskell comment.
Deliberately naive: we do not attempt to respect `--` inside a string
literal. Tidal mini-notation has no legitimate `--`, and a false strip can
only ever cause a MISSED finding, never a wrong autofix (autofixing rules
re-match against the raw line).
"""
return COMMENT_RE.sub("", line)
def iter_code_lines(track: Track) -> Iterator[tuple[int, str]]:
"""(1-indexed line number, comment-stripped text) for non-blank lines."""
for i, ln in enumerate(track.lines, start=1):
code = strip_comment(ln)
if code.strip():
yield i, code
def load(path: str) -> Track:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return Track(path=path, text=fh.read())
"""pvlint rules — each one encodes a bug that actually silenced this rig.
A rule earns its place here by having cost a real evening. Nothing speculative:
if it has never made sound disappear, it is not a rule.
Severity contract
-----------------
error — this WILL produce silence or a failed compile. Blocks a gig check.
warning — this can produce silence under a knob position we know occurs.
info — structural facts a human should see (orbit sets, cut groups).
"""
from __future__ import annotations
import os
import re
from typing import Callable, Iterable
from .core import Finding, Track, iter_code_lines, strip_comment
Rule = Callable[[Track], Iterable[Finding]]
RULES: list[Rule] = []
def rule(fn: Rule) -> Rule:
RULES.append(fn)
return fn
# --------------------------------------------------------------------------
# PV001 — a track redefines the global DJ-filter helpers with the old footgun
# --------------------------------------------------------------------------
LET_GF_RE = re.compile(r"^\s*let\s+(gF\d|gMask|gMute\d?|gM\d)\s*=")
DJFBUS_RE = re.compile(r"djfbus")
@rule
def pv001_local_djfbus_shadow(track: Track) -> Iterable[Finding]:
"""`let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))` — the 2026-07-28 bug.
SuperDirt's dj-filter maps `djf` through `linexp(0, 0.5, 20, 10000)`, so
bypass is 0.5 (the CENTRE) and 0.05 is a ~26 Hz low-pass. `range 0.05 0.95`
puts a low or untouched knob at the silent end. BootTidal's `gDJF` was fixed
to be safe-at-centre; a local `let` throws that fix away.
Autofix: delete the line so the track inherits the corrected global helper.
"""
for i, code in iter_code_lines(track):
m = LET_GF_RE.match(code)
if m and DJFBUS_RE.search(code):
yield Finding(
rule="PV001",
severity="error",
line=i,
message=f"local `{m.group(1)}` shadows the fixed global helper "
f"with the djfbus footgun",
detail="djf 0.05 is a ~26 Hz low-pass = silence; bypass is 0.5 "
"(centre). Delete this line to inherit BootTidal's "
"safe-at-centre gDJF.",
fix="",
)
# --------------------------------------------------------------------------
# PV002 — an operator section containing a top-level function composition
# --------------------------------------------------------------------------
def _paren_spans(s: str) -> list[tuple[int, int]]:
"""Every balanced (start, end_exclusive) paren span, outermost included."""
stack: list[int] = []
out: list[tuple[int, int]] = []
for i, ch in enumerate(s):
if ch == "(":
stack.append(i)
elif ch == ")" and stack:
out.append((stack.pop(), i + 1))
return out
def _top_level_dots(inner: str) -> list[int]:
"""Indices of ` . ` composition operators at depth 0, OUTSIDE string literals.
String-awareness is not optional here. In Tidal mini-notation `.` is the
grouping separator, so `"k ~ ~ k . ~"` and `(# gain "0 . 0 1")` are perfectly
ordinary code. The first version of this scanner ignored quotes and reported
all four as compile-killers — a linter that cries wolf on a pre-gig gate is
worse than no linter, because the real finding gets scrolled past.
"""
depth = 0
in_str = False
out: list[int] = []
for i, ch in enumerate(inner):
if in_str:
if ch == '"' and (i == 0 or inner[i - 1] != "\\"):
in_str = False
continue
if ch == '"':
in_str = True
elif ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
elif ch == "." and depth == 0:
before = inner[i - 1] if i else " "
after = inner[i + 1] if i + 1 < len(inner) else " "
# ` . ` is composition; `0.5` and `Sound.Tidal` are not.
if before == " " and after == " ":
out.append(i)
return out
@rule
def pv002_section_composition(track: Track) -> Iterable[Finding]:
"""`(|+ note 12 . (|* gain 0.6))` — the bug that killed desire.tidal.
`.` binds looser than application, so this parses as
`(|+) note (12 . (|* gain 0.6))`: a FUNCTION composed into a ValueMap. GHC
rejects it with "'(.)' is applied to too few arguments", the whole block
fails to compile, and every orbit in that block goes silent at once.
The fix is one pair of parens: `((|+ note 12) . (|* gain 0.6))`.
"""
for i, code in iter_code_lines(track):
raw = track.lines[i - 1]
for a, b in _paren_spans(code):
inner = code[a + 1:b - 1]
head = inner.lstrip()
if not (head.startswith("|") or head.startswith("#")):
continue
dots = _top_level_dots(inner)
if not dots:
continue
fix = None
if len(dots) == 1:
d = dots[0]
left, right = inner[:d].rstrip(), inner[d + 1:].lstrip()
new_inner = f"({left}) . {right}"
candidate = code[:a + 1] + new_inner + code[b - 1:]
# Only offer the fix if we can splice it back into the RAW line
# (i.e. the span is not straddling a stripped comment).
if code.strip() and code in raw or raw.strip() == code.strip():
fix = raw.replace(code.strip(), candidate.strip(), 1)
yield Finding(
rule="PV002",
severity="error",
line=i,
message="operator section contains a top-level `.` — this "
"composes a function into a ValueMap",
detail="`.` binds looser than application, so "
"`(|+ note 12 . f)` is `(|+) note (12 . f)`. GHC fails "
"the WHOLE block, silencing every orbit in it. "
"Wrap the section: `((|+ note 12) . f)`.",
fix=fix,
)
# --------------------------------------------------------------------------
# PV003 — CCs that are MIDI-learned to Ardour must never appear in a track
# --------------------------------------------------------------------------
CC_RE = re.compile(r'"\^(\d{1,3})"')
# CC77 ONLY. An earlier version of this rule flagged the whole 77-84 fader bank
# as "owned by Ardour" and produced 1109 errors across the corpus — every one of
# them wrong. BootTidal itself reads `^77` (`midiGGlobal = orDef 0.769 "^77" *
# 1.3`), and reading a fader CC in a track is the documented ParVagues
# convention. The hazard is not READING; it is that CC77 has TWO owners (Ardour's
# MIDI-learn and Tidal's global gain), so one physical fader can silence
# everything with no visible cause in either. That is task #53, and until it is
# resolved a track that reads ^77 directly deserves a note, not a failure.
GLOBAL_GAIN_CC = 77
@rule
def pv003_global_gain_cc(track: Track) -> Iterable[Finding]:
"""A track reading `"^77"` directly stacks on top of BootTidal's global gain.
`midiGGlobal` already multiplies every `_gainG` stream by `^77`. A track that
reads it again squares the fader: at 0.5 you get 0.25, and at the bottom you
get silence twice over, with the cause invisible from Tidal.
"""
for i, code in iter_code_lines(track):
for m in CC_RE.finditer(code):
if int(m.group(1)) == GLOBAL_GAIN_CC:
yield Finding(
rule="PV003",
severity="warning",
line=i,
message='"^77" is already applied globally by midiGGlobal',
detail="BootTidal multiplies gain by ^77 for every stream. "
"Reading it again squares the fader. CC77 is also "
"MIDI-learned in Ardour — two owners, one control "
"(task #53).",
)
# --------------------------------------------------------------------------
# PV004 — two orbits sharing one cut group choke each other
# --------------------------------------------------------------------------
CUT_RE = re.compile(r"#\s*cut\s+(\d+)")
@rule
def pv004_cut_group_collision(track: Track) -> Iterable[Finding]:
"""Two orbits in the same `cut` group truncate each other to clicks.
`cut N` means "only one voice at a time in group N". Across orbits that is
almost always accidental, and the symptom is the nastiest kind: the sound is
THERE, it just stops early, so it reads as a fade or a bad sample rather
than as a routing mistake.
"""
owners: dict[int, list[tuple[int, int]]] = {}
for orb in track.orbits():
for ln_off, ln in enumerate(orb.lines):
for m in CUT_RE.finditer(strip_comment(ln)):
owners.setdefault(int(m.group(1)), []).append(
(orb.number, orb.start + ln_off)
)
for group, uses in sorted(owners.items()):
orbs = sorted({o for o, _ in uses})
if len(orbs) > 1:
where = ", ".join(f"d{o}" for o in orbs)
# One finding per ORBIT, not per `# cut` occurrence: an orbit that
# sets the same group on three lines is one mistake, not three.
first_line_of = {}
for o, line in uses:
first_line_of.setdefault(o, line)
for _, line in sorted(first_line_of.items()):
yield Finding(
rule="PV004",
severity="warning",
line=line,
message=f"cut group {group} is shared by {where}",
detail="Orbits in one cut group cut each other off. If that "
"is deliberate, say so in a comment; otherwise give "
"each orbit its own group.",
)
# --------------------------------------------------------------------------
# PV005 — a duplicate `dN` declaration silently discards the earlier one
# --------------------------------------------------------------------------
@rule
def pv005_duplicate_orbit(track: Track) -> Iterable[Finding]:
"""Declaring `d5` twice in one file: the last one silently wins.
Easy to create while editing live (copy a block, forget to renumber) and
invisible on screen because both look correct in isolation.
"""
# Scoped to a BLOCK, deliberately. Re-declaring `d5` in a *later* block is
# normal ParVagues practice — a track file keeps several evaluable versions
# of an orbit and you pick one live. Flagging that produced 576 findings on
# the corpus, all of them noise. Within ONE block it is a real mistake: the
# second declaration silently wins and the first is dead code.
from .core import ORBIT_RE
seen: dict[int, int] = {}
for i, ln in enumerate(track.lines, start=1):
if ln.strip() == "":
seen = {}
continue
m = ORBIT_RE.match(ln)
if not m:
continue
num = int(m.group(2))
if num in seen:
yield Finding(
rule="PV005",
severity="warning",
line=i,
message=f"d{num} is declared twice in the same block (first at "
f"line {seen[num]})",
detail="Within one block the later declaration silently wins "
"and the earlier is dead code. For two layers use "
"`superimpose` or a different orbit.",
)
else:
seen[num] = i
# --------------------------------------------------------------------------
# PV006 — a sample index past the end of its folder is silence, not an error
# --------------------------------------------------------------------------
# `"foldername"` or `"foldername:3"` or `"foldername/2"` as a bare sound literal.
SOUND_RE = re.compile(r'"([a-zA-Z][a-zA-Z0-9_]{2,})(?::(\d+))?(?:/\d+)?"')
N_PATTERN_RE = re.compile(r'#\s*n\s+"([^"]*)"')
INT_RE = re.compile(r"\d+")
# Keyed by (folder, roots): the roots are configurable (PVLINT_SAMPLE_ROOTS),
# so caching on the folder name alone leaks one test's tmp_path into the next.
_SAMPLE_CACHE: dict[tuple[str, tuple[str, ...]], int | None] = {}
def _folder_size(name: str, roots: list[str]) -> int | None:
"""Number of audio files in a sample folder, or None if not found."""
key = (name, tuple(roots))
if key in _SAMPLE_CACHE:
return _SAMPLE_CACHE[key]
size = None
for root in roots:
d = os.path.join(root, name)
if os.path.isdir(d):
size = sum(
1
for f in os.listdir(d)
if f.lower().endswith((".wav", ".aif", ".aiff", ".flac", ".ogg"))
)
break
_SAMPLE_CACHE[key] = size
return size
def sample_roots() -> list[str]:
env = os.environ.get("PVLINT_SAMPLE_ROOTS")
if env:
return [p for p in env.split(":") if p]
home = os.path.expanduser("~")
return [
os.path.join(home, "Work", "Sound", "Samples"),
os.path.join(home, ".local", "share", "SuperCollider", "downloaded-quarks",
"Dirt-Samples"),
]
@rule
def pv006_sample_index_out_of_range(track: Track) -> Iterable[Finding]:
"""`# n "12"` on a 10-sample folder plays NOTHING and logs nothing useful.
SuperDirt wraps or drops out-of-range indices depending on the path taken;
either way the musical result is "that layer didn't come in" with no error
the performer can see. Since the folders are on disk, this is checkable
before the gig instead of during it.
Skipped silently when the sample root cannot be resolved, so the linter
stays usable on a machine that is not the rig.
"""
roots = sample_roots()
if not any(os.path.isdir(r) for r in roots):
return
for orb in track.orbits():
code = orb.code()
folders = {m.group(1) for m in SOUND_RE.finditer(code)}
# An index is only attributable to a folder in two unambiguous cases:
# "folder:N" -> N belongs to that folder, always
# a single-folder orbit -> its `# n "..."` can only mean that folder
# Pooling every index against every folder (the first version of this
# rule) invented a finding on d8 of you_my_sunshine, where n=24 belongs
# to org_jungle_breaks and breaks165 has one sample. A false positive on
# a pre-gig gate is how a real one gets ignored, so we under-report here
# on purpose.
per_folder: dict[str, set[int]] = {f: set() for f in folders}
for m in SOUND_RE.finditer(code):
if m.group(2):
per_folder.setdefault(m.group(1), set()).add(int(m.group(2)))
if len(folders) == 1:
only = next(iter(folders))
for m in N_PATTERN_RE.finditer(code):
per_folder[only].update(int(x) for x in INT_RE.findall(m.group(1)))
for folder in sorted(folders):
indices = per_folder.get(folder) or set()
if not indices:
continue
size = _folder_size(folder, roots)
if not size:
continue
over = sorted(i for i in indices if i >= size)
if over:
yield Finding(
rule="PV006",
severity="warning",
line=orb.start,
message=f'd{orb.number}: index {over[0]} is past the end of '
f'"{folder}" ({size} samples, max n={size - 1})',
detail=f"out-of-range indices: {over}. An index past the "
"end plays nothing and reports nothing — the layer "
"just never arrives.",
)
# --------------------------------------------------------------------------
# PV007 — structural facts worth printing (orbit set drives orphan detection)
# --------------------------------------------------------------------------
@rule
def pv007_orbit_inventory(track: Track) -> Iterable[Finding]:
"""Report the declared orbit set.
`dN` replaces orbit N and says NOTHING about the others, so switching to a
track that declares fewer orbits leaves the previous track's extras playing
underneath — audible, immune to the new track's gains, still filtered by the
global helpers. The set is the input to that cross-track check.
"""
orbs = sorted(track.declared_orbits())
if orbs:
yield Finding(
rule="PV007",
severity="info",
line=1,
message="declares orbits " + ",".join(f"d{o}" for o in orbs),
detail="Switching FROM a track with a larger orbit set leaves "
"ghosts. See RUNBOOK.md 'orphan orbits'.",
)
def check(track: Track, enabled: set[str] | None = None) -> list[Finding]:
out: list[Finding] = []
for fn in RULES:
for f in fn(track):
if enabled is None or f.rule in enabled:
out.append(f)
out.sort(key=lambda f: (f.line, f.rule))
return out
"""Unit tests for pvlint rules.
Every test here is a REGRESSION: the positive cases are bugs that actually
silenced the rig, and the negative cases are real lines from the corpus that an
earlier version of a rule wrongly flagged. A rule that cries wolf on a pre-gig
gate is worse than no rule, so the negative cases carry equal weight.
"""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
from pvlint.core import Track, strip_comment # noqa: E402
from pvlint.rules import check # noqa: E402
def lint(src: str, rule: str | None = None):
findings = check(Track(path="<test>", text=src))
if rule:
findings = [f for f in findings if f.rule == rule]
return findings
# ---------------------------------------------------------------- core parse
def test_orbit_needs_no_same_line_dollar():
"""Haskell layout lets the `$` start the next line — and PLN writes that.
The first ORBIT_RE required a same-line `$` and silently dropped d5 from
you_my_sunshine's inventory while the track was playing it.
"""
src = 'd5 -- The Voice of Love\n $ midiOn "^58" (# n 1)\n # cut 5\n'
assert Track(path="t", text=src).declared_orbits() == {5}
def test_blank_line_splits_blocks():
src = "d1 $ s \"bd\"\n\nd2 $ s \"sn\"\n"
blocks = Track(path="t", text=src).blocks()
assert len(blocks) == 2
def test_orbit_continuation_lines_are_captured():
src = 'd1 $ s "bd"\n # gain 1.2\n # cut 1\n'
orb = Track(path="t", text=src).orbits()[0]
assert len(orb.lines) == 3
def test_strip_comment():
assert strip_comment('d1 $ s "bd" -- kick').strip() == 'd1 $ s "bd"'
# ------------------------------------------------------------------- PV001
def test_pv001_flags_local_djfbus_shadow():
"""The 2026-07-28 bug: four orbits inaudible until a knob crossed centre."""
src = 'let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))\n'
f = lint(src, "PV001")
assert len(f) == 1 and f[0].severity == "error"
def test_pv001_autofix_deletes_the_line():
f = lint('let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))\n', "PV001")
assert f[0].fix == ""
def test_pv001_ignores_a_local_gmask_without_djfbus():
"""Redefining gMask locally is a musical choice, not the footgun."""
assert lint('let gMask = (midiOn "^41" (mask "t f"))\n', "PV001") == []
# ------------------------------------------------------------------- PV002
def test_pv002_flags_the_desire_bug():
"""`(|+ note 12 . (|* gain 0.6))` — killed nine orbits in one block."""
src = 'd1 $ off 0.125 (|+ note 12 . (|* gain 0.6)) $ s "bd"\n'
f = lint(src, "PV002")
assert len(f) == 1 and f[0].severity == "error"
def test_pv002_autofix_adds_the_missing_parens():
src = 'd1 $ off 0.125 (|+ note 12 . (|* gain 0.6)) $ s "bd"\n'
f = lint(src, "PV002")
assert f[0].fix is not None
assert "((|+ note 12) . (|* gain 0.6))" in f[0].fix
def test_pv002_accepts_the_corrected_form():
src = 'd1 $ off 0.125 ((|+ note 12) . (|* gain 0.6)) $ s "bd"\n'
assert lint(src, "PV002") == []
@pytest.mark.parametrize("src", [
' $ whenTrap (|<| "k ~ ~ k . ~")',
' $ whenmod 16 15 (# gain "0 . 0 . [0 . 1] . 1")',
' $ every 4 (# gain "0 . 0 1")',
' $ always (|+ note "0 . <[-2 -4] [5 0]>")',
])
def test_pv002_ignores_mininotation_dots(src):
"""`.` inside a string is mini-notation grouping, NOT Haskell composition.
All four of these are real corpus lines that the first, string-blind
scanner reported as compile-killers.
"""
assert lint(src + "\n", "PV002") == []
def test_pv002_ignores_decimal_points():
assert lint('d1 $ s "bd" # (|* gain 0.6)\n', "PV002") == []
# ------------------------------------------------------------------- PV003
def test_pv003_flags_the_global_gain_cc():
f = lint('d1 $ s "bd" # gain "^77"\n', "PV003")
assert len(f) == 1 and f[0].severity == "warning"
@pytest.mark.parametrize("cc", [49, 51, 76, 78, 80, 84, 91])
def test_pv003_allows_every_other_cc_including_the_fader_bank(cc):
"""Reading a fader CC in a track is the documented convention, not a bug.
Flagging the whole 77-84 bank produced 1109 corpus errors, all wrong.
"""
assert lint(f'd1 $ s "bd" # gain "^{cc}"\n', "PV003") == []
# ------------------------------------------------------------------- PV004
def test_pv004_flags_shared_cut_group_across_orbits():
"""you_my_sunshine: d5 (voice) and d11 both `cut 5` — they truncate each other."""
src = 'd5 $ s "voice" # cut 5\n\nd11 $ s "chop" # cut 5\n'
f = lint(src, "PV004")
assert {x.line for x in f} == {1, 3}
def test_pv004_one_finding_per_orbit_not_per_occurrence():
src = 'd5 $ s "a" # cut 5\n # cut 5\n\nd11 $ s "b" # cut 5\n'
assert len(lint(src, "PV004")) == 2
def test_pv004_allows_distinct_groups():
src = 'd5 $ s "a" # cut 5\n\nd11 $ s "b" # cut 11\n'
assert lint(src, "PV004") == []
# ------------------------------------------------------------------- PV005
def test_pv005_flags_duplicate_orbit_in_one_block():
src = 'd1 $ s "bd"\nd1 $ s "sn"\n'
f = lint(src, "PV005")
assert len(f) == 1 and f[0].line == 2
def test_pv005_allows_redeclaration_in_a_later_block():
"""Keeping several evaluable versions of an orbit is normal live practice."""
src = 'd1 $ s "bd"\n\nd1 $ s "sn"\n'
assert lint(src, "PV005") == []
# ------------------------------------------------------------------- PV006
def test_pv006_skips_when_sample_root_is_absent(monkeypatch, tmp_path):
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path / "nope"))
assert lint('d1 $ s "ghostfolder:99"\n', "PV006") == []
def test_pv006_flags_index_past_end_of_folder(monkeypatch, tmp_path):
d = tmp_path / "twohit"
d.mkdir()
(d / "a.wav").write_bytes(b"")
(d / "b.wav").write_bytes(b"")
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
f = lint('d1 $ s "twohit:5"\n', "PV006")
assert len(f) == 1 and "5" in f[0].message
def test_pv006_accepts_in_range_index(monkeypatch, tmp_path):
d = tmp_path / "twohit"
d.mkdir()
(d / "a.wav").write_bytes(b"")
(d / "b.wav").write_bytes(b"")
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
assert lint('d1 $ s "twohit:1"\n', "PV006") == []
def test_pv006_does_not_pool_indices_across_folders(monkeypatch, tmp_path):
"""d8 of you_my_sunshine: n=24 belongs to org_jungle_breaks, not breaks165.
Pooling every index in an orbit against every folder invented a finding.
"""
one = tmp_path / "breaks165"
one.mkdir()
(one / "a.wav").write_bytes(b"")
many = tmp_path / "org_jungle_breaks"
many.mkdir()
for i in range(30):
(many / f"{i}.wav").write_bytes(b"")
monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
src = 'd8 $ s "org_jungle_breaks:24" # s "breaks165"\n'
assert lint(src, "PV006") == []
# ------------------------------------------------------------------- PV007
def test_pv007_reports_the_orbit_inventory():
src = 'd1 $ s "bd"\n\nd7 $ s "sn"\n'
f = lint(src, "PV007")
assert len(f) == 1 and "d1,d7" in f[0].message
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