|
#!/usr/bin/env python3 |
|
""" |
|
validate_pfm.py — parser + validator for PFM (Programmable Fun Music) v2. |
|
|
|
Pure stdlib. See format-spec.md for the grammar. |
|
|
|
v2 ADDITIONS (back-compatible with v1): |
|
- Drum kit on noise voice: K/S/H/C named hits (kick/snare/hat/crash) |
|
- Arpeggio macro: [C4 E4 G4]:4 cycles pitches for the given duration |
|
- Vibrato: C4~v:4 (note-level effect flag) |
|
- Pitch bend: C4>E4:4 |
|
- Swing: @swing 0.67 header |
|
- Triplets: :8t (already in v1, reaffirmed) |
|
- Echo voice: `voice echo follows=<name> delay=3/16 vol=0.4` |
|
- @style ambient|drone|energetic|standard — tunes musicality thresholds |
|
- wave=drums alias for noise |
|
|
|
v2 MUSICALITY CHECKS (warnings, not errors): |
|
- density : any 4-bar window most-active-voice < floor ev/s |
|
- percussion : @loop piece >8 bars with no noise/drums voice |
|
- harmony : implied chord static >4 bars |
|
- stagnation : lead repeats same pitch >3× consecutively w/o rhythm var |
|
- pedal-bass : bass is pure pedal tone >8 bars |
|
|
|
Exit 0 = clean (no errors, no warnings), 1 = issues. --json for machine output. |
|
""" |
|
from __future__ import annotations |
|
import sys, re, json, argparse |
|
from fractions import Fraction |
|
from dataclasses import dataclass, field |
|
from typing import List, Dict, Optional, Tuple |
|
|
|
# ---------------------------------------------------------------- pitch --- |
|
NOTE_BASE = {'C':0,'D':2,'E':4,'F':5,'G':7,'A':9,'B':11} |
|
PITCH_RE = re.compile(r'^([A-Ga-g])([#b]?)(-?\d)$') |
|
|
|
def pitch_to_midi(tok: str) -> int: |
|
m = PITCH_RE.match(tok) |
|
if not m: |
|
raise ValueError(f"bad pitch '{tok}'") |
|
letter, acc, octv = m.group(1).upper(), m.group(2), int(m.group(3)) |
|
semis = NOTE_BASE[letter] + (1 if acc=='#' else -1 if acc=='b' else 0) |
|
return 12 * (octv + 1) + semis # C4 -> 60 |
|
|
|
def midi_to_name(m: int) -> str: |
|
names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] |
|
return f"{names[m%12]}{m//12 - 1}" |
|
|
|
# ---------------------------------------------------------------- modes --- |
|
MODES = { |
|
'major': [0,2,4,5,7,9,11], |
|
'ionian': [0,2,4,5,7,9,11], |
|
'minor': [0,2,3,5,7,8,10], |
|
'aeolian': [0,2,3,5,7,8,10], |
|
'dorian': [0,2,3,5,7,9,10], |
|
'phrygian': [0,1,3,5,7,8,10], |
|
'lydian': [0,2,4,6,7,9,11], |
|
'mixolydian': [0,2,4,5,7,9,10], |
|
'locrian': [0,1,3,5,6,8,10], |
|
'pent_major': [0,2,4,7,9], |
|
'pent_minor': [0,3,5,7,10], |
|
'blues': [0,3,5,6,7,10], |
|
'chromatic': list(range(12)), |
|
} |
|
|
|
# ---------------------------------------------------------- consonance --- |
|
DIRECTED_SCORE = { |
|
0:1.0, 1:0.0, 2:0.3, 3:0.8, 4:0.8, 5:0.6, 6:0.0, |
|
7:1.0, 8:0.8, 9:0.8,10:0.3,11:0.0, |
|
} |
|
|
|
# ---------------------------------------------------------------- drums --- |
|
# Drum-kit macros for noise voice. Each is (pseudo-midi for LFSR rate, |
|
# adsr override, pitch-drop semitones over attack, label) |
|
DRUM_KIT = { |
|
'K': dict(midi=36, adsr=(0.001,0.08,0.0,0.02), drop=18, label='kick'), |
|
'S': dict(midi=64, adsr=(0.001,0.09,0.0,0.03), drop=4, label='snare'), |
|
'H': dict(midi=84, adsr=(0.001,0.025,0.0,0.01), drop=0, label='hat'), |
|
'O': dict(midi=82, adsr=(0.001,0.18,0.0,0.05), drop=0, label='openhat'), |
|
'C': dict(midi=80, adsr=(0.001,0.45,0.1,0.25), drop=0, label='crash'), |
|
} |
|
|
|
# ---------------------------------------------------------------- model --- |
|
@dataclass |
|
class Note: |
|
midi: Optional[int] # None = rest |
|
dur: Fraction # in whole-note units |
|
bar: int # 1-based |
|
beat: float # 1-based beat position within bar at onset |
|
tie_from_prev: bool = False |
|
vol: float = 1.0 |
|
adsr: Optional[Tuple[float,float,float,float]] = None |
|
tok: str = '' |
|
# v2 |
|
arp: Optional[List[int]] = None # list of midi pitches to cycle |
|
drum: Optional[str] = None # 'K','S','H','C','O' |
|
vibrato: bool = False |
|
bend_to: Optional[int] = None # target midi for pitch bend |
|
|
|
@dataclass |
|
class Voice: |
|
name: str |
|
wave: str = 'pulse' |
|
duty: float = 50.0 |
|
adsr: Tuple[float,float,float,float] = (0.005,0.05,0.7,0.05) |
|
vol: float = 0.8 |
|
rng: Tuple[int,int] = (pitch_to_midi('C1'), pitch_to_midi('C7')) |
|
bars: List[List['Note']] = field(default_factory=list) |
|
# v2 |
|
follows: Optional[str] = None # echo source voice |
|
delay: Fraction = Fraction(0) # echo delay in whole-note units |
|
role: Optional[str] = None # 'lead','bass','drums','harmony' hint |
|
|
|
@dataclass |
|
class Song: |
|
title: str = '' |
|
tempo: float = 120.0 |
|
timesig: Tuple[int,int] = (4,4) |
|
key_root: int = 0 |
|
key_mode: str = 'major' |
|
loop: bool = False |
|
swing: float = 0.5 # 0.5 = straight; 0.67 = triplet swing |
|
style: str = 'standard' # ambient|drone|energetic|standard |
|
voices: Dict[str,'Voice'] = field(default_factory=dict) |
|
master: Dict[str,float] = field(default_factory=lambda: {'headroom_db': -3.0, 'limiter': 'none'}) |
|
errors: List[str] = field(default_factory=list) |
|
|
|
@property |
|
def bar_len(self) -> Fraction: |
|
n,d = self.timesig |
|
return Fraction(n, d) |
|
|
|
@property |
|
def beat_len(self) -> Fraction: |
|
return Fraction(1, self.timesig[1]) |
|
|
|
# -------------------------------------------------------------- parsing --- |
|
DUR_RE = re.compile(r'^(\d+)(?:/(\d+))?(\.{0,2})(t?)$') |
|
|
|
def parse_dur(tok: str) -> Fraction: |
|
m = DUR_RE.match(tok) |
|
if not m: |
|
raise ValueError(f"bad duration '{tok}'") |
|
a = int(m.group(1)) |
|
b = m.group(2) |
|
base = Fraction(a, int(b)) if b else Fraction(1, a) |
|
dots = len(m.group(3)) |
|
if dots == 1: base = base * Fraction(3,2) |
|
elif dots == 2: base = base * Fraction(7,4) |
|
if m.group(4) == 't': |
|
base = base * Fraction(2,3) |
|
return base |
|
|
|
def parse_frac(tok: str) -> Fraction: |
|
"""Parse '3/16' or '0.1875' or '8' (→1/8) into whole-note Fraction.""" |
|
if '/' in tok: |
|
a,b = tok.split('/') |
|
return Fraction(int(a), int(b)) |
|
try: |
|
return Fraction(tok).limit_denominator(192) |
|
except Exception: |
|
return Fraction(1, int(tok)) |
|
|
|
# v2 event regex |
|
EVENT_RE = re.compile( |
|
r'^(?:' |
|
r'(?P<adsr>\{[^}]*\})' |
|
r'|(?P<rest>R):(?P<rdur>\S+)' |
|
r'|(?P<hold>-):(?P<hdur>\S+)' |
|
r'|(?P<drum>[KSHCO]):(?P<ddur>[0-9./t]+)(?:!(?P<dvol>[0-9.]+))?' |
|
r'|(?P<pitch>[A-Ga-g][#b]?\d)(?:>(?P<bend>[A-Ga-g][#b]?\d))?(?P<vib>~v)?' |
|
r':(?P<ndur>[0-9./t]+)(?P<tie>~?)(?:!(?P<vol>[0-9.]+))?' |
|
r')$' |
|
) |
|
ARP_RE = re.compile(r'^\[([^\]]+)\]:(?P<dur>[0-9./t]+)(?:!(?P<vol>[0-9.]+))?$') |
|
|
|
def _tokenize_bar(chunk: str) -> List[str]: |
|
"""Whitespace split, but keep [...] groups intact.""" |
|
out, buf, depth = [], '', 0 |
|
for ch in chunk: |
|
if ch == '[': depth += 1; buf += ch |
|
elif ch == ']': depth -= 1; buf += ch |
|
elif ch.isspace() and depth == 0: |
|
if buf: out.append(buf); buf='' |
|
else: |
|
buf += ch |
|
if buf: out.append(buf) |
|
return out |
|
|
|
def parse_pfm(text: str) -> Song: |
|
song = Song() |
|
pending_adsr: Dict[str, Optional[Tuple[float,float,float,float]]] = {} |
|
for lineno, raw in enumerate(text.splitlines(), 1): |
|
line = re.sub(r'(^|\s)#.*$', '', raw).rstrip() |
|
if not line.strip(): |
|
continue |
|
# ---- directives |
|
if line.startswith('@'): |
|
parts = line[1:].split() |
|
d = parts[0].lower() |
|
args = parts[1:] |
|
if d == 'title': song.title = ' '.join(args) |
|
elif d == 'tempo': song.tempo = float(args[0]) |
|
elif d == 'timesig': |
|
n,den = args[0].split('/'); song.timesig = (int(n), int(den)) |
|
elif d == 'key': |
|
root = args[0]; mode = args[1].lower() if len(args)>1 else 'major' |
|
r = NOTE_BASE[root[0].upper()] |
|
if len(root)>1: |
|
r += 1 if root[1]=='#' else -1 if root[1]=='b' else 0 |
|
song.key_root = r % 12 |
|
if mode not in MODES: |
|
song.errors.append(f"line {lineno}: unknown mode '{mode}', using chromatic") |
|
mode = 'chromatic' |
|
song.key_mode = mode |
|
elif d == 'loop': song.loop = True |
|
elif d == 'swing': song.swing = float(args[0]) |
|
elif d == 'style': song.style = args[0].lower() |
|
elif d == 'master': |
|
for kv in args: |
|
if '=' not in kv: continue |
|
k,val = kv.split('=',1) |
|
if k=='headroom': song.master['headroom_db']=float(val) |
|
elif k=='limiter': song.master['limiter']=val |
|
elif k=='gain': song.master['gain']=float(val) |
|
else: |
|
song.errors.append(f"line {lineno}: unknown directive @{d}") |
|
continue |
|
# ---- voice decl |
|
if line.startswith('voice ') or line.startswith('voice\t'): |
|
toks = line.split() |
|
name = toks[1] |
|
v = Voice(name=name) |
|
for kv in toks[2:]: |
|
if '=' not in kv: |
|
song.errors.append(f"line {lineno}: voice '{name}' bad attr '{kv}'"); continue |
|
k,val = kv.split('=',1); k=k.lower() |
|
if k=='wave': |
|
v.wave = 'noise' if val=='drums' else val |
|
if val=='drums': v.role='drums' |
|
elif k=='duty': v.duty = float(val) |
|
elif k=='vol': v.vol = float(val) |
|
elif k=='adsr': |
|
a = [float(x) for x in val.split(',')] |
|
if len(a)!=4: song.errors.append(f"line {lineno}: adsr needs 4 values") |
|
else: v.adsr = tuple(a) |
|
elif k=='range': |
|
lo,hi = val.split('-'); v.rng = (pitch_to_midi(lo), pitch_to_midi(hi)) |
|
elif k=='follows': v.follows = val |
|
elif k=='delay': v.delay = parse_frac(val) |
|
elif k=='role': v.role = val |
|
else: |
|
song.errors.append(f"line {lineno}: voice '{name}' unknown attr '{k}'") |
|
song.voices[name] = v |
|
pending_adsr[name] = None |
|
continue |
|
# ---- score line |
|
if ':' in line: |
|
vname, rest = line.split(':',1) |
|
vname = vname.strip() |
|
if vname not in song.voices: |
|
song.errors.append(f"line {lineno}: undeclared voice '{vname}'"); continue |
|
v = song.voices[vname] |
|
chunks = rest.split('|') |
|
if chunks and chunks[-1].strip()=='': chunks = chunks[:-1] |
|
for chunk in chunks: |
|
barno = len(v.bars) + 1 |
|
evs: List[Note] = [] |
|
pos = Fraction(0) |
|
tie_next = False |
|
for tok in _tokenize_bar(chunk): |
|
# arpeggio |
|
am = ARP_RE.match(tok) |
|
if am: |
|
try: |
|
pitches = [pitch_to_midi(p) for p in am.group(1).split()] |
|
d = parse_dur(am.group('dur')) |
|
except ValueError as e: |
|
song.errors.append(f"line {lineno}: {vname} bar {barno}: {e}"); continue |
|
beat = 1 + float(pos / song.beat_len) |
|
nv = float(am.group('vol')) if am.group('vol') else 1.0 |
|
evs.append(Note(pitches[0], d, barno, beat, vol=nv, |
|
adsr=pending_adsr.get(vname), arp=pitches, tok=tok)) |
|
pending_adsr[vname]=None; pos += d; tie_next=False; continue |
|
m = EVENT_RE.match(tok) |
|
if not m: |
|
song.errors.append(f"line {lineno}: voice {vname} bar {barno}: bad token '{tok}'"); continue |
|
if m.group('adsr'): |
|
try: |
|
nums = [float(x) for x in m.group('adsr').strip('{}').split(',')] |
|
if len(nums)!=4: raise ValueError |
|
pending_adsr[vname] = tuple(nums) |
|
except Exception: |
|
song.errors.append(f"line {lineno}: bad ADSR override '{tok}'") |
|
continue |
|
if m.group('rest'): |
|
d = parse_dur(m.group('rdur')); beat = 1 + float(pos / song.beat_len) |
|
evs.append(Note(None, d, barno, beat, tok=tok)) |
|
pos += d; tie_next=False; continue |
|
if m.group('hold'): |
|
d = parse_dur(m.group('hdur')); beat = 1 + float(pos / song.beat_len) |
|
evs.append(Note(-1, d, barno, beat, tie_from_prev=True, tok=tok)) |
|
pos += d; continue |
|
if m.group('drum'): |
|
dk = m.group('drum'); d = parse_dur(m.group('ddur')) |
|
beat = 1 + float(pos / song.beat_len) |
|
spec = DRUM_KIT[dk] |
|
nv = float(m.group('dvol')) if m.group('dvol') else 1.0 |
|
evs.append(Note(spec['midi'], d, barno, beat, vol=nv, |
|
adsr=spec['adsr'], drum=dk, tok=tok)) |
|
pos += d; tie_next=False; continue |
|
# pitched note |
|
d = parse_dur(m.group('ndur')) |
|
midi = pitch_to_midi(m.group('pitch')) |
|
beat = 1 + float(pos / song.beat_len) |
|
bend = pitch_to_midi(m.group('bend')) if m.group('bend') else None |
|
note = Note(midi, d, barno, beat, |
|
tie_from_prev=tie_next, |
|
vol=float(m.group('vol')) if m.group('vol') else 1.0, |
|
adsr=pending_adsr.get(vname), |
|
vibrato=bool(m.group('vib')), |
|
bend_to=bend, tok=tok) |
|
pending_adsr[vname] = None |
|
evs.append(note); pos += d |
|
tie_next = (m.group('tie') == '~') |
|
v.bars.append(evs) |
|
continue |
|
song.errors.append(f"line {lineno}: unrecognised line") |
|
# resolve holds |
|
for v in song.voices.values(): |
|
last_midi = None |
|
for bar in v.bars: |
|
for n in bar: |
|
if n.midi == -1: |
|
n.midi = last_midi |
|
if n.midi is not None: |
|
last_midi = n.midi |
|
# resolve echo voices: copy source bars, apply delay as leading rest |
|
for v in list(song.voices.values()): |
|
if v.follows: |
|
src = song.voices.get(v.follows) |
|
if not src: |
|
song.errors.append(f"voice {v.name}: follows='{v.follows}' not found"); continue |
|
if v.bars: |
|
song.errors.append(f"voice {v.name}: echo voice cannot have its own score lines"); continue |
|
if not v.wave or v.wave=='pulse': v.wave = src.wave |
|
# flatten src into absolute-time events then re-bar with delay |
|
bar_len = song.bar_len |
|
t = Fraction(0); seq=[] |
|
for bar in src.bars: |
|
for n in bar: |
|
seq.append((t, n)); t += n.dur |
|
total = t |
|
nbars = len(src.bars) |
|
# build echo sequence |
|
v.bars = [[] for _ in range(nbars)] |
|
pos = Fraction(0) |
|
def emit(dur, proto=None): |
|
nonlocal pos |
|
rem = dur |
|
while rem > 0: |
|
bi = int(pos // bar_len) |
|
if bi >= nbars: return |
|
in_bar = pos - bi*bar_len |
|
cap = bar_len - in_bar |
|
d = min(rem, cap) |
|
beat = 1 + float(in_bar / song.beat_len) |
|
if proto is None or proto.midi is None: |
|
v.bars[bi].append(Note(None, d, bi+1, beat, tok='R')) |
|
else: |
|
tie = (rem != dur) |
|
v.bars[bi].append(Note(proto.midi, d, bi+1, beat, |
|
tie_from_prev=tie, vol=proto.vol, adsr=proto.adsr, |
|
arp=proto.arp, drum=proto.drum, |
|
vibrato=proto.vibrato, bend_to=proto.bend_to, |
|
tok=proto.tok)) |
|
pos += d; rem -= d |
|
emit(v.delay, None) |
|
for (st,n) in seq: |
|
emit(n.dur, n) |
|
# pad |
|
if pos < nbars*bar_len: |
|
emit(nbars*bar_len - pos, None) |
|
return song |
|
|
|
# ----------------------------------------------------------- flattening --- |
|
def flatten_voice(v: Voice) -> List[Note]: |
|
"""Merge ties into single Note objects with summed duration.""" |
|
out: List[Note] = [] |
|
for bar in v.bars: |
|
for n in bar: |
|
if n.tie_from_prev and out and out[-1].midi == n.midi and n.midi is not None: |
|
prev = out[-1] |
|
out[-1] = Note(prev.midi, prev.dur + n.dur, prev.bar, prev.beat, |
|
prev.tie_from_prev, prev.vol, prev.adsr, prev.tok, |
|
prev.arp, prev.drum, prev.vibrato, prev.bend_to) |
|
else: |
|
out.append(n) |
|
return out |
|
|
|
# ----------------------------------------------------- role inference --- |
|
def infer_roles(song: Song) -> Dict[str,str]: |
|
"""Best-effort classification of each voice as lead/bass/drums/harmony.""" |
|
roles = {} |
|
for v in song.voices.values(): |
|
if v.role: roles[v.name]=v.role; continue |
|
if v.wave=='noise': roles[v.name]='drums'; continue |
|
if v.follows: roles[v.name]='echo'; continue |
|
# remaining pitched voices: lowest avg pitch = bass, highest = lead |
|
pitched = [] |
|
for v in song.voices.values(): |
|
if v.name in roles: continue |
|
ps = [n.midi for bar in v.bars for n in bar if n.midi is not None] |
|
if not ps: roles[v.name]='harmony'; continue |
|
pitched.append((sum(ps)/len(ps), v.name)) |
|
pitched.sort() |
|
have_lead = 'lead' in roles.values() |
|
have_bass = 'bass' in roles.values() |
|
for i,(avg,nm) in enumerate(pitched): |
|
if i==0 and (len(pitched)>1 or have_lead) and not have_bass: |
|
roles[nm]='bass' |
|
elif i==len(pitched)-1 and not have_lead: |
|
roles[nm]='lead' |
|
else: |
|
roles[nm]='harmony' |
|
# name-based hints as last resort |
|
for nm,r in list(roles.items()): |
|
low=nm.lower() |
|
if r=='harmony' and ('bass' in low or 'tri' in low) and 'bass' not in roles.values(): |
|
roles[nm]='bass' |
|
if r=='harmony' and 'lead' in low and 'lead' not in roles.values(): |
|
roles[nm]='lead' |
|
return roles |
|
|
|
# ----------------------------------------------------- implied harmony --- |
|
CHORD_TEMPLATES = { |
|
'maj':[0,4,7], 'min':[0,3,7], 'dim':[0,3,6], 'aug':[0,4,8], |
|
'sus4':[0,5,7], 'sus2':[0,2,7], |
|
} |
|
PC_NAMES=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] |
|
def implied_chords(song: Song) -> List[str]: |
|
"""One label per bar, derived from bass root + pitch-class histogram.""" |
|
roles = infer_roles(song) |
|
nbars = max((len(v.bars) for v in song.voices.values()), default=0) |
|
out=[] |
|
for bi in range(nbars): |
|
hist=[0.0]*12; bass_pc=None; bass_lo=999 |
|
for v in song.voices.values(): |
|
if v.wave=='noise': continue |
|
if bi>=len(v.bars): continue |
|
for n in v.bars[bi]: |
|
if n.midi is None: continue |
|
pcs = [p%12 for p in (n.arp or [n.midi])] |
|
w = float(n.dur)/len(pcs) |
|
for pc in pcs: hist[pc]+=w |
|
if roles.get(v.name)=='bass' and n.midi<bass_lo: |
|
bass_lo=n.midi; bass_pc=n.midi%12 |
|
if sum(hist)==0: out.append('—'); continue |
|
if bass_pc is None: |
|
bass_pc = max(range(12), key=lambda i:hist[i]) |
|
best=('?',-1) |
|
for q,iv in CHORD_TEMPLATES.items(): |
|
for root in ([bass_pc] + list(range(12))): |
|
sc = sum(hist[(root+i)%12] for i in iv) |
|
if root==bass_pc: sc*=1.5 |
|
if sc>best[1]: best=(f"{PC_NAMES[root]}{q}",sc) |
|
out.append(best[0]) |
|
return out |
|
|
|
# ------------------------------------------------------------ validator --- |
|
@dataclass |
|
class Issue: |
|
severity: str; check: str; where: str; msg: str |
|
def as_dict(self): return self.__dict__ |
|
|
|
STYLE_THRESHOLDS = { |
|
'standard': dict(density_floor=1.5, need_perc=True, harm_static=4, pedal_bars=8, bass_run_bars=4, bass_rest_min=8.0, vol_sum_max=1.6), |
|
'energetic': dict(density_floor=2.5, need_perc=True, harm_static=4, pedal_bars=4, bass_run_bars=8, bass_rest_min=0.0, vol_sum_max=1.8), |
|
'ambient': dict(density_floor=0.8, need_perc=True, harm_static=8, pedal_bars=16, bass_run_bars=4, bass_rest_min=15.0, vol_sum_max=1.4), |
|
'drone': dict(density_floor=0.4, need_perc=False, harm_static=16,pedal_bars=32, bass_run_bars=32, bass_rest_min=0.0, vol_sum_max=1.4), |
|
} |
|
|
|
def validate(song: Song) -> Tuple[List[Issue], Dict]: |
|
issues: List[Issue] = [] |
|
stats: Dict = {} |
|
for e in song.errors: |
|
issues.append(Issue('error','parse','',e)) |
|
|
|
bar_len = song.bar_len |
|
beat_len = song.beat_len |
|
scale = set((song.key_root + s) % 12 for s in MODES[song.key_mode]) |
|
roles = infer_roles(song) |
|
th = STYLE_THRESHOLDS.get(song.style, STYLE_THRESHOLDS['standard']) |
|
|
|
# ---- rhythm ---------------------------------------------------------- |
|
for v in song.voices.values(): |
|
for i, bar in enumerate(v.bars, 1): |
|
tot = sum((n.dur for n in bar), Fraction(0)) |
|
if tot != bar_len: |
|
beat = 1 + float(tot / beat_len) |
|
issues.append(Issue('error','rhythm', f"{v.name} bar {i}", |
|
f"expected {song.timesig[0]}/{song.timesig[1]} (={float(bar_len):.3f} whole), " |
|
f"got {float(tot):.3f} (ends at beat {beat:.2f})")) |
|
|
|
# ---- range ----------------------------------------------------------- |
|
for v in song.voices.values(): |
|
if v.wave=='noise': continue |
|
lo,hi = v.rng |
|
for bar in v.bars: |
|
for n in bar: |
|
if n.midi is None: continue |
|
for p in (n.arp or [n.midi]): |
|
if not (lo <= p <= hi): |
|
issues.append(Issue('error','range', |
|
f"{v.name} bar {n.bar} beat {n.beat:.2f}", |
|
f"{midi_to_name(p)} outside {midi_to_name(lo)}-{midi_to_name(hi)}")) |
|
|
|
# ---- tonality -------------------------------------------------------- |
|
total_dur = Fraction(0); in_key_dur = Fraction(0); out_notes = [] |
|
for v in song.voices.values(): |
|
if v.wave == 'noise': continue |
|
flat = flatten_voice(v) |
|
pitched = [n for n in flat if n.midi is not None] |
|
for idx,n in enumerate(pitched): |
|
pcs = (n.arp or [n.midi]) |
|
for p in pcs: |
|
d = n.dur/len(pcs); total_dur += d; pc=p%12 |
|
if pc in scale: in_key_dur += d |
|
else: |
|
prev_m = pitched[idx-1].midi if idx>0 else None |
|
next_m = pitched[idx+1].midi if idx+1<len(pitched) else None |
|
stepwise = (prev_m is not None and abs(p-prev_m)<=2) and \ |
|
(next_m is not None and abs(p-next_m)<=2) |
|
short = d <= Fraction(1,8) |
|
passing = stepwise and short |
|
out_notes.append({'voice':v.name,'bar':n.bar,'beat':round(n.beat,2), |
|
'note':midi_to_name(p),'dur':float(d),'passing':passing}) |
|
# common alterations that are idiomatic, downgrade to 'passing' |
|
root=song.key_root; mode=song.key_mode |
|
alter=set() |
|
if mode in ('minor','aeolian','dorian','phrygian','pent_minor','blues'): |
|
alter |= {(root+11)%12,(root+9)%12} # raised 7th, raised 6th (harmonic/melodic minor) |
|
if mode in ('major','ionian','mixolydian','lydian','pent_major'): |
|
alter |= {(root+10)%12,(root+3)%12} # b7, b3 (mixolydian/blues inflection) |
|
if mode in ('pent_minor','pent_major'): |
|
alter |= {(root+2)%12,(root+8)%12,(root+5)%12,(root+11)%12,(root+4)%12} # fill diatonic gaps |
|
alter |= {(root+6)%12} # tritone/blue note |
|
for o in out_notes: |
|
pc = pitch_to_midi(o['note'])%12 |
|
if pc in alter: o['passing']=True |
|
pct = float(in_key_dur/total_dur*100) if total_dur else 100.0 |
|
stats['tonality'] = {'in_key_pct': round(pct,1), |
|
'key': f"{PC_NAMES[song.key_root]} {song.key_mode}", |
|
'out_of_key': out_notes} |
|
non_passing = [o for o in out_notes if not o['passing']] |
|
for o in non_passing: |
|
issues.append(Issue('warn','tonality', |
|
f"{o['voice']} bar {o['bar']} beat {o['beat']}", |
|
f"{o['note']} not in {stats['tonality']['key']} (dur {o['dur']})")) |
|
if out_notes and not non_passing: |
|
issues.append(Issue('info','tonality','', |
|
f"{len(out_notes)} chromatic passing tone(s), all stepwise & short — OK")) |
|
|
|
# ---- dissonance ------------------------------------------------------ |
|
timelines: Dict[str,List[Tuple[Fraction,Fraction,int]]] = {} |
|
max_len = Fraction(0) |
|
for v in song.voices.values(): |
|
if v.wave == 'noise': continue |
|
t = Fraction(0); tl=[] |
|
for n in flatten_voice(v): |
|
if n.midi is not None: |
|
tl.append((t, t+n.dur, n.midi)) |
|
t += n.dur |
|
timelines[v.name] = tl |
|
if t > max_len: max_len = t |
|
grid = Fraction(1,16); scores=[]; harsh_run=Fraction(0); harsh_runs=[] |
|
t=Fraction(0); harsh_start=None |
|
while t < max_len: |
|
sounding=[] |
|
for tl in timelines.values(): |
|
for (s,e,m) in tl: |
|
if s<=t<e: sounding.append(m); break |
|
if len(sounding)>=2: |
|
ps=[] |
|
for i in range(len(sounding)): |
|
for j in range(i+1,len(sounding)): |
|
ps.append(DIRECTED_SCORE[abs(sounding[i]-sounding[j])%12]) |
|
sc=sum(ps)/len(ps); scores.append(sc) |
|
if sc<0.3: |
|
if harsh_start is None: harsh_start=t |
|
harsh_run+=grid |
|
else: |
|
if harsh_run>beat_len: |
|
harsh_runs.append((float(harsh_start/beat_len)+1,float(harsh_run/beat_len))) |
|
harsh_run=Fraction(0); harsh_start=None |
|
else: |
|
if harsh_run>beat_len: |
|
harsh_runs.append((float(harsh_start/beat_len)+1,float(harsh_run/beat_len))) |
|
harsh_run=Fraction(0); harsh_start=None |
|
t+=grid |
|
if harsh_run>beat_len and harsh_start is not None: |
|
harsh_runs.append((float(harsh_start/beat_len)+1,float(harsh_run/beat_len))) |
|
if scores: |
|
stats['dissonance']={'mean':round(sum(scores)/len(scores),3), |
|
'min':round(min(scores),3),'samples':len(scores), |
|
'harsh_runs':[{'start_beat':round(a,2),'length_beats':round(b,2)} for a,b in harsh_runs]} |
|
for (a,b) in harsh_runs: |
|
bar=int((a-1)//song.timesig[0])+1; beat=((a-1)%song.timesig[0])+1 |
|
issues.append(Issue('warn','dissonance',f"bar {bar} beat {beat:.2f}", |
|
f"sustained harsh interval for {b:.2f} beats (score<0.3)")) |
|
else: |
|
stats['dissonance']={'mean':None,'min':None,'samples':0,'harsh_runs':[]} |
|
|
|
# ---- loop seam ------------------------------------------------------- |
|
nbars = max((len(v.bars) for v in song.voices.values()), default=0) |
|
if song.loop: |
|
lens={v.name:len(v.bars) for v in song.voices.values()} |
|
if len(set(lens.values()))>1: |
|
issues.append(Issue('error','loop','',f"voice bar counts differ: {lens}")) |
|
for v in song.voices.values(): |
|
if v.wave=='noise' or v.follows: continue |
|
flat=[n for n in flatten_voice(v) if n.midi is not None] |
|
if len(flat)<2: continue |
|
last=flat[-1].midi; first=flat[0].midi; leap=abs(last-first) |
|
# bass lines routinely leap octaves at seams — judge by pitch class |
|
if roles.get(v.name) in ('bass','harmony'): |
|
leap = min(leap%12, 12-leap%12) |
|
elif leap%12==0: leap=0 |
|
if leap>7: |
|
issues.append(Issue('warn','loop',v.name, |
|
f"seam leap {midi_to_name(last)}→{midi_to_name(first)} = {leap} semitones (>P5)")) |
|
stats['loop']={'bars':lens} |
|
|
|
stats['rhythm']={'bars_per_voice':{v.name:len(v.bars) for v in song.voices.values()}} |
|
stats['roles']=roles |
|
|
|
# ═══════════════ v2 MUSICALITY CHECKS (warnings) ═════════════════════ |
|
bar_sec = float(bar_len) * (60.0/song.tempo) * 4.0 |
|
# per-voice onset counts per bar |
|
onsets = {v.name:[0]*nbars for v in song.voices.values()} |
|
for v in song.voices.values(): |
|
for bi,bar in enumerate(v.bars): |
|
for n in bar: |
|
if n.midi is not None and not n.tie_from_prev: |
|
onsets[v.name][bi]+=1 |
|
stats['onsets_per_bar']=onsets |
|
|
|
# ---- density floor (4-bar sliding window, most active voice) -------- |
|
if nbars>=4 and bar_sec>0: |
|
pitched_v=[v.name for v in song.voices.values() if v.wave!='noise'] |
|
worst=None |
|
for w0 in range(0, nbars-3): |
|
best_v=0 |
|
for nm in pitched_v: |
|
c=sum(onsets[nm][w0:w0+4]); best_v=max(best_v,c) |
|
evs=best_v/(4*bar_sec) |
|
if worst is None or evs<worst[1]: worst=(w0+1,evs) |
|
stats['density']={'min_window_evs':round(worst[1],2),'at_bar':worst[0]} |
|
if worst[1] < th['density_floor']: |
|
issues.append(Issue('warn','density',f"bars {worst[0]}-{worst[0]+3}", |
|
f"most-active voice only {worst[1]:.2f} ev/s " |
|
f"(floor {th['density_floor']} for @style {song.style}) — too sparse")) |
|
|
|
# ---- percussion presence -------------------------------------------- |
|
has_noise = any(v.wave=='noise' and any(onsets[v.name]) for v in song.voices.values()) |
|
stats['percussion_present']=has_noise |
|
if song.loop and nbars>8 and th['need_perc'] and not has_noise: |
|
issues.append(Issue('warn','percussion','', |
|
f"@loop piece with {nbars} bars has no noise/drum voice " |
|
f"(declare @style drone to suppress)")) |
|
|
|
# ---- static harmony -------------------------------------------------- |
|
chords = implied_chords(song) |
|
stats['implied_chords']=chords |
|
run=1; flagged=False |
|
for i in range(1,len(chords)): |
|
if chords[i]==chords[i-1] and chords[i]!='—': run+=1 |
|
else: |
|
if run>th['harm_static']: |
|
issues.append(Issue('warn','harmony',f"bars {i-run+1}-{i}", |
|
f"implied chord '{chords[i-1]}' static for {run} bars (>{th['harm_static']})")) |
|
flagged=True |
|
run=1 |
|
if run>th['harm_static'] and chords: |
|
issues.append(Issue('warn','harmony',f"bars {len(chords)-run+1}-{len(chords)}", |
|
f"implied chord '{chords[-1]}' static for {run} bars (>{th['harm_static']})")) |
|
|
|
# ---- melodic stagnation --------------------------------------------- |
|
for v in song.voices.values(): |
|
if roles.get(v.name)!='lead': continue |
|
flat=[n for n in flatten_voice(v) if n.midi is not None] |
|
i=0 |
|
while i<len(flat): |
|
j=i |
|
while j+1<len(flat) and flat[j+1].midi==flat[i].midi: j+=1 |
|
reps=j-i+1 |
|
if reps>3: |
|
durs={flat[k].dur for k in range(i,j+1)} |
|
if len(durs)==1: |
|
issues.append(Issue('warn','stagnation', |
|
f"{v.name} bar {flat[i].bar}", |
|
f"{midi_to_name(flat[i].midi)} repeated {reps}× with identical rhythm")) |
|
i=j+1 |
|
|
|
# ---- pedal bass ------------------------------------------------------ |
|
for v in song.voices.values(): |
|
if roles.get(v.name)!='bass': continue |
|
pcs_per_bar=[] |
|
for bar in v.bars: |
|
s={n.midi%12 for n in bar if n.midi is not None} |
|
pcs_per_bar.append(s) |
|
i=0 |
|
while i<len(pcs_per_bar): |
|
if len(pcs_per_bar[i])!=1: i+=1; continue |
|
pc=list(pcs_per_bar[i])[0]; j=i |
|
while j+1<len(pcs_per_bar) and pcs_per_bar[j+1]=={pc}: j+=1 |
|
if j-i+1>th['pedal_bars']: |
|
issues.append(Issue('warn','pedal-bass',f"{v.name} bars {i+1}-{j+1}", |
|
f"bass pedal on {PC_NAMES[pc]} for {j-i+1} bars (>{th['pedal_bars']})")) |
|
i=j+1 |
|
|
|
# ---- bass breathing (rest% + longest run without rest) -------------- |
|
for v in song.voices.values(): |
|
if roles.get(v.name)!='bass': continue |
|
seq=[n for bar in v.bars for n in bar] |
|
tot=sum((n.dur for n in seq), Fraction(0)) |
|
rst=sum((n.dur for n in seq if n.midi is None), Fraction(0)) |
|
rest_pct=100*float(rst)/float(tot) if tot else 0 |
|
run=Fraction(0); longest=Fraction(0) |
|
for n in seq: |
|
if n.midi is None: run=Fraction(0) |
|
else: run+=n.dur; longest=max(longest,run) |
|
run_bars=float(longest)/float(song.bar_len) |
|
stats.setdefault('bass',{}).update(rest_pct=rest_pct, longest_run_bars=run_bars) |
|
if run_bars>th['bass_run_bars'] and rest_pct<th['bass_rest_min']: |
|
issues.append(Issue('warn','bass-breathe',v.name, |
|
f"bass plays {run_bars:.1f} bars without a rest " |
|
f"(>{th['bass_run_bars']}) and rest%={rest_pct:.0f}% " |
|
f"(<{th['bass_rest_min']:.0f}%) — relentless, will dominate mix")) |
|
|
|
# ---- swing vs fine subdivision -------------------------------------- |
|
if abs(song.swing-0.5)>1e-6: |
|
fine=set() |
|
for v in song.voices.values(): |
|
for bar in v.bars: |
|
for n in bar: |
|
if n.dur < Fraction(1,8) and n.dur != Fraction(1,12): |
|
fine.add(n.dur) |
|
if fine: |
|
ds=', '.join(f"1/{int(1/float(d))}" for d in sorted(fine,reverse=True)) |
|
issues.append(Issue('warn','swing-grid','', |
|
f"@swing={song.swing} but piece uses sub-8th values ({ds}); " |
|
f"renderer warps ALL onsets — 16ths will be unevenly spaced. " |
|
f"If you want straight 16ths with syncopation, remove @swing.")) |
|
|
|
# ---- voice-volume headroom (cheap static proxy; meter_pfm for real) -- |
|
vsum=sum(v.vol for v in song.voices.values()) |
|
stats['vol_sum']=vsum |
|
if vsum>th['vol_sum_max']: |
|
vols=', '.join(f"{v.name}={v.vol:.2f}" for v in song.voices.values()) |
|
issues.append(Issue('warn','headroom','', |
|
f"Σ voice vol = {vsum:.2f} > {th['vol_sum_max']:.1f} " |
|
f"({vols}) — raw mix will likely exceed 0 dBFS; " |
|
f"run meter_pfm.py to confirm")) |
|
|
|
return issues, stats |
|
|
|
# ----------------------------------------------------------------- main --- |
|
def main(argv=None) -> int: |
|
ap = argparse.ArgumentParser(description="Validate a PFM file (v2)") |
|
ap.add_argument('file') |
|
ap.add_argument('--json', action='store_true') |
|
args = ap.parse_args(argv) |
|
with open(args.file) as f: |
|
song = parse_pfm(f.read()) |
|
issues, stats = validate(song) |
|
errs=[i for i in issues if i.severity=='error'] |
|
warns=[i for i in issues if i.severity=='warn'] |
|
infos=[i for i in issues if i.severity=='info'] |
|
if args.json: |
|
print(json.dumps({'file':args.file,'title':song.title, |
|
'ok':len(errs)==0 and len(warns)==0, |
|
'errors':[i.as_dict() for i in errs], |
|
'warnings':[i.as_dict() for i in warns], |
|
'info':[i.as_dict() for i in infos], |
|
'stats':stats},indent=2)) |
|
else: |
|
print(f"══ {args.file} «{song.title or 'untitled'}» " |
|
f"{song.timesig[0]}/{song.timesig[1]} @ {song.tempo}bpm " |
|
f"key={stats['tonality']['key']} style={song.style} loop={'yes' if song.loop else 'no'}") |
|
rl=stats["roles"] |
|
print(" voices: " + ", ".join(f"{n}({v.wave},{len(v.bars)}bars,{rl.get(n,'?')})" for n,v in song.voices.items())) |
|
print(f" tonality: {stats['tonality']['in_key_pct']}% in key" |
|
+ (f" ({len(stats['tonality']['out_of_key'])} out-of-key, " |
|
f"{sum(1 for o in stats['tonality']['out_of_key'] if o['passing'])} passing)" |
|
if stats['tonality']['out_of_key'] else "")) |
|
d=stats['dissonance'] |
|
if d['samples']: |
|
print(f" consonance: mean={d['mean']} min={d['min']} over {d['samples']} samples" |
|
+ (f" ⚠ {len(d['harsh_runs'])} harsh run(s)" if d['harsh_runs'] else "")) |
|
if 'density' in stats: |
|
print(f" density: min 4-bar window = {stats['density']['min_window_evs']} ev/s @ bar {stats['density']['at_bar']}") |
|
print(f" harmony: {' '.join(stats['implied_chords'][:16])}" |
|
+ (" …" if len(stats['implied_chords'])>16 else "")) |
|
for i in errs: print(f" ✘ [{i.check}] {i.where}: {i.msg}") |
|
for i in warns: print(f" ⚠ [{i.check}] {i.where}: {i.msg}") |
|
for i in infos: print(f" ℹ [{i.check}] {i.msg}") |
|
if not errs and not warns: print(" ✔ clean") |
|
return 0 if (not errs and not warns) else 1 |
|
|
|
if __name__ == '__main__': |
|
sys.exit(main()) |