Skip to content

Instantly share code, notes, and snippets.

@clusterfudge
Created April 9, 2026 17:41
Show Gist options
  • Select an option

  • Save clusterfudge/3f6f8b718fe44dad1c845486035a2cdb to your computer and use it in GitHub Desktop.

Select an option

Save clusterfudge/3f6f8b718fe44dad1c845486035a2cdb to your computer and use it in GitHub Desktop.
PFM Chiptune Composer — Claude Code skill + toolchain + reference docs for NES-style game audio composition

PFM Chiptune Composer

A Claude Code skill + pure-Python toolchain for composing, validating, rendering, and analyzing NES-2A03-style chiptune music using the PFM (Programmable Fun Music) plain-text score format.

What's Here

Skill

  • SKILL.md — Claude Code skill definition (drop into .claude/skills/pfm-chiptune-composer/)

Toolchain (pure Python stdlib, no dependencies)

  • validate_pfm.py — Parser + validator: rhythm, tonality, dissonance, range, loop seam, musicality warnings
  • render_pfm.py — Synthesizer: pulse/tri/saw/LFSR oscillators, drums, arps, vibrato, bends, swing → 16-bit WAV
  • meter_pfm.py — Headroom analyzer: per-voice energy, saturation %, raw peak detection
  • analyze_pfm.py — Musical metrics: density, syncopation, contour, phrase structure, corpus comparison
  • rebuild.sh — Full pipeline rebuild script

Reference Documentation

  • format-spec.md — Complete PFM v1 + v2 format specification
  • philosophy.md — Audio design philosophy: why chiptune, puzzle-game audio research, SFX pitch ladder
  • techniques.md — 13 composition techniques with corpus citations and paste-ready PFM templates
  • corpus-report.md — Analysis of 8 NES/GB reference transcriptions with 12 distilled lessons
  • disney-resolution-study.md — Harmonic resolution techniques (Disney/Pixar) adapted for chiptune cadences

Sample Compositions

  • sample-ambient.pfm — 16-bar puzzle-calm loop (104 BPM, A pentatonic minor, 4 voices)
  • sample-complete.pfm — Victory sting (iv→V→I Picardy cadence)
  • sample-fail.pfm — Fail sting (m2 + tritone descent)
  • sample-sfx.pfm — SFX catalogue (tap / connect / undo / star / unlock)

Quick Start

# Validate a composition
python3 validate_pfm.py song.pfm

# Render to WAV
python3 render_pfm.py song.pfm -o song.wav

# Check headroom / mix levels
python3 meter_pfm.py song.pfm

# Analyze musical metrics
python3 analyze_pfm.py song.pfm

# Compare against corpus envelope
python3 analyze_pfm.py --corpus corpus/ --compare song.pfm
#!/usr/bin/env python3
"""
analyze_pfm.py — Deliverable C: musical metrics + corpus comparison for PFM.
Pure stdlib. Reuses parser/harmony/roles from validate_pfm.py (same dir).
MODE 1: python3 analyze_pfm.py file.pfm [--json]
→ per-piece metrics (density, syncopation, contour, harmony, drums,
phrase structure, voice motion …)
MODE 2: python3 analyze_pfm.py --corpus DIR/ --compare file.pfm
→ analyze every .pfm in DIR, compute min/median/max for each scalar,
compare file.pfm against that envelope, flag outliers.
Exit 0 always (advisory, not validation).
"""
from __future__ import annotations
import sys, os, json, argparse, statistics
from fractions import Fraction
from typing import List, Dict, Optional, Tuple
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) or '.')
from validate_pfm import (parse_pfm, Song, Voice, Note, flatten_voice,
infer_roles, implied_chords, midi_to_name)
# ═════════════════════════════════════════════════════════════ helpers ═══
def nbars(song: Song) -> int:
return max((len(v.bars) for v in song.voices.values()), default=0)
def bar_seconds(song: Song) -> float:
# whole-note = 4 beats; bar_len is in whole-note units; quarter = 60/tempo
return float(song.bar_len) * 4.0 * (60.0 / song.tempo)
def onsets(v: Voice) -> List[Note]:
"""All sounding onsets (no rests, no tie-continuations)."""
return [n for bar in v.bars for n in bar
if n.midi is not None and not n.tie_from_prev]
def pitched_onsets(v: Voice) -> List[Note]:
return [n for n in onsets(v) if n.drum is None]
def smart_roles(song: Song) -> Dict[str,str]:
"""Wrap validate_pfm.infer_roles with name-based hints first."""
roles: Dict[str,str] = {}
for v in song.voices.values():
if v.role: roles[v.name] = v.role; continue
low = v.name.lower()
if v.wave == 'noise' or 'drum' in low or 'perc' in low:
roles[v.name] = 'drums'
elif 'lead' in low or 'mel' in low:
roles[v.name] = 'lead'
elif 'bass' in low:
roles[v.name] = 'bass'
elif 'harm' in low or 'pad' in low or 'chord' in low:
roles[v.name] = 'harmony'
elif 'echo' in low or v.follows:
roles[v.name] = 'echo'
# fill remaining via validator heuristic
base = infer_roles(song)
for nm, r in base.items():
roles.setdefault(nm, r)
# ensure at most one lead: keep the one with most onsets
leads = [nm for nm,r in roles.items() if r=='lead']
if len(leads) > 1:
leads.sort(key=lambda nm: -len(onsets(song.voices[nm])))
for nm in leads[1:]: roles[nm] = 'harmony'
if 'lead' not in roles.values():
# promote busiest pitched non-bass voice
cand = [(len(onsets(v)), nm) for nm,v in song.voices.items()
if roles.get(nm) not in ('drums','bass') and v.wave!='noise']
if cand:
cand.sort(reverse=True); roles[cand[0][1]]='lead'
return roles
def find_role(roles: Dict[str,str], song: Song, want: str) -> Optional[Voice]:
for nm, r in roles.items():
if r == want and nm in song.voices:
return song.voices[nm]
return None
# ═══════════════════════════════════════════════════════════════ MODE 1 ═══
def analyze(song: Song) -> Dict:
roles = smart_roles(song)
nb = nbars(song)
bsec = bar_seconds(song)
total_sec = nb * bsec
beat_len = song.beat_len
n_beats = song.timesig[0]
out: Dict = {
'title': song.title, 'tempo': song.tempo,
'timesig': f"{song.timesig[0]}/{song.timesig[1]}",
'key': None, 'style': song.style, 'loop': song.loop,
'bars': nb, 'seconds': round(total_sec, 2),
'roles': roles,
}
# ── note_density ────────────────────────────────────────────────────
per_voice = {}
total_ons = 0
for v in song.voices.values():
c = len(onsets(v))
total_ons += c
per_voice[v.name] = {
'events': c,
'ev_per_bar': round(c / nb, 2) if nb else 0.0,
'ev_per_sec': round(c / total_sec, 3) if total_sec else 0.0,
}
out['note_density'] = {
'overall_ev_per_sec': round(total_ons / total_sec, 3) if total_sec else 0.0,
'overall_ev_per_bar': round(total_ons / nb, 2) if nb else 0.0,
'per_voice': per_voice,
}
# ── duration_histogram ──────────────────────────────────────────────
NAMED = {Fraction(1,1):'1', Fraction(1,2):'2', Fraction(1,4):'4',
Fraction(1,8):'8', Fraction(1,16):'16', Fraction(1,32):'32',
Fraction(3,8):'4.', Fraction(3,16):'8.', Fraction(3,4):'2.',
Fraction(3,32):'16.', Fraction(1,12):'8t', Fraction(1,6):'4t',
Fraction(1,24):'16t'}
hist: Dict[str,int] = {}
for v in song.voices.values():
for n in onsets(v):
key = NAMED.get(n.dur, str(n.dur))
hist[key] = hist.get(key, 0) + 1
out['duration_histogram'] = dict(sorted(hist.items(),
key=lambda kv: -kv[1]))
# ── syncopation_index ───────────────────────────────────────────────
sync_on = sync_tot = 0
EPS = 1e-6
for v in song.voices.values():
if roles.get(v.name) == 'drums' or v.wave == 'noise':
continue
for n in onsets(v):
sync_tot += 1
frac = (n.beat - 1.0) % 1.0
if frac > EPS and frac < 1 - EPS:
sync_on += 1
out['syncopation_index'] = round(sync_on / sync_tot, 3) if sync_tot else 0.0
# ── swing_ratio ─────────────────────────────────────────────────────
if abs(song.swing - 0.5) > 1e-6:
out['swing_ratio'] = {'declared': song.swing, 'detected': song.swing,
'n_pairs': 0}
else:
# detect from consecutive 8th-note pairs on beat boundaries
eighth = Fraction(1, 8)
ratios = []
for v in song.voices.values():
if v.wave == 'noise': continue
flat = flatten_voice(v)
for a, b in zip(flat, flat[1:]):
if a.midi is None or b.midi is None: continue
tot = a.dur + b.dur
# pair spans exactly one beat, first note on a beat
if tot == 2*eighth*Fraction(song.timesig[1],4)*0 + Fraction(1,4): pass
if tot == Fraction(1,4) and abs((a.beat-1.0)%1.0) < EPS:
ratios.append(float(a.dur / tot))
det = round(statistics.median(ratios), 3) if ratios else 0.5
out['swing_ratio'] = {'declared': song.swing, 'detected': det,
'n_pairs': len(ratios)}
# ── melodic_contour (lead) ──────────────────────────────────────────
lead = find_role(roles, song, 'lead')
if lead:
ps = [n for n in flatten_voice(lead)
if n.midi is not None and not n.tie_from_prev and n.drum is None]
ups = downs = reps = 0
leaps = []
for a, b in zip(ps, ps[1:]):
d = b.midi - a.midi
if d > 0: ups += 1
elif d < 0: downs += 1
else: reps += 1
if d != 0: leaps.append(abs(d))
n_iv = max(1, len(ps) - 1)
midis = [n.midi for n in ps]
# phrase shapes per 4-bar window
shapes = []
for w0 in range(0, nb, 4):
seg = [n.midi for n in ps if w0 < n.bar <= w0 + 4]
shapes.append(_contour_shape(seg))
out['melodic_contour'] = {
'voice': lead.name,
'pct_up': round(ups / n_iv, 3),
'pct_down': round(downs / n_iv, 3),
'pct_repeat': round(reps / n_iv, 3),
'mean_leap_semitones': round(statistics.mean(leaps), 2) if leaps else 0.0,
'range_span': (max(midis) - min(midis)) if midis else 0,
'range': f"{midi_to_name(min(midis))}-{midi_to_name(max(midis))}" if midis else '',
'phrase_shapes': shapes,
}
else:
out['melodic_contour'] = None
# ── harmonic_rhythm ─────────────────────────────────────────────────
chords = implied_chords(song)
changes = sum(1 for a, b in zip(chords, chords[1:])
if a != b and a != '—' and b != '—')
per8 = round(changes / nb * 8, 2) if nb else 0.0
out['harmonic_rhythm'] = {
'chords_per_bar': chords,
'n_distinct': len({c for c in chords if c != '—'}),
'changes_per_8bars': per8,
}
# ── drum_grid ───────────────────────────────────────────────────────
drumv = find_role(roles, song, 'drums')
out['has_drums'] = drumv is not None and len(onsets(drumv)) > 0
if out['has_drums']:
step = song.bar_len / 16
grids = []
backbeat_bars = 0
for bi, bar in enumerate(drumv.bars, 1):
g = ['.'] * 16
pos = Fraction(0)
snare_beats = set()
for n in bar:
if n.midi is not None and not n.tie_from_prev:
idx = int(pos / step)
if 0 <= idx < 16:
sym = n.drum or 'x'
# don't overwrite K/S with H
if g[idx] in ('.','H','O') or sym in ('K','S','C'):
g[idx] = sym
if n.drum == 'S':
b = 1 + float(pos / beat_len)
if abs(b - round(b)) < 1e-6:
snare_beats.add(int(round(b)))
pos += n.dur
grids.append(''.join(g))
if n_beats == 4 and {2, 4} <= snare_beats:
backbeat_bars += 1
elif n_beats != 4 and 2 in snare_beats:
backbeat_bars += 1
out['drum_grid'] = {
'voice': drumv.name,
'patterns': grids,
'distinct_patterns': len(set(grids)),
'backbeat_present': backbeat_bars >= max(1, len(grids) // 2),
'backbeat_bars': f"{backbeat_bars}/{len(grids)}",
}
else:
out['drum_grid'] = None
# ── phrase_structure (lead bar-similarity → AABA…) ──────────────────
if lead and nb:
sigs = []
for bi in range(nb):
bar = lead.bars[bi] if bi < len(lead.bars) else []
seq = tuple((n.midi, n.dur) for n in bar
if n.midi is not None and not n.tie_from_prev)
sigs.append(seq)
# similarity matrix (normalized LCS on pitch-contour+dur)
sim = [[_bar_similarity(sigs[i], sigs[j]) for j in range(nb)]
for i in range(nb)]
# greedy label per bar
labels = [''] * nb
next_l = 0
LET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
for i in range(nb):
if labels[i]: continue
lab = LET[next_l % 26]; next_l += 1
labels[i] = lab
for j in range(i + 1, nb):
if not labels[j] and sim[i][j] >= 0.75:
labels[j] = lab
bar_form = ''.join(labels)
# collapse to 4-bar phrases
phrase_form = _collapse_phrases(labels, 4)
out['phrase_structure'] = {
'bar_labels': bar_form,
'form': phrase_form,
'similarity_matrix': [[round(x, 2) for x in row] for row in sim],
}
else:
out['phrase_structure'] = None
# ── voice_motion (lead vs harmony, beat-grid) ───────────────────────
harm = find_role(roles, song, 'harmony') or find_role(roles, song, 'bass')
if lead and harm and harm is not lead:
tl_l = _timeline(lead, song)
tl_h = _timeline(harm, song)
par = con = obl = 0
prev = None
t = Fraction(0)
end = nb * song.bar_len
while t < end:
a = _sounding(tl_l, t)
b = _sounding(tl_h, t)
if a is not None and b is not None:
if prev is not None:
pa, pb = prev
da, db = a - pa, b - pb
if da == 0 and db == 0:
pass # static, skip
elif da == 0 or db == 0:
obl += 1
elif (da > 0) == (db > 0):
par += 1
else:
con += 1
prev = (a, b)
else:
prev = None
t += beat_len
tot = max(1, par + con + obl)
out['voice_motion'] = {
'vs': f"{lead.name}↔{harm.name}",
'pct_parallel': round(par / tot, 3),
'pct_contrary': round(con / tot, 3),
'pct_oblique': round(obl / tot, 3),
'n_samples': par + con + obl,
}
else:
out['voice_motion'] = None
return out
# ── contour / similarity helpers ────────────────────────────────────────
def _contour_shape(seg: List[int]) -> str:
if len(seg) < 3: return 'flat'
n = len(seg)
first, last = seg[0], seg[-1]
peak_i = max(range(n), key=lambda i: seg[i])
trough_i = min(range(n), key=lambda i: seg[i])
span = max(seg) - min(seg)
if span <= 2: return 'flat'
mid_lo, mid_hi = n * 0.25, n * 0.75
if mid_lo <= peak_i <= mid_hi and seg[peak_i] - first >= 2 and seg[peak_i] - last >= 2:
return 'arch'
if mid_lo <= trough_i <= mid_hi and first - seg[trough_i] >= 2 and last - seg[trough_i] >= 2:
return 'valley'
if last - first >= span * 0.5: return 'ramp-up'
if first - last >= span * 0.5: return 'ramp-down'
return 'wave'
def _bar_similarity(a: tuple, b: tuple) -> float:
if not a and not b: return 1.0
if not a or not b: return 0.0
# LCS on (pitch-class, dur) pairs
A = [(m % 12, d) for (m, d) in a]
B = [(m % 12, d) for (m, d) in b]
la, lb = len(A), len(B)
dp = [[0]*(lb+1) for _ in range(la+1)]
for i in range(la):
for j in range(lb):
dp[i+1][j+1] = dp[i][j]+1 if A[i]==B[j] else max(dp[i][j+1], dp[i+1][j])
return dp[la][lb] / max(la, lb)
def _collapse_phrases(labels: List[str], width: int) -> str:
if not labels: return ''
phrases = [tuple(labels[i:i+width]) for i in range(0, len(labels), width)]
LET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
plabs = []
seen: Dict[tuple,str] = {}
for p in phrases:
# match if ≥ half the bar-labels agree with a prior phrase
best = None
for q, lab in seen.items():
if len(q) == len(p):
agree = sum(1 for x,y in zip(p,q) if x==y)
if agree >= (len(p)+1)//2:
best = lab; break
if best is None:
best = LET[len(seen) % 26]
seen[p] = best
plabs.append(best)
return ''.join(plabs)
def _timeline(v: Voice, song: Song) -> List[Tuple[Fraction,Fraction,int]]:
t = Fraction(0); out = []
for n in flatten_voice(v):
if n.midi is not None and n.drum is None:
out.append((t, t + n.dur, n.midi))
t += n.dur
return out
def _sounding(tl, t: Fraction) -> Optional[int]:
for s, e, m in tl:
if s <= t < e: return m
return None
# ═════════════════════════════════════════════════════════════ scalars ═══
# Scalar metrics extracted for corpus comparison. Each is (label, getter,
# higher_is_more, fmt). getter returns float or None.
SCALARS = [
('density (ev/s)', lambda m: m['note_density']['overall_ev_per_sec'], '{:.2f}'),
('density (ev/bar)', lambda m: m['note_density']['overall_ev_per_bar'], '{:.1f}'),
('syncopation', lambda m: m['syncopation_index'], '{:.2f}'),
('swing ratio', lambda m: m['swing_ratio']['detected'], '{:.2f}'),
('harm changes/8b', lambda m: m['harmonic_rhythm']['changes_per_8bars'],'{:.1f}'),
('harm distinct', lambda m: m['harmonic_rhythm']['n_distinct'], '{:.0f}'),
('lead range (st)', lambda m: (m['melodic_contour'] or {}).get('range_span'), '{:.0f}'),
('lead mean leap', lambda m: (m['melodic_contour'] or {}).get('mean_leap_semitones'), '{:.2f}'),
('lead %repeat', lambda m: (m['melodic_contour'] or {}).get('pct_repeat'), '{:.2f}'),
('motion %contrary', lambda m: (m['voice_motion'] or {}).get('pct_contrary'), '{:.2f}'),
('motion %parallel', lambda m: (m['voice_motion'] or {}).get('pct_parallel'), '{:.2f}'),
('bars', lambda m: m['bars'], '{:.0f}'),
]
BOOLS = [
('has_drums', lambda m: m['has_drums']),
('backbeat', lambda m: (m['drum_grid'] or {}).get('backbeat_present', False)),
('loop', lambda m: m['loop']),
]
# ═════════════════════════════════════════════════════════════ printing ═══
def print_human(m: Dict):
p = print
p(f"══ {m['title'] or 'untitled'} — {m['timesig']} @ {m['tempo']}bpm "
f"style={m['style']} {m['bars']} bars ({m['seconds']}s) loop={'yes' if m['loop'] else 'no'}")
roles = m['roles']
p(" voices: " + ", ".join(f"{n}[{r}]" for n, r in roles.items()))
p("")
# density
nd = m['note_density']
p(f"┌ note_density overall = {nd['overall_ev_per_sec']:.2f} ev/s "
f"({nd['overall_ev_per_bar']:.1f} ev/bar)")
for nm, d in nd['per_voice'].items():
p(f"│ {nm:<12} {d['ev_per_sec']:>6.2f} ev/s {d['ev_per_bar']:>5.1f} ev/bar "
f"({d['events']} events)")
# durations
dh = m['duration_histogram']
p(f"├ duration_histogram " +
" ".join(f"{k}:{v}" for k, v in list(dh.items())[:8]) +
(" …" if len(dh) > 8 else ""))
# syncopation / swing
sw = m['swing_ratio']
p(f"├ syncopation_index {m['syncopation_index']:.3f} "
f"(non-drum onsets off the beat)")
p(f"├ swing_ratio declared={sw['declared']} detected={sw['detected']} "
f"(from {sw['n_pairs']} 8th-pairs)")
# contour
mc = m['melodic_contour']
if mc:
p(f"├ melodic_contour [{mc['voice']}] "
f"up={mc['pct_up']:.0%} down={mc['pct_down']:.0%} rep={mc['pct_repeat']:.0%} "
f"mean_leap={mc['mean_leap_semitones']}st "
f"range={mc['range']} ({mc['range_span']}st)")
p(f"│ phrase shapes: {' '.join(mc['phrase_shapes'])}")
# harmony
hr = m['harmonic_rhythm']
ch = hr['chords_per_bar']
p(f"├ harmonic_rhythm {hr['changes_per_8bars']} changes/8bars "
f"({hr['n_distinct']} distinct chords)")
p(f"│ per-bar: {' '.join(ch[:16])}" + (" …" if len(ch)>16 else ""))
# drums
dg = m['drum_grid']
if dg:
p(f"├ drum_grid [{dg['voice']}] "
f"backbeat={'yes' if dg['backbeat_present'] else 'no'} "
f"({dg['backbeat_bars']} bars) "
f"{dg['distinct_patterns']} distinct pattern(s)")
for i, g in enumerate(dg['patterns'][:4], 1):
p(f"│ bar {i:<2} {g}")
if len(dg['patterns']) > 4: p(f"│ … ({len(dg['patterns'])-4} more)")
else:
p(f"├ drum_grid (no drums voice)")
# phrase structure
ps = m['phrase_structure']
if ps:
p(f"├ phrase_structure bars: {ps['bar_labels']}")
p(f"│ form (4-bar): {ps['form']}")
# voice motion
vm = m['voice_motion']
if vm:
p(f"└ voice_motion {vm['vs']} "
f"parallel={vm['pct_parallel']:.0%} contrary={vm['pct_contrary']:.0%} "
f"oblique={vm['pct_oblique']:.0%} (n={vm['n_samples']})")
else:
p(f"└ voice_motion (need lead + harmony/bass)")
# ═════════════════════════════════════════════════════════════ MODE 2 ═══
def corpus_compare(corpus_dir: str, target_file: str):
files = sorted(os.path.join(corpus_dir, f)
for f in os.listdir(corpus_dir) if f.endswith('.pfm'))
if not files:
print(f"no .pfm files in {corpus_dir}"); return
corpus = []
for f in files:
try:
with open(f) as fh:
corpus.append((os.path.basename(f), analyze(parse_pfm(fh.read()))))
except Exception as e:
print(f" (skip {f}: {e})", file=sys.stderr)
with open(target_file) as fh:
tgt = analyze(parse_pfm(fh.read()))
print(f"══ CORPUS COMPARISON")
print(f" corpus: {corpus_dir} ({len(corpus)} piece(s): "
f"{', '.join(n for n,_ in corpus)})")
print(f" target: {target_file} «{tgt['title'] or 'untitled'}»")
print("")
W = (20, 9, 24, 10)
hdr = f"{'metric':<{W[0]}} | {'song':>{W[1]}} | {'corpus min-med-max':<{W[2]}} | verdict"
print(hdr)
print('-' * len(hdr))
def mmm(vals):
vals = [v for v in vals if v is not None]
if not vals: return None
return (min(vals), statistics.median(vals), max(vals))
for label, getter, fmt in SCALARS:
try: sv = getter(tgt)
except Exception: sv = None
cvals = []
for _, cm in corpus:
try: cvals.append(getter(cm))
except Exception: cvals.append(None)
stat = mmm(cvals)
if sv is None:
sstr = 'n/a'
else:
sstr = fmt.format(sv)
if stat is None:
cstr, verdict = 'n/a', ''
else:
lo, med, hi = stat
cstr = f"{fmt.format(lo)} - {fmt.format(med)} - {fmt.format(hi)}"
if sv is None:
verdict = '⚠ MISSING'
elif sv < lo:
verdict = '⚠ BELOW'
elif sv > hi:
verdict = '⚠ ABOVE'
else:
verdict = '✓ ok'
print(f"{label:<{W[0]}} | {sstr:>{W[1]}} | {cstr:<{W[2]}} | {verdict}")
for label, getter in BOOLS:
sv = bool(getter(tgt))
cvs = [bool(getter(cm)) for _, cm in corpus]
yes = sum(1 for x in cvs if x)
cstr = f"{'yes' if yes==len(cvs) else 'no' if yes==0 else 'mixed'} ({yes}/{len(cvs)})"
if yes == len(cvs) and not sv:
verdict = '⚠ MISSING'
elif yes == 0 and sv:
verdict = '⚠ EXTRA'
else:
verdict = '✓ ok'
print(f"{label:<{W[0]}} | {('yes' if sv else 'no'):>{W[1]}} | "
f"{cstr:<{W[2]}} | {verdict}")
# ═══════════════════════════════════════════════════════════════ main ═══
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Analyze PFM musical metrics")
ap.add_argument('file', nargs='?')
ap.add_argument('--json', action='store_true')
ap.add_argument('--corpus', metavar='DIR')
ap.add_argument('--compare', metavar='FILE')
args = ap.parse_args(argv)
if args.corpus and args.compare:
corpus_compare(args.corpus, args.compare)
return 0
if not args.file:
ap.error("need FILE (mode 1) or --corpus DIR --compare FILE (mode 2)")
with open(args.file) as f:
song = parse_pfm(f.read())
m = analyze(song)
if args.json:
print(json.dumps(m, indent=2, default=str))
else:
print_human(m)
return 0
if __name__ == '__main__':
sys.exit(main())

Corpus Report — 8 NES/GB Reference Transcriptions

Generated by analyze_pfm.py on all 8 corpus/*.pfm study transcriptions. These pieces define the envelope against which new compositions are compared (analyze_pfm.py --corpus corpus/ --compare X.pfm).

Metrics Table

piece bpm density (ev/s) sync harm-chg/8b backbeat signature move
tetris-a (Tanaka '89) 150 15.39 0.32 7.0 yes Relentless octave-pump 8th bass under a folk melody
smb-overworld (Kondo '85) 150 17.19 0.63 7.2 yes Pushed-16th calypso syncopation + oom-pah root-fifth bass
smb-underground (Kondo '85) 100 4.58 0.52 1.3 no Six stabs then 2.5 beats of silence — rest IS the groove
zelda-overworld (Kondo '86) 130 13.47 0.36 7.0 yes Dotted-8th+16th gallop + triplet brass fanfares; triangle counter-melody
metroid-brinstar (Tanaka '86) 130 10.83 0.33 4.7 no Arp-macro 8th ostinato + triangle-as-lead = ambient but dense
megaman2-wily1 (Tateishi '88) 170 33.29 0.74 7.2 yes Dotted-8th echo voice (3/16 delay) + unbroken 16th-note bass engine
kirby-greengreens (Ishikawa '92) 140 12.91 0.25 6.0 yes Call-and-response between pulse1 and pulse2 over walking bass
drmario-fever (Tanaka '90) 150 17.03 0.51 5.0 yes Chromatic approach-tone funk bass + swung ghost-snare go-go

Envelope: density 4.58–33.29 ev/s (median 14.4) · sync 0.25–0.74 · harm-chg/8b 1.3–7.2 · 8/8 have drums · 6/8 have backbeat.


Per-Piece: Defining Technique

tetris-a — Octave-pump bass

The triangle never stops: eight unbroken root-octave 8ths per bar (E2:8 E3:8 E2:8 E3:8 …). That pump is the pulse — the lead is free to hold dotted quarters because the bass supplies all forward motion. Harmonic rhythm is fast (7 changes/8b) but every change is nailed by a new bass root on beat 1.

smb-overworld — Pushed syncopation + oom-pah

Kondo's genius is attacking notes a 16th before the expected beat (bar 1: E5:16 E5:16 R:16 E5:16 R:16 C5:16 E5:16 R:16 G5:4). Combined with @swing 0.67 this reads as calypso. Underneath, the triangle plays oom-pah — root on the beat, fifth on the off-8th with rests in between — so the syncopated lead has a rock-solid grid to push against.

smb-underground — Silence as rhythm

The lowest density in the corpus (4.58 ev/s) yet the most rhythmically intense. Six staccato octave stabs (ADSR sustain=0) then a full empty bar. No hats, no pad — the listener's brain fills the vacuum. Bass and drums do one hit on beat 1 then vanish. Proof that rests are events.

zelda-overworld — Gallop + triplet fanfare + triangle counter-melody

The dotted-8th+16th cell (X:8. X:16) gives every phrase a forward lean; triplet bursts (:8t :8t :8t) punctuate cadences like brass. Crucially the triangle is not a root-note bass — it's a singable stepwise counter-melody in contrary motion to the lead, doubling as bass only on downbeats.

metroid-brinstar — Arp-ostinato ambience

"Ambient ≠ empty." A thin-duty pulse cycles [E4 B4 E5]:8 eight times a bar — 96 events in 12 bars from one channel. The triangle (role-inverted to lead) sings long vibrato notes on top. Drums are K+H only, no snare: heartbeat, not backbeat. Density stays at 10.8 ev/s while the dynamics stay soft.

megaman2-wily1 — Dotted-8th echo + 16th-note engine

Highest density (33.3 ev/s) and highest syncopation (0.74) in the corpus. The echo voice (follows=lead delay=3/16) turns one melodic line into a rolling two-voice canon — the "Capcom shimmer." Bass is unbroken 16ths, walking on chord changes; drums are 16th hats with K on 1&3+& — pure engine.

kirby-greengreens — Call-and-response

Pulse1 plays a bouncy dotted motif in bar 1, then rests in bar 2 while pulse2 answers. The two voices trade space rather than stacking — you get melodic richness with zero clutter. Walking quarter-note bass keeps it strolling.

drmario-fever — Chromatic approach-tone bass

Every bass target is approached by a half-step from below: G#2→A2, C#3→D3, D#3→E3. Under @swing 0.62 with ghost-snare 16ths (!0.3 volume), this is straight-up funk on a 2A03. Lead lands almost exclusively on off-8ths (R:8 A5:8 …) — 51% syncopation index.


10 Lessons for Game Audio

  1. Ambient ≠ empty. Brinstar hits 10.8 ev/s while feeling calm — density comes from an arp ostinato and hat pulse, not from a busy lead. Our old ambient (1.48 ev/s) was actually empty.
  2. Bass must move. Every corpus piece has per-bar bass motion (octave-pump, oom-pah, walking, 16th-pedal, chromatic). Whole-note pedals read as placeholder.
  3. Drums are non-negotiable. 8/8 corpus pieces have a noise voice. Even "drone" Underground uses a kick on 1. For puzzle-calm, use K+H only (no snare) — Brinstar's heartbeat groove.
  4. Syncopation floor is ~0.25. Even Kirby (the squarest piece) puts a quarter of its onsets off the beat. Zero syncopation sounds mechanical.
  5. Push, don't drag. Anticipate the next downbeat by an 8th/16th and tie over (Kondo, Tanaka). It creates forward lean without speeding up.
  6. One channel can imply a triad. Arp macros ([C4 E4 G4]:8) or 3/16 echo voices turn a single pulse into full harmony — save the other pulse for melody.
  7. Let the triangle sing. Zelda and Metroid put real melody on the triangle channel; it doesn't have to be root-note bass.
  8. Harmony should move at 4–7 changes per 8 bars. Static harmony >4 bars (standard style) reads as a loop glitch, not a vibe.
  9. Rests are events. Underground proves silence can carry groove; Kirby proves rests in the lead make room for a harmony answer. Don't fill every 8th in every voice.
  10. Vary bar 8 and bar 16. Every corpus loop marks phrase boundaries with a drum fill, open-hat, or crash — it tells the ear "here's the seam."

Addendum — lessons 11 & 12 (post-listen feedback)

11. Headroom is a compositional constraint, not a mastering step

The v1 renderer hid bad mixing behind tanh(0.6·Σ) + normalize-to-−1dB. Every track peaked at exactly −1.00 dBFS regardless of content, and 19% of ambient samples were in the tanh nonlinear knee → audible "crunch". Fix: renderer now sums linearly, normalizes to @master headroom (default −3 dB), and warns when raw peak >1.0. meter_pfm.py reports raw peak / saturation% / per-voice energy share so the composer fixes levels at source. Target: no single voice >55% energy, raw peak ≤1.6.

12. Bass must breathe, not just move

v2.0 bass "moved" (97% pitch-change between consecutive notes) but it was the same 3-note oom-pah cell for 16 bars with 0% rest. At vol=0.85 + 0.06s release, every note boundary overlap-summed → bass alone = 62% of mix energy. Survey of corpus bass lines:

piece rest% longest run notes
smb-overworld 44% 0.62 bars the model: anchor + gap + walk
smb-underground 92% 0.12 bars almost all space
others 0% 8–12 bars but pieces are only 8–12 bars total
ambient v2.0 0% 16 bars relentless
ambient v2.1 36% 0.5 bars root-anchor · rest · approach

Validator now emits ⚠ [bass-breathe] when bass rest% < threshold AND longest-run > N bars (style-tuned). ⚠ [headroom] when Σ voice-vol exceeds style budget.

How Disney & Pixar Songs Achieve Resolution

A Harmonic Study for Chiptune Composers

"I hope your life resolves like a Disney song." — What makes a Disney resolution feel earned, inevitable, and emotionally satisfying?


1. The "I Want" Song Architecture

The Menken/Ashman template (codified 1989–1992, still the backbone of every Disney musical) follows a verse → pre-chorus → chorus arc that maps directly onto emotional wanting → reaching → arrival.

The Harmonic Engine

Section Function Typical Harmony Feeling
Verse Establish character's world I – V/I – IV – I diatonic, often with a pedal tone Contained, conversational
Pre-chorus Tension / yearning ii → ii/♯4 → V or IV → V/V → V — rising bass line "Reaching upward"
Chorus Emotional release I → V → vi → IV (or IV → V → I) — big open voicings Arrival, declaration

Concrete examples:

  • "Part of Your World" (Menken/Ashman, The Little Mermaid, 1989) — Key of A♭. The verse sits on A♭ pedal with gentle I–IV–V motion. The pre-chorus ("Betcha on land...") introduces a chromatic bass walk A♭→A→B♭→B♮ (I→V⁶₅/ii→ii→V/V), creating the reaching sensation. The chorus ("Up where they walk...") lands on a bright IV→V→I with the melody arriving on 1̂ (A♭). The final reprise at ~3:10 strips to solo voice + harp on a bare I chord — resolution via texture thinning.

  • "Belle" (Beauty and the Beast, 1991) — Key of C (opening). The "I want" section modulates to D (truck-driver +2 semitones) at "I want adventure in the great wide somewhere" (~2:50). The pre-chorus builds on ii→V→V/vi→vi (Em→A→F♯→Bm in D), then resolves with a sweeping IV→V→I (G→A→D), melody landing 2̂→1̂.

  • "How Far I'll Go" (Lin-Manuel Miranda, Moana, 2016) — Key of E. Pre-chorus uses the progression I→iii→IV→iv (E→G♯m→A→Am), where the borrowed iv (Am, with ♭6̂) creates a poignant "not-yet" tension. Chorus resolves with IV→V→I→V/vi→vi→IV→V→I, the deceptive V→vi buying one extra phrase of yearning before the authentic payoff.

  • "Let It Go" (Lopez/Anderson-Lopez, Frozen, 2013) — Key of A♭ minor → A♭ major. Verse is firmly in A♭m (i–♭VI–♭III–♭VII = A♭m–F♭–C♭–G♭). The chorus pivots to A♭ major — a structural Picardy third — the entire chorus is the resolution of minor to major. This is not a tag; it's the song's thesis: letting go = resolving to major.

The "Reaching" Chords (for chiptune shorthand)

The feeling of yearning before arrival almost always comes from one of:

  • ii → V (supertonic prep) — the workhorse
  • IV → V with melody on 6̂→7̂→1̂ — the Menken signature
  • ♭VI → ♭VII (Aeolian) — more modern / pop-Disney
  • iv (borrowed from parallel minor) — the Miranda / Lopez gut-punch

2. Cadence Vocabulary

IV → I (Plagal / "Amen")

Used for warmth, completion, benediction. Disney deploys this at the very end of a number — the last bar, after the authentic cadence has already landed. It says "and all is well."

  • "A Whole New World" final chord: the orchestra sustains I (D) while the inner voices move IV→I. Melody rests on 1̂.
  • "You've Got a Friend in Me" (Randy Newman, Toy Story, 1995) — the outro vamp is a lazy IV→I in E♭, matching Newman's "porch-swing" aesthetic.

V → I (Authentic)

The backbone. Used for definitive statement, triumph, certainty. Menken almost always uses a V⁷→I for the big chorus landing.

  • "Go the Distance" (Hercules, 1997) — the final chorus cadence at ~2:55 is a textbook B→E (V→I in E major), melody 7̂→1̂ (D♯→E), full orchestra doubling the resolution. Zero ambiguity.

♭VII → I (Mixolydian / "Mario Cadence")

Enormous in post-2010 Disney. The ♭VII is borrowed from Mixolydian or Aeolian; it gives a folk / anthemic / "earned-but-not-classical" resolution. Less formal than V→I, more modern.

  • "How Far I'll Go" — the final vocal phrase cadences ♭VII→I (D→E).
  • "Into the Unknown" (Frozen II, 2019) — ♭VI→♭VII→I is the primary cadential motion (D♭→E♭→F in F minor→major). This is the exact same cadence as the Super Mario Bros. flagpole fanfare (♭VI→♭VII→I in C = A♭→B♭→C). Chiptune composers: you already know this one.

♭VI → ♭VII → I (the Double-Flat Ascent)

A two-chord runway into I. Feels like an approach from below, heroic, slightly modal.

  • "Let It Go" chorus ("Here I stand..."): the final phrase uses F♭→G♭→A♭ (♭VI→♭VII→I in A♭ major).
  • "Remember Me" (Coco, 2017, lullaby version) — the gentle ♭VI→♭VII→I at the final cadence gives it a folk-song inevitability.

V → vi (Deceptive → Delayed Payoff)

Disney loves the deceptive cadence as a narrative device: you expect home, you get vi instead, which means the story isn't over yet. The real I arrives 2–4 bars later, and it hits harder for the delay.

  • "Part of Your World" — the climactic "Part of your wooorld" at ~2:48 lands on vi (Fm in A♭) instead of I. The true I arrives on the final sustained "world" four bars later. The delay is the drama.
  • "When She Loved Me" (Toy Story 2, 1999, Newman) — V→vi at "when somebody loved me," resolved only at the very end to I. Devastating.

Picardy Third (i → I)

The resolution from minor tonic to major tonic at a phrase or section boundary. Signals transcendence, transformation, acceptance.

  • "Let It Go" — as discussed, the entire chorus is a Picardy resolution of the minor verse.
  • "Surface Pressure" (Encanto, 2021) — bridge collapses into minor, final chorus re-emerges in major.
  • Chiptune note: on 4 channels, a Picardy third is extremely effective — just raise ♭3̂ to 3̂ on the final chord. One semitone, massive emotional payoff.

The Lydian IV♯ ("Wonder")

Giacchino's signature. Raising the 4th scale degree by a half step creates a floating, open, "look at that view" quality. Not a cadence per se, but a color that precedes resolution.

  • "Married Life" (Up, 2009) — the main theme oscillates between I and IV♯ (F and B♮ over F) before resolving to a standard IV→V→I. The ♯4̂ is the wonder; the resolution to natural 4̂→V→I is the return to earth.
  • "Nomanisan Island" (The Incredibles, 2004) — Giacchino uses ♯4̂ over spy-jazz chords for the "adventure is out there" moments.

ii → V → I with Chromatic Bass

The Broadway sophistication move. The bass walks chromatically (e.g., ii → ♭II⁶₅ → I, or ii → V → ♭V → I with tritone sub). Lopez/Anderson-Lopez use this constantly.

  • "For the First Time in Forever" (Frozen) — the bridge at ~1:50 features a descending chromatic bass under sustained vocal notes: IV → iv → I⁶₄ → V⁷ → I (chromatic inner voice: 6̂→♭6̂→5̂→4̂→3̂). Classic Broadway walk-down.
  • In chiptune: assign the bass channel to a chromatic walk while pulse channels hold sustained chord tones. Extremely idiomatic for NES-style writing.

3. The "Truck Driver" Modulation

A modulation up a half or whole step (occasionally a minor third) for the final chorus. Named for country music, but Disney perfected it.

When It's Earned

The modulation must be prepared by dramatic context: a belt, a key lyric, a character breakthrough.

  • "Defying Gravity" (Wicked / Menken-adjacent Broadway DNA, 2003) — modulates up a semitone from D♭ to D at "nobody's gonna bring me down." The modulation IS the character's transformation. It's earned because the entire song has been building harmonic tension.
  • "Go the Distance" — modulates from D to E for the final chorus (~2:30). Prepared by a 2-bar orchestral swell on V/E (B major). Earned by narrative context (Hercules commits to his quest).
  • "Let It Go" — conspicuously does NOT modulate. The resolution is the mode change (minor→major), not a key change. This was a deliberate Lopez choice.

When It's Cheap

If the modulation arrives without dramatic justification — just "we need more energy for the last chorus" — it feels like a gimmick. Post-2010 Disney has largely moved away from truck-driver mods in favor of the ♭VI→♭VII→I climax or textural builds.

Chiptune Application

A half-step-up modulation in a 4–8 bar victory sting is extremely effective if the first phrase establishes the key clearly. 2 bars in C → 2-bar transition → 2 bars in D♭ with the same melody = instant "level up" feeling. Use a single bar of V/D♭ (A♭ major) as the pivot.


4. Melodic Resolution: Where Does the Final Note Land?

Landing Feeling Who Does It
(tonic) Complete, triumphant, certain Menken, Lopez, Miranda
(mediant) Bittersweet, reflective, open Randy Newman, Giacchino
(dominant) Hopeful, "to be continued" Giacchino (score cues), Menken (mid-song)

The Menken/Ashman 2̂→1̂

The most iconic Disney melodic resolution: the melody approaches 1̂ from 2̂ (a whole step above), often preceded by 7̂→1̂→2̂→1̂. This creates a gentle stepwise landing — no leaps, pure inevitability.

  • "Part of Your World": final "world" = B♭→A♭ (2̂→1̂ in A♭).
  • "A Whole New World": final "world" = E→D (2̂→1̂ in D).
  • "Go the Distance": final "distance" = F♯→E (2̂→1̂ in E).

The Newman 3̂

Newman ends on the mediant (3̂) to leave emotional space. It says "this isn't a fairy tale; it's life."

  • "You've Got a Friend in Me": final "me" = G (3̂ in E♭).
  • "When She Loved Me": last note = 3̂, unresolved, aching.

Chiptune Takeaway

For a victory sting, land on 1̂ with a 2̂→1̂ or 7̂→1̂ approach. For a bittersweet story moment, land on 3̂. For "quest continues", land on 5̂ over a I chord (stable but open).


5. Pixar / Giacchino Scoring Techniques

Giacchino's approach to resolution diverges from the song-based model:

Resolution via Texture, Not Cadence

  • "Married Life" (Up): the theme plays through in full orchestration → tragedy hits → the theme returns on solo piano, same notes, stripped bare. Resolution = the return of the motif in a thinner texture. The harmony barely changes; the orchestration does all the emotional work.
  • "Remembering Bing Bong" (Inside Out, 2015): ostinato pattern (4 notes, piano) continues unchanged while strings swell and recede around it. The resolution is the ostinato surviving the chaos. The final chord is just I with the ostinato still going.

The Giacchino Ostinato-to-Resolution Pattern

  1. Establish a 1–2 bar ostinato (arpeggiated I or i)
  2. Layer harmonic motion above/below it (IV, vi, ii, ♭VI)
  3. Strip layers away until only the ostinato remains
  4. Final bar: ostinato alone on I → silence

This is perfect for chiptune. Your arpeggio channel IS the ostinato. Layer pulse-wave melody and noise-channel texture, then pull them out, leaving the bare arpeggio for the last beat.

Coco: "Remember Me" (Scoring, Not the Song)

Giacchino's underscore for the finale uses a circle-of-fifths descent (I→IV→♭VII→♭III→♭VI→♭II→V→I) under the melody — a complete harmonic journey that arrives home with inevitability. The bass moves in perfect fourths, each chord's 7th resolving stepwise to the next chord's 3rd. This "inevitability chain" connects to the Lopez technique below.


6. Lopez / Anderson-Lopez: The Inevitability Chain

The Lopezes write with a Broadway harmonic vocabulary dense with secondary dominants and chromatic approach chords, creating chains where each chord must resolve to the next.

The Technique

Each chord contains a tendency tone (a note that wants to resolve by half-step) that becomes a chord tone of the next chord:

I → V/vi → vi → V/V → V → V/IV → IV → V → I
(C → E → Am → D → G → C7 → F → G → C)

Every chord feels like it's "falling" into the next. The listener experiences resolution continuously, not just at the final cadence.

Song Examples

  • "For the First Time in Forever": the bridge ("'cause for the first time in forever...") chains I→V/ii→ii→V/V→V→I, each secondary dominant making the next chord feel inevitable. The final I hits like exhaling.
  • "Love Is an Open Door" (Frozen): uses a chain of secondary dominants over a descending bass (a passacaglia-like structure) for comic breathlessness.
  • "We Don't Talk About Bruno" (Encanto, 2021): chains i→♭VII→♭VI→V across sections, each character's verse adding a new link to the chain. The collective chorus is the resolution — all the harmonic threads converging on i (then Picardy to I).
  • "Surface Pressure": secondary dominant chain in the pre-chorus (V/vi→vi→V/V→V) with a chromatic bass line, resolving to I for the chorus drop.

7. Actionable Chiptune Recipes (2–8 Bars)

Recipe A: "Victory Sting" (4 bars, triumphant)

Key of C major, ♩= 160+
| IV   | V    | vi  IV | V   I  |
| F    | G    | Am  F  | G   C  |

Melody: 6̂–7̂ | 1̂–5̂ | 6̂–5̂–4̂–3̂ | 2̂–1̂
              (deceptive delay)  (payoff)

Pulse 1 = melody. Pulse 2 = chord tones (3rds/5ths). Triangle = bass (roots). Noise = snare on 2&4.

Recipe B: "Amen Resolve" (2 bars, warm completion)

Key of G major, ♩= 120
| IV    V⁷  | I     |
| C    D⁷   | G     |

Melody: 4̂–2̂ | 1̂ (sustained)

Hold the final I for a full bar with the melody on 1̂. Pulse 2 plays 3̂→5̂ arpeggio under it.

Recipe C: "Mario / Heroic Arrival" (4 bars, ♭VII→I)

Key of C major, ♩= 140
| I    | ♭VI       | ♭VII      | I        |
| C    | A♭        | B♭        | C        |

Melody: 5̂–1̂ | ♭6̂–♭7̂ | ♭7̂–1̂–2̂ | 3̂–1̂

The ♭VI→♭VII→I gives a heroic, folk-modal lift. Perfect for overworld or quest-complete.

Recipe D: "Bittersweet Story Beat" (4 bars, end on 3̂)

Key of A minor → A major (Picardy), ♩= 100
| i     | ♭VI    | ♭VII    | I (Picardy) |
| Am    | F      | G       | A            |

Melody: 1̂–♭3̂ | ♭6̂–5̂ | ♭7̂–1̂ | 3̂ (sustained, now natural 3̂ = C♯)

The Picardy I at bar 4 transforms ♭3̂ to 3̂. Land the melody on the new 3̂ (C♯ in A major) for Newman-style bittersweetness within a hopeful frame.

Recipe E: "Truck Driver Level-Up" (6 bars)

Key of C → D♭, ♩= 150
| I    | IV  V  | I     | V/D♭    | IV(D♭) V(D♭) | I(D♭)    |
| C    | F   G  | C     | A♭      | G♭     A♭    | D♭       |

Melody (bars 1–3): 5̂–1̂–3̂ | 4̂–5̂ | 1̂
Melody (bars 4–6): 5̂–1̂–3̂ | 4̂–5̂ | 1̂ (same melody, new key!)

Bar 4 is the pivot — V of D♭ (A♭) recontextualizes everything. The repeated melody in the new key = instant elation.

Recipe F: "Giacchino Ostinato Resolve" (8 bars)

Key of F major, ♩= 110
Triangle (ostinato, constant): F–A–C–A (I arpeggio, eighth notes, all 8 bars)

Bars 1–4: Pulse 1 melody enters, Pulse 2 adds countermelody
| I     | IV    | ii    | V     |
Bars 5–6: Noise adds rhythm, texture peaks
| vi    | IV    |
Bar 7: All channels drop except Triangle ostinato + Pulse 1
| V⁷            |
Bar 8: Triangle ostinato alone → final note = root (F), fermata
| I              |

Resolution = silence of layers, not harmonic climax. The arpeggio surviving alone IS the emotional statement.


Quick Reference: Disney Cadence Cheat Sheet

Cadence Roman In C Feeling Use For
Authentic V→I G→C Definitive, triumphant Victory, boss defeat
Plagal IV→I F→C Warm, benediction Level complete, save point
♭VII→I ♭VII→I B♭→C Heroic, folk, modal Quest complete, overworld
♭VI→♭VII→I ♭VI→♭VII→I A♭→B♭→C Anthemic, ascending Final boss, credits
Deceptive V→vi G→Am Delayed, yearning Mid-level, "not yet"
Picardy i→I Cm→C Transformation, hope Story twist, dawn scene
Lydian color I(♯4) → I Fmaj7♯11→F Wonder, floating Discovery, new area reveal

Every Disney resolution earns its landing by first making you feel the distance between where you are and where you want to be. The yearning IS the technique. Build tension with ii, IV, and chromatic approach — then let gravity do the rest. In chiptune, you have four voices to do what an orchestra does: make one of them ache, and let the others bring it home.

PFM — Programmable Fun Music format (v1)

PFM is a plain-text, line-oriented, human-diffable chiptune score format. It targets an NES-2A03-style 4-voice synth (2 pulse, 1 triangle, 1 noise) but voice roles are soft — any voice can use any waveform.

Design constraints:

  • One event per token, one voice per line-group. No vertical alignment required. Bars are delimited explicitly with | so rhythm errors are local and reportable as bar:beat.
  • Trivially parseable. str.split() on whitespace inside a bar is enough. No lookahead, no nested grammar.
  • Self-describing. The header carries everything a validator or renderer needs: tempo, key/mode, time signature, per-voice waveform, envelope, range, and volume.
  • Diffable. Changing one note touches one token on one line.

1. File structure

# comments start with '#', blank lines ignored everywhere
@directive value ...      ← header directives (start with '@')
voice <name> ...          ← declare a voice
<name>: ev ev | ev ev |   ← score line(s) for that voice
@loop                     ← optional loop marker

A PFM file is a header (@ directives + voice declarations) followed by a body (name: score lines). Score lines for the same voice concatenate in file order, so long parts can wrap across many lines.


2. Header directives

Directive Form Meaning
@title @title <text…> Free text, metadata only.
@tempo @tempo <bpm> Quarter-note BPM. Integer or float. Required.
@timesig @timesig <num>/<den> e.g. 4/4, 3/4, 6/8. Den must be 1,2,4,8,16. Required.
@key @key <tonic> <mode> Tonic = C..B w/ optional #/b. Mode = major, minor, dorian, phrygian, lydian, mixolydian, locrian, pent_major, pent_minor, blues, chromatic. Required (use chromatic to opt out of tonality checks).
@loop @loop Marks the piece as a seamless loop. Validator checks the loop seam; renderer honours --loops N.

2.1 Voice declaration

voice <name> wave=<w> [duty=<d>] [adsr=<a>,<d>,<s>,<r>] [vol=<0..1>] [range=<lo>-<hi>]
Field Values Default
<name> identifier, e.g. pulse1, tri, lead, bass. Max 4 voices.
wave pulse, triangle, saw, noise — (required)
duty 12.5, 25, 50 (percent; pulse only) 50
adsr Attack, Decay (seconds), Sustain (0–1 level), Release (seconds). 0.005,0.05,0.7,0.05
vol Mix level 0–1. 0.8
range <lo>-<hi> scientific pitch, e.g. C2-C6. Validator flags notes outside. C1-C7

Noise voices ignore pitch frequency but still accept a pitch token — the octave number maps to the LFSR clock rate (higher = brighter hiss).


3. Body — score lines

<voiceName>: <bar> | <bar> | ... |
  • A score line starts with a declared voice name followed by :.
  • Bars are separated by |. Trailing | is optional.
  • Multiple <voiceName>: lines concatenate — bar numbering continues.
  • Inside a bar, events are whitespace-separated tokens.

3.1 Event tokens

Token Pattern Meaning
Note <Pitch>:<dur>[~][!<vol>] Sound a pitch for a duration.
Rest R:<dur> Silence for a duration.
Hold -:<dur> Continue the previous sounding note (tie extension). Same as ~ on the previous note but lets a tie cross a barline cleanly.
ADSR override {a,d,s,r} Applies to the next note only, then reverts to the voice default.

Pitch — scientific notation: note letter A–G, optional accidental # (sharp) / b (flat), octave 0–8. Middle C is C4 (MIDI 60). A4 = 440 Hz.

Duration — a fraction of a whole note:

  • 1/1, 1/2, 1/4, 1/8, 1/16, 1/32
  • Shorthand: a bare integer N means 1/N, so C4:4C4:1/4.
  • Dotted: append . → ×1.5. C4:4. is a dotted quarter (3/8 of a whole).
  • Double-dot .. → ×1.75.
  • Triplet: append t → ×⅔. C4:8t is a triplet eighth (1/12 of a whole).
  • Ties: append ~ → this note is tied into the next event in the same voice (which must be the same pitch or a -: hold). Duration accumulates; envelope does not retrigger.

Per-note volume!<0..1> at the very end: C4:8!0.4.

3.2 Bar semantics

The validator sums every event's duration (in whole-note units) inside each | … | span and checks it equals num/den from @timesig (within 1e-6). A mismatch is reported as:

[voice] bar <n>: expected 4/4 (=1.000), got 0.875 at beat 3.5

Voices need not have the same number of bars — shorter voices are implicitly rested to the length of the longest voice (renderer) but the validator warns if @loop is set and voice lengths differ.


4. Worked examples

4.1 Minimal — single-voice jingle (4/4, C major)

@title Coin
@tempo 150
@timesig 4/4
@key C major
voice p1 wave=pulse duty=25 adsr=0.002,0.06,0.3,0.04 vol=0.9 range=C4-C7

p1: B5:16 E6:16 R:8 R:4 R:2 |

One bar: 1/16 + 1/16 + 1/8 + 1/4 + 1/2 = 1.0 ✓

4.2 Two-voice loop with tie across barline (A minor, 3/4)

@title Waltzy
@tempo 96
@timesig 3/4
@key A minor
@loop
voice lead wave=pulse duty=50 adsr=0.01,0.08,0.6,0.08 range=A3-A6
voice bass wave=triangle adsr=0.005,0.02,0.9,0.1 range=A1-A4

lead: A4:4 C5:4 E5:4 | E5:4~ -:4 D5:4 | C5:4 B4:4 A4:4 | A4:2. |
bass: A2:2.          | F2:2.          | G2:2.          | A2:2. |
  • E5:4~ -:4 is a half-note E5 that straddles nothing here but shows the tie syntax; -:4 extends without retrigger.
  • Every bar sums to 3/4 = 0.75. Loop seam: lead A4 → A4 (P1), bass A2 → A2 (P1). ✓

4.3 Four-voice groove with per-note ADSR and triplets

@title Groove
@tempo 120
@timesig 4/4
@key E dorian
voice p1  wave=pulse duty=12.5 adsr=0.002,0.05,0.5,0.03 vol=0.7 range=C4-C7
voice p2  wave=pulse duty=50   adsr=0.01,0.1,0.7,0.1   vol=0.6 range=C3-C6
voice tri wave=triangle        adsr=0.005,0.02,1.0,0.05 vol=0.9 range=E1-E4
voice nz  wave=noise           adsr=0.001,0.04,0.0,0.01 vol=0.5

p1:  E5:8 G5:8 A5:8 B5:8  {0,0.02,0,0.02} D6:8t D6:8t D6:8t  B5:4 |
p2:  E4:4       G4:4       A4:4                         B4:4      |
tri: E2:2                           E2:2                          |
nz:  C4:8 R:8 C4:8 R:8  C4:8 R:8 C4:8 R:8 |
  • p1 bar: 4×⅛ + 3×(1/12) + ¼ = 0.5 + 0.25 + 0.25 = 1.0 ✓
  • {0,0.02,0,0.02} gives the next D6 a percussive blip envelope, overriding p1's declared ADSR for that note only.
  • nz uses pitch C4 purely to pick a mid-rate LFSR clock.

5. Grammar (informal EBNF)

file      := (blank | comment | directive | voiceDecl | scoreLine)*
comment   := '#' .* EOL
directive := '@' ident rest-of-line
voiceDecl := 'voice' ident (ident '=' value)*
scoreLine := ident ':' bar ('|' bar)* '|'? EOL
bar       := event*
event     := note | rest | hold | adsrOverride
note      := pitch ':' dur tie? vol?
rest      := 'R' ':' dur
hold      := '-' ':' dur
pitch     := [A-Ga-g] ('#'|'b')? [0-8]
dur       := ( int '/' int | int ) '.'? '.'? 't'?
tie       := '~'
vol       := '!' float
adsrOverride := '{' float ',' float ',' float ',' float '}'

6. Reserved / future

  • @section <name> / @goto <name> for pattern reuse — not in v1.
  • Pitch bend, vibrato, arpeggio macros — not in v1. Keep it shippable.

PFM v2 extensions

All v1 files remain valid. v2 adds:

7. New header directives

Directive Form Meaning
@swing @swing <0.5..0.75> Swing ratio for 8th pairs. 0.5=straight, 0.67=triplet swing. Renderer warps onset times; bar-sum validation is unchanged (write straight 8ths).
@style @style standard|ambient|drone|energetic Tunes musicality-check thresholds (density floor, percussion requirement, static-harmony window).

8. New voice attributes

Attr Meaning
wave=drums Alias for noise; marks voice role as drums.
follows=<voice> Makes this an echo voice: auto-copies <voice>'s score, delayed. The echo voice must have NO score lines of its own.
delay=<frac> Echo delay in whole-note units, e.g. 3/16.
role=lead|bass|harmony|drums Hint for analyzer/validator role inference.
voice echo wave=pulse duty=25 follows=lead delay=3/16 vol=0.3

9. New event tokens

Token Pattern Meaning
Drum hit K:8 S:8 H:16 O:8 C:4 Kick / Snare / Hat / Open-hat / Crash. Only on noise/drums voices. Each expands to a fixed LFSR-rate + pitch-drop + ADSR macro.
Arpeggio [C4 E4 G4]:4 Cycle the bracketed pitches at ~45 Hz for the duration. Counts as ONE event of the given duration for rhythm/density. Tonality checks each pitch.
Vibrato C4~v:2 ±25 cents sine @ 6 Hz during sustain.
Bend C4>E4:4 Linear pitch glide from C4 to E4 over the duration.
Triplet C4:8t (v1) — three 8t fill one quarter.

10. v2 musicality warnings

Emitted by validate_pfm.py (never errors):

  • density — some 4-bar window's most-active pitched voice falls below the style's events/sec floor (standard=1.5, ambient=0.8, drone=0.4).
  • percussion@loop piece >8 bars with no sounding noise voice.
  • harmony — implied chord static for > style threshold bars.
  • stagnation — lead repeats same pitch >3× with identical rhythm.
  • pedal-bass — bass holds one pitch-class for > style threshold bars.

@master — mix-bus settings (v2.1)

@master headroom=-3 limiter=none gain=1.0
  • headroom — normalize final peak to this dBFS (default −3.0).
  • limiternone (default, linear), soft (tanh), hard (clamp). With none, renderer warns to stderr if raw Σ peak >1.0.
  • gain — post-normalize trim (rarely needed).

Use meter_pfm.py song.pfm to see raw peak, saturation%, and per-voice energy share before committing levels.

@swing interaction with sub-8th values (v2.1 clarification)

@swing is a global piecewise-linear time-warp per beat (first half → swing·beat, second → (1−swing)·beat). It therefore warps 16th-note onsets too. If you want straight-16th syncopation (e.g. SMB overworld), do not use @swing — write the rests explicitly on a straight grid. The validator emits ⚠ [swing-grid] when both are present.

#!/usr/bin/env python3
"""
meter_pfm.py — headroom / saturation / per-voice mix analysis for PFM.
Renders each voice to float (reusing render_pfm.render_voice), then
measures the raw float sum BEFORE the tanh limiter / normalizer in
render_pfm.mix_and_write. This exposes what the limiter is hiding.
Why this exists
---------------
render_pfm does: out = normalize( tanh(0.6 · Σ voice_i) , −1 dBFS )
If Σ voice_i regularly > ~0.83 the tanh is in its nonlinear knee: wave
tops flatten → audible "crunch" even though the WAV peaks at −1 dBFS.
And because everything is then normalized to the SAME peak, relative
track loudness is lost and the loudest voice sets the ceiling for all.
Reports
· per-voice peak / RMS / energy-share of the mix
· raw-sum peak (>1.0 ⇒ limiter doing real work), RMS, crest
· saturation %: |raw| > 0.833 (tanh >8% nonlinear)
· hard-over %: |raw| > 1.667 (tanh slope <0.25, tops ~flat)
· longest continuous saturation run (sustained crunch vs transient)
WAV mode (meter_pfm.py --wav file.wav or any *.wav arg):
· peak dBFS, RMS dBFS, crest
· flat-top run: longest run of consecutive samples within 0.1 dB of
file peak → limiter/clip artefact even when peak < 0 dBFS.
Exit 1 if saturation% > --sat-limit (default 5) or raw peak > --peak-limit
(default 1.5) or any voice energy-share > --dom-limit (default 50).
"""
from __future__ import annotations
import sys, os, math, argparse, json, wave, struct
from fractions import Fraction
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from validate_pfm import parse_pfm
from render_pfm import render_voice, whole_note_seconds
SAT_THRESH = 0.833 # |raw| beyond which tanh(0.6x) is >8% nonlinear
HARD_THRESH = 1.667 # tanh slope ≈0.25 — effectively squaring the tops
def db(x): return 20*math.log10(x) if x>1e-12 else -120.0
def stats(buf):
n=len(buf); pk=0.0; ss=0.0
for s in buf:
a=abs(s); ss+=s*s
if a>pk: pk=a
rms=math.sqrt(ss/n) if n else 0.0
return pk, rms
def longest_run_above(buf, thr):
best=cur=0
for s in buf:
if abs(s)>=thr: cur+=1; best=max(best,cur)
else: cur=0
return best
# ─────────────────────────────────────────────────────────── WAV mode
def meter_wav(path):
with wave.open(path,'rb') as w:
n=w.getnframes(); raw=w.readframes(n)
samps=struct.unpack(f'<{n}h',raw)
buf=[s/32767.0 for s in samps]
pk,rms=stats(buf)
ft_thr=pk*10**(-0.1/20)
ft=longest_run_above(buf, ft_thr)
near=sum(1 for s in buf if abs(s)>=ft_thr)
return dict(path=path, n=n, peak=pk, peak_db=db(pk), rms_db=db(rms),
crest_db=db(pk)-db(rms), flat_top_run=ft,
flat_top_ms=1000*ft/22050, near_peak_pct=100*near/n)
# ─────────────────────────────────────────────────────────── PFM mode
def meter_pfm(path, rate=22050, loops=1):
with open(path) as f: song=parse_pfm(f.read())
lim_mode=song.master.get('limiter','none')
total=Fraction(0)
for v in song.voices.values():
t=sum((n.dur for bar in v.bars for n in bar), Fraction(0))
if t>total: total=t
n_samp=max(1,int(round(float(total)*whole_note_seconds(song)*rate*loops)))
vbufs={v.name: render_voice(song,v,total,rate,loops)
for v in song.voices.values()}
mix=[0.0]*n_samp
for vb in vbufs.values():
for i in range(n_samp): mix[i]+=vb[i]
pk,rms=stats(mix)
sat =sum(1 for s in mix if abs(s)>SAT_THRESH)
hard=sum(1 for s in mix if abs(s)>HARD_THRESH)
sat_run=longest_run_above(mix, SAT_THRESH)
ve={}; tot_e=0.0
for name,vb in vbufs.items():
vpk,vrms=stats(vb)
vol=song.voices[name].vol if hasattr(song.voices[name],'vol') else None
ve[name]=dict(vol=vol, peak=vpk, peak_db=db(vpk), rms=vrms,
rms_db=db(vrms), crest_db=db(vpk)-db(vrms),
energy=vrms*vrms)
tot_e+=vrms*vrms
for name in ve:
ve[name]['energy_pct']=100*ve[name]['energy']/tot_e if tot_e else 0
return dict(
path=path, tempo=song.tempo, n_samples=n_samp, seconds=n_samp/rate,
voices=ve,
mix=dict(peak=pk, peak_db=db(pk), rms=rms, rms_db=db(rms),
crest_db=db(pk)-db(rms)),
saturation=dict(thresh=SAT_THRESH, pct=100*sat/n_samp,
longest_run=sat_run, longest_ms=1000*sat_run/rate),
hard_over=dict(thresh=HARD_THRESH, pct=100*hard/n_samp),
limiter=dict(mode=lim_mode, over=pk>1.0,
engaged=(pk>1.0 and lim_mode!='none'),
gain_reduction_db=db(1.0/pk) if pk>1.0 else 0.0,
headroom_db=song.master.get('headroom_db',-3.0)),
)
def fmt_report(r, sat_limit, peak_limit, dom_limit):
L=[]; a=L.append
a(f"══ {r['path']} {r['seconds']:.2f}s @ {r['tempo']}bpm")
m=r['mix']
a(f" raw mix peak={m['peak']:.3f} ({m['peak_db']:+.2f} dB) "
f"rms={m['rms']:.3f} ({m['rms_db']:+.2f} dB) crest={m['crest_db']:.1f} dB")
lim=r['limiter']
if lim['mode']=='none':
a(f" limiter none → linear normalize to {lim['headroom_db']:+.1f} dBFS "
f"(scale ×{10**(lim['headroom_db']/20)/m['peak']:.3f})"
+(" [raw>1.0: balance set by normalizer]" if lim['over'] else ""))
else:
a(f" limiter {lim['mode']} "+
(f"ENGAGED (~{abs(lim['gain_reduction_db']):.1f} dB into knee)" if lim['engaged'] else "clean"))
s=r['saturation']; h=r['hard_over']
a(f" saturate {s['pct']:6.2f}% |raw|>{s['thresh']:.2f} "
f"longest run {s['longest_run']:6d} samp ({s['longest_ms']:.1f} ms)")
a(f" hard-over {h['pct']:6.2f}% |raw|>{h['thresh']:.2f}")
a( " ── per-voice ──────────────────────────────────────────────────")
a(f" {'voice':10} {'vol':>5} {'peak':>7} {'pk dB':>7} {'rms dB':>7} {'crest':>6} {'energy%':>8}")
for name,v in sorted(r['voices'].items(), key=lambda kv:-kv[1]['energy_pct']):
vol=f"{v['vol']:.2f}" if v['vol'] is not None else ' ? '
a(f" {name:10} {vol:>5} {v['peak']:7.3f} {v['peak_db']:7.2f} "
f"{v['rms_db']:7.2f} {v['crest_db']:6.1f} {v['energy_pct']:7.1f}%")
warn=[]
lim=r['limiter']
if lim['mode']!='none':
if m['peak']>peak_limit:
warn.append(f"raw peak {m['peak']:.2f} > {peak_limit} — {lim['mode']}-limiter driven hard")
if s['pct']>sat_limit:
warn.append(f"saturation {s['pct']:.1f}% > {sat_limit}% — audible {lim['mode']}-clip crunch")
else:
if m['peak']>3.0:
warn.append(f"raw peak {m['peak']:.2f} > 3.0 — voice vols are decorative; "
f"rebalance so Σ≈1.0")
if s['pct']>30.0:
warn.append(f"{s['pct']:.0f}% of samples >0.83 — consider @master limiter=soft "
f"or lower vols for transient safety")
if len(r['voices'])>1:
dn,dv=max(r['voices'].items(), key=lambda kv:kv[1]['energy_pct'])
if dv['energy_pct']>dom_limit:
warn.append(f"voice '{dn}' dominates mix ({dv['energy_pct']:.0f}% energy, limit {dom_limit}%)")
for w in warn: a(f" ⚠ {w}")
if not warn: a(" ✔ headroom ok")
return "\n".join(L), (1 if warn else 0)
def main(argv=None):
ap=argparse.ArgumentParser()
ap.add_argument('paths', nargs='+')
ap.add_argument('--wav', action='store_true')
ap.add_argument('--json', action='store_true')
ap.add_argument('--rate', type=int, default=22050)
ap.add_argument('--sat-limit', type=float, default=5.0)
ap.add_argument('--peak-limit', type=float, default=1.5)
ap.add_argument('--dom-limit', type=float, default=55.0)
a=ap.parse_args(argv)
rc=0
for p in a.paths:
if a.wav or p.endswith('.wav'):
r=meter_wav(p)
if a.json: print(json.dumps(r)); continue
print(f"══ {p} peak={r['peak_db']:+.2f} dBFS rms={r['rms_db']:+.2f} "
f"crest={r['crest_db']:.1f} dB flat-top={r['flat_top_run']} samp "
f"({r['flat_top_ms']:.1f} ms) near-peak={r['near_peak_pct']:.2f}%")
continue
r=meter_pfm(p, rate=a.rate)
if a.json: print(json.dumps(r)); continue
txt,code=fmt_report(r, a.sat_limit, a.peak_limit, a.dom_limit)
print(txt); print()
rc|=code
return rc
if __name__=='__main__':
sys.exit(main())

Chiptune Audio Design Philosophy

Written after building the toolchain (PFM format → validator → renderer) and composing game audio. Everything below is constrained by, and informed by, what the tools can actually express and verify.


1. Why chiptune

Three converging reasons:

  1. File size. Mobile WebView; every KB goes over the wire and into decodeAudioData. A 53-second ambient loop rendered from PFM is 2.3 MB as raw 22 kHz mono PCM and will gzip/OGG down to a fraction of that — but more importantly the source is ~2 KB of diffable text. We can regenerate audio at build time and never check WAVs into git.
  2. Visual coherence. All five games use flat-shaded neon geometry on dark grounds. Square/triangle waves look like the art sounds: hard edges, high contrast, limited palette.
  3. Cognitive load. Chiptune's spectral simplicity (few harmonics, no reverb tails, no wide stereo image) leaves the player's auditory working memory free for the puzzle. A lush orchestral pad would fight for attention; a lone triangle drone doesn't.

1.1 Hardware references

Chip Voices Waves What we steal
Ricoh 2A03 (NES) 2 pulse + 1 tri + 1 noise + DPCM 12.5/25/50/75% pulse duty, 4-bit tri, 15-bit LFSR noise Our exact voice model. Triangle has no volume control on real HW — we cheat and give it one.
GB APU (LR35902) 2 pulse + 1 wave + 1 noise 4 pulse duties, 32-sample wavetable, 7/15-bit LFSR The "metallic" 7-bit short-LFSR mode — future noise mode=short extension.
MOS 6581 SID (C64) 3 × (pulse/tri/saw/noise) + analog filter Continuous PWM, multimode filter, ring mod Saw wave + filter sweep for organic moods. We fake filter with ADSR decay.
SN76489 PSG (Master System / BBC) 3 square + 1 noise Fixed 50% squares, periodic/white noise The bare-minimum sound. Good reference for "how sparse can SFX be and still read".

PFM v1 maps directly onto 2A03: four voices max, pulse/tri/noise, LFSR noise clocked by octave. Saw is the one SID indulgence.


2. Puzzle-game audio — what works

Game Ambient strategy Takeaway
Tetris (GB, 1989) 3 melodic loops (Korobeiniki etc.), ~32 bars, strongly tonal, always on Players tolerate — even love — short loops if the harmony never builds tension that demands resolution. Type-A is in A minor with almost no dominant function.
Lumines (2004) Music is the game clock; every block-drop is quantised to the beat SFX must be in key and on grid with the ambient. We adopt this: every game SFX is pitched inside the ambient's pentatonic scale.
Mini Metro (2014) Disasterpeace: generative, one chord, notes triggered by passenger events, pentatonic Pentatonic = no wrong notes. When game events fire SFX at unpredictable times, any two pentatonic pitches form at worst a M2 — never a tritone or m2.
Monument Valley (2014) Long drones, sparse melodic fragments, heavy reverb, Lydian/Dorian colours Modal (not functional) harmony = no V→I pull = loops don't feel "unfinished" at the seam.
Threes (2014) Single 16-bar loop + pitched SFX that form a rising scale as tile values grow SFX pitch ladder: map game-state magnitude → scale degree. We reserve this for star and chain-combo feedback.

2.1 The loop-seam rule

The validator enforces it mechanically: last note → first note ≤ P5 in every voice. This is the single biggest difference between "pleasant background" and "I can hear the loop point". Combined with:

  • No leading tone. Pentatonic minor has no ♭6 or ♭2; natural minor has no raised 7̂. Nothing demands the tonic, so the tonic arriving at bar 1 feels like rest, not resolution.
  • End on I. Bass returns to tonic in the final bar; lead either rests or holds the tonic/5th.
  • Bar-1 must work as both an entrance and a continuation. Compose bar 1 last.

3. Per-game sonic palette

All five games share the PFM engine and SFX slot names (tap connect complete fail undo star unlock ambient) so the audio manifest is structurally identical. What changes is key, mode, tempo, duty-cycle emphasis, and the "characteristic interval" — the one interval that dominates the ambient melody and therefore colours the player's memory of the game.

Game Theme / mood Key · Mode Tempo Wave emphasis Characteristic interval Notes
Game A calm, focused A pent-minor 72 pulse 25% lead, tri bass, 12.5% echo P5 (A–E) open, glowing Sparse — ≤50% note density in lead.
Game B kinetic, shifting D dorian 112 pulse 50% lead, pulse 25% counter, saw bass M6 (D–B) the dorian colour-note Faster; 8th-note ostinato in counter-voice. Noise hi-hat on off-beats.
Game C tense, brooding E phrygian 64 tri lead (hollow), pulse 12.5% drone, slow LFSR noise m2 (E–F) the phrygian ♭2, used sparingly as a "threat" tone Lowest register (bass in octave 1). Long notes, long rests.
Game D crystalline, precise F♯ pent-major 96 pulse 12.5% everything (thin = glassy), no tri M3 (F♯–A♯) bright, bell-like Short ADSR decay (≤60 ms) on every voice → plucked-glass timbre. Short-mode LFSR for metallic ticks.
Game E organic, growing G mixolydian 84 saw lead (rounded via slow attack), tri bass, no noise ♭7 (G–F) the mixo colour — warm, folky Only game with attack ≥ 40 ms on lead → "bowed" feel. Melody ascends across the loop (growth).

Rationale for key choices: spread tonics across the octave (A–D–E–F♯–G) so switching games gives an audible "scene change" without any two ambients sharing a tonal centre.


4. SFX design rules

4.1 Pitch ladder (per game, in the ambient's key)

Every SFX is one or two notes drawn from the ambient's scale, placed on a fixed ladder so simultaneous SFX + ambient never clash and the SFX themselves form a consonant hierarchy:

Slot Scale degree(s) Octave Envelope Duration budget Intent
tap +2 A=1ms D=40ms S=0 ≤ 80 ms Barely-there tick. Pure feedback, no pitch identity.
connect 1̂ → ♭3̂ (minor) / 3̂ (major) +1 A=2ms D=80ms S=0.3 ≤ 200 ms Small affirmation. Rising = "yes".
undo ♭3̂ → 1̂ 0 triangle, soft ≤ 200 ms Inverse of connect. Falling = "back".
complete 1̂–3̂–5̂–1̂ arpeggio ↑ +1 → +2 full ADSR 2 bars The only SFX allowed a melody. Must resolve on tonic.
fail 1̂ → ♭2̂ or 1̂ → ♯4̂ 0 sharp decay ≤ 500 ms The only out-of-key SFX. m2/tritone = universal "wrong". Declared @key X chromatic so validator doesn't flag it.
star 1̂–3̂–5̂–1̂ +2 → +3 12.5% pulse, short ≤ 400 ms Sparkle. Thinnest duty cycle, highest register.
unlock 5̂ → 1̂ → 3̂ +1 medium sustain ≤ 500 ms "Door opens". Ends on the 3rd — warmer than tonic.

4.2 Hard rules (validator-enforceable)

  • All SFX except fail must validate at 100% in-key against the game's ambient @key.
  • No SFX may exceed 1 bar at the ambient tempo (so it never straddles an ambient downbeat by more than one bar).
  • connect and undo must be pitch-inverses (same interval, opposite direction) — not validator-checked, but a review rule.

5. Adaptive audio (stretch goal)

The ambient .pfm already separates voices. For v2:

  1. Render each voice to its own WAV (render_pfm.py --solo <voice>).
  2. audio.ts gains playMusicLayered(stems: string[]) that starts all stems muted on a shared AudioContext clock.
  3. Game reports boardFillPct each move; audio manager lerps gain:
    • 0–25 %: bass only
    • 25–60 %: + lead
    • 60–90 %: + echo
    • 90–100 %: + a fourth "shimmer" voice (pre-composed, silent in the base mix)
  4. On complete, all stems crossfade to 0 over the sting's 2 bars.

Because every stem is the same 16-bar length at the same tempo and already validated for loop-seam, layering is just four GainNode.gain.setTargetAtTime() calls — no additional sync logic.


6. What the toolchain taught me

  • Fractions, not floats. Early validator drafts summed bar durations as floats and got 0.999999≠1.0 on triplets. Fraction fixed it and the spec now guarantees exact rhythm checking.
  • Interval class isn't enough for consonance. P4 and P5 fold to the same ic=5, but a bass P4 under a melody reads as an unresolved sus. The validator scores the directed mod-12 interval so P5 gets 1.0 and P4 gets 0.6, matching the spec and the ear.
  • Sparse is hard to validate. A calm ambient loop is ~50 % rests in the lead; the dissonance sampler originally divided by zero on all-rest grid slots. Now it only scores slots with ≥2 sounding pitches — which also means a deliberately monophonic piece can't fail the dissonance check. That's correct.
  • The # comment character collides with sharps. Comment stripping now requires whitespace-before-#. F#4 parses; F #4 is F then a comment. Documented in the spec.
#!/usr/bin/env bash
# Re-render every compositions/*.pfm and corpus/*.pfm, run meter on each,
# then regenerate soundboard-manifest.json (music/sfx/corpus) with per-track
# validator + meter reports embedded.
set -euo pipefail
cd "$(dirname "$0")"
echo "── Rendering compositions/*.pfm ──"
for f in compositions/*.pfm; do
base="$(basename "$f" .pfm)"
case "$base" in
game-sfx-*)
sfx="${base#game-sfx-}"
out="compositions/game-${sfx}.wav" ;;
game-sfx) continue ;;
*) out="compositions/${base}.wav" ;;
esac
python3 render_pfm.py "$f" -o "$out"
done
# keep ambient == ambient-v2 (soundboard 'ambient' slot plays the current pick)
cp compositions/game-ambient-v2.wav compositions/game-ambient.wav
cp compositions/game-ambient-v2.pfm compositions/game-ambient.pfm
echo "── Rendering corpus/*.pfm ──"
for f in corpus/*.pfm; do
python3 render_pfm.py "$f" -o "${f%.pfm}.wav"
done
echo "── Regenerating manifest ──"
python3 - <<'PY'
import json, subprocess, os, wave, glob
from meter_pfm import meter_pfm, meter_wav
def entry(slot, wav, pfm, cat, desc):
w = wave.open(wav); dur = w.getnframes() / w.getframerate(); w.close()
rv = subprocess.run(["python3","validate_pfm.py",pfm], capture_output=True, text=True)
try:
mp = meter_pfm(pfm)
# compact per-voice energy + headline numbers
mtr = {
"raw_peak": round(mp["mix"]["peak"],3),
"raw_peak_db": round(mp["mix"]["peak_db"],2),
"raw_rms_db": round(mp["mix"]["rms_db"],2),
"crest_db": round(mp["mix"]["crest_db"],1),
"sat_pct": round(mp["saturation"]["pct"],2),
"hard_pct": round(mp["hard_over"]["pct"],2),
"limiter": mp["limiter"]["mode"],
"headroom_db": mp["limiter"]["headroom_db"],
"voices": {n: {"vol":v["vol"],"energy_pct":round(v["energy_pct"],1),
"rms_db":round(v["rms_db"],1)}
for n,v in mp["voices"].items()},
}
rm = subprocess.run(["python3","meter_pfm.py",pfm],capture_output=True,text=True)
mtr_txt = rm.stdout.strip()
mtr_ok = rm.returncode==0
except Exception as e:
mtr={"error":str(e)}; mtr_txt=str(e); mtr_ok=False
mw = meter_wav(wav)
return {
"slot": slot, "category": cat, "description": desc,
"wav": wav, "duration": round(dur,2), "bytes": os.path.getsize(wav),
"pfm": open(pfm).read() if os.path.exists(pfm) else "",
"report": (rv.stdout + rv.stderr).strip(),
"clean": rv.returncode == 0,
"meter": mtr, "meter_report": mtr_txt, "meter_ok": mtr_ok,
"wav_peak_db": round(mw["peak_db"],2),
"wav_flat_top_ms": round(mw["flat_top_ms"],2),
}
out = []
music = [
("ambient", "game-ambient.wav", "game-ambient.pfm",
"16-bar loop · A pent-minor · 104 bpm · arp ostinato + breathing bass + hat-pulse"),
("ambient-calm", "game-ambient-calm.wav", "game-ambient-calm.pfm",
"12-bar deep-focus loop · 88 bpm · Brinstar-style · tri lead + arp + sparse K/H"),
("complete", "game-complete.wav", "game-complete.pfm",
"2-bar victory sting · ascending arpeggio → tonic"),
("fail", "game-fail.wav", "game-fail.pfm",
"descending m2 — the only out-of-key sound"),
]
for slot, wav, pfm, desc in music:
out.append(entry(slot, f"compositions/{wav}", f"compositions/{pfm}", "music", desc))
sfx = [
("tap", "game-tap.wav", "game-sfx-tap.pfm", "E6 click · 5̂ · barely-there feedback"),
("connect", "game-connect.wav", "game-sfx-connect.pfm", "A5→C6 · 1̂→♭3̂ rising · affirmation"),
("undo", "game-undo.wav", "game-sfx-undo.pfm", "C5→A4 · ♭3̂→1̂ falling · inverse of connect"),
("star", "game-star.wav", "game-sfx-star.pfm", "A6-C7-E7 sparkle · 12.5% pulse · highest register"),
("unlock", "game-unlock.wav", "game-sfx-unlock.pfm", "E5→A5→C6 · door-open chime"),
]
for slot, wav, pfm, desc in sfx:
out.append(entry(slot, f"compositions/{wav}", f"compositions/{pfm}", "sfx", desc))
corpus_meta = {
"tetris-a": "Tetris Type-A (Korobeiniki) · Hirokazu Tanaka · GB 1989",
"smb-overworld": "Super Mario Bros. Overworld · Koji Kondo · NES 1985",
"smb-underground": "Super Mario Bros. Underground · Koji Kondo · NES 1985",
"zelda-overworld": "Legend of Zelda Overworld · Koji Kondo · NES 1986",
"metroid-brinstar": "Metroid Brinstar · Hirokazu Tanaka · NES 1986",
"megaman2-wily1": "Mega Man 2 Wily Stage 1 · Takashi Tateishi · NES 1988",
"kirby-greengreens": "Kirby Green Greens · Jun Ishikawa · GB 1992",
"drmario-fever": "Dr. Mario Fever · Hirokazu Tanaka · NES 1990",
}
for f in sorted(glob.glob("corpus/*.pfm")):
slot = os.path.splitext(os.path.basename(f))[0]
wav = f"corpus/{slot}.wav"
if not os.path.exists(wav): continue
out.append(entry(slot, wav, f, "corpus", corpus_meta.get(slot, slot)))
json.dump(out, open("soundboard-manifest.json","w"), indent=2)
print(f"manifest: {len(out)} sounds "
f"(music={sum(1 for e in out if e['category']=='music')}, "
f"sfx={sum(1 for e in out if e['category']=='sfx')}, "
f"corpus={sum(1 for e in out if e['category']=='corpus')})")
# headroom summary table
print()
print(f"{'slot':16} {'cat':7} {'peak':>6} {'sat%':>6} {'dom-voice':>16} {'ok':>3}")
for e in out:
m=e.get('meter',{})
if 'error' in m: print(f"{e['slot']:16} {e['category']:7} ERROR: {m['error']}"); continue
dom=max(m['voices'].items(),key=lambda kv:kv[1]['energy_pct']) if len(m['voices'])>1 else ('—',{'energy_pct':0})
print(f"{e['slot']:16} {e['category']:7} {m['raw_peak']:6.2f} {m['sat_pct']:6.2f} "
f"{dom[0]+' '+str(round(dom[1]['energy_pct']))+'%':>16} {'✔' if e['meter_ok'] else '⚠':>3}")
PY
echo "✓ done — reload http://localhost:3090/soundboard.html"
#!/usr/bin/env python3
"""
render_pfm.py — synthesize a PFM (v2) file to 16-bit mono WAV @ 22050 Hz.
Pure stdlib. Imports parser from validate_pfm.py.
v2 features
-----------
- Drum-kit macros (K/S/H/C/O) on noise voice: LFSR rate + pitch-drop
envelope + per-hit ADSR.
- Arpeggio [C E G]:4 — cycles pitches at ~45 Hz for the duration.
- Vibrato ~v — ±25 cents sine @ 6 Hz during sustain.
- Pitch bend C4>E4:4 — linear freq ramp.
- @swing 0.67 — 8th-note pairs become long-short at onset time.
- Echo voice (follows=/delay=) — parser already expands bars.
CLI
---
render_pfm.py song.pfm -o song.wav [--loops N] [--rate 22050]
"""
from __future__ import annotations
import sys, os, math, wave, argparse
from array import array
from fractions import Fraction
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from validate_pfm import parse_pfm, flatten_voice, Song, Voice, Note, DRUM_KIT # noqa
TAU = math.pi * 2.0
ARP_RATE_HZ = 45.0 # pitch-cycle rate for [...] arps
VIB_RATE_HZ = 6.0
VIB_DEPTH_CENTS = 25.0
# ------------------------------------------------------------ oscillators
def osc_pulse(phase: float, duty: float) -> float:
return 1.0 if (phase % 1.0) < duty else -1.0
def osc_triangle(phase: float) -> float:
p = phase % 1.0
return 4.0*p - 1.0 if p < 0.5 else 3.0 - 4.0*p
def osc_saw(phase: float) -> float:
return 2.0*(phase % 1.0) - 1.0
class LFSR:
__slots__ = ('reg',)
def __init__(self): self.reg = 0x4A11
def step(self) -> float:
fb = (self.reg ^ (self.reg >> 1)) & 1
self.reg = (self.reg >> 1) | (fb << 14)
return 1.0 if (self.reg & 1) else -1.0
# ------------------------------------------------------------ helpers
def midi_to_hz(m: float) -> float:
return 440.0 * (2.0 ** ((m - 69) / 12.0))
def whole_note_seconds(song: Song) -> float:
return (60.0 / song.tempo) * 4.0
def noise_clock(midi: float) -> float:
"""Map a pseudo-midi value to an LFSR clock rate (Hz)."""
octv = max(0.0, min(9.0, midi/12.0 - 1.0))
return 180.0 * (2.0 ** octv)
def apply_swing(start_whole: Fraction, dur_whole: Fraction, swing: float) -> tuple:
"""Piecewise-linear time warp: within each beat, the first half is
stretched to `swing` of the beat, the second compressed to `1-swing`.
NOTE: this warps ALL onsets, including 16ths — if you write 16ths
under @swing they will be unevenly spaced. Use swing for 8th-pair
feel only; for straight syncopation, write rests on a straight grid.
validate_pfm warns on @swing + sub-8th values."""
if abs(swing - 0.5) < 1e-9:
return float(start_whole), float(dur_whole)
eighth = Fraction(1,8)
def warp(t: Fraction) -> float:
q = t // (2*eighth) # which beat (quarter)
r = t - q*(2*eighth) # 0..1/4
base = float(q)*0.25
rf = float(r)/0.25 # 0..1 within the beat
# piecewise-linear: first half stretched to `swing`, second to 1-swing
if rf <= 0.5:
return base + (rf/0.5)*swing*0.25
else:
return base + (swing + ((rf-0.5)/0.5)*(1.0-swing))*0.25
s = warp(start_whole)
e = warp(start_whole + dur_whole)
return s, max(1e-5, e - s)
# ------------------------------------------------------------ rendering
def render_voice(song: Song, v: Voice, total_whole: Fraction,
rate: int, loops: int) -> array:
wns = whole_note_seconds(song)
n_samples = int(round(float(total_whole) * wns * rate * loops))
buf = array('f', bytes(4*n_samples))
events = flatten_voice(v)
starts = []
t = Fraction(0)
for n in events:
starts.append(t); t += n.dur
duty = (v.duty/100.0) if v.wave=='pulse' else 0.5
lfsr = LFSR(); noise_val = 0.0; noise_acc = 0.0
vib_depth = (2.0 ** (VIB_DEPTH_CENTS/1200.0)) - 1.0 # fractional freq dev
for loop_i in range(loops):
loop_off = float(Fraction(loop_i) * total_whole)
for n, st in zip(events, starts):
if n.midi is None:
continue
a,d,s,r = (n.adsr or v.adsr)
# swing-warp onset/duration
sw_start, sw_dur = apply_swing(st, n.dur, song.swing)
start_sec = (loop_off + sw_start) * wns
dur_sec = sw_dur * wns
rel = min(r, 0.5)
end_sec = start_sec + dur_sec + rel
i0 = int(start_sec * rate)
i1 = min(int(end_sec * rate), n_samples)
if i0 >= n_samples: continue
gate = dur_sec
nv = v.vol * n.vol
phase = 0.0
is_noise = (v.wave == 'noise')
# precompute per-note params
arp = n.arp
arp_period = 1.0/ARP_RATE_HZ
bend_from = float(n.midi)
bend_to = float(n.bend_to) if n.bend_to is not None else bend_from
drum_drop = DRUM_KIT[n.drum]['drop'] if n.drum else 0
for i in range(i0, i1):
ts = (i - i0) / rate
# ---- ADSR
if ts < a:
env = ts/a if a>0 else 1.0
elif ts < a + d:
env = 1.0 - (1.0 - s) * ((ts-a)/d if d>0 else 1.0)
elif ts < gate:
env = s
else:
rs = ts - gate
env = s * (1.0 - rs/rel) if rel>0 else 0.0
if env < 0: env = 0.0
# ---- instantaneous pitch (midi)
if arp:
idx = int(ts / arp_period) % len(arp)
cur_m = float(arp[idx])
elif n.bend_to is not None:
frac = min(1.0, ts/gate) if gate>0 else 1.0
cur_m = bend_from + (bend_to - bend_from)*frac
else:
cur_m = bend_from
if n.drum and drum_drop:
# fast pitch drop over ~40ms
dd = max(0.0, 1.0 - ts/0.04)
cur_m = bend_from - drum_drop*(1.0-dd*0) # start high, drop
cur_m = bend_from*dd + (bend_from-drum_drop)*(1.0-dd)
if n.vibrato and ts > a+d:
cur_m += (VIB_DEPTH_CENTS/100.0)*math.sin(TAU*VIB_RATE_HZ*ts)
# ---- oscillator
if is_noise:
nclock = noise_clock(cur_m)
noise_acc += nclock/rate
while noise_acc >= 1.0:
noise_val = lfsr.step(); noise_acc -= 1.0
smp = noise_val
else:
hz = midi_to_hz(cur_m)
if v.wave == 'pulse':
smp = osc_pulse(phase, duty)
elif v.wave == 'triangle':
smp = osc_triangle(phase)
elif v.wave == 'saw':
smp = osc_saw(phase)
else:
smp = math.sin(TAU*phase)
phase += hz/rate
buf[i] += smp * env * nv
return buf
def mix_and_write(song: Song, out_path: str, rate: int, loops: int,
verbose: bool = True):
"""Sum voices linearly, normalize to @master headroom (default −3 dBFS).
No tanh/soft-clip unless @master limiter=soft|hard. Warns to stderr if
the raw float sum exceeded 1.0 (meaning voice vols are too hot and the
mix balance depends on post-normalization, not on the written vols)."""
total = Fraction(0)
for v in song.voices.values():
t = sum((n.dur for bar in v.bars for n in bar), Fraction(0))
if t > total: total = t
if total == 0:
raise SystemExit("no notes to render")
wns = whole_note_seconds(song)
n_samples = int(round(float(total) * wns * rate * loops))
mix = array('f', bytes(4*n_samples))
for v in song.voices.values():
vb = render_voice(song, v, total, rate, loops)
for i in range(n_samples):
mix[i] += vb[i]
# ── meter raw sum
raw_peak = 0.0; raw_ss = 0.0
for x in mix:
ax=abs(x); raw_ss += x*x
if ax>raw_peak: raw_peak=ax
raw_rms=(raw_ss/n_samples)**0.5
# ── optional limiter
limiter = song.master.get('limiter','none')
if limiter == 'soft':
for i in range(n_samples): mix[i]=math.tanh(mix[i])
elif limiter == 'hard':
for i in range(n_samples):
x=mix[i]; mix[i]=1.0 if x>1.0 else (-1.0 if x<-1.0 else x)
# post-limiter peak
pk=0.0
for x in mix:
ax=abs(x)
if ax>pk: pk=ax
headroom_db = song.master.get('headroom_db', -3.0)
target = 10**(headroom_db/20.0)
pregain = song.master.get('gain', 1.0)
gain = pregain * ((target/pk) if pk>1e-9 else 1.0)
pcm = array('h', [0]*n_samples)
for i in range(n_samples):
s=int(mix[i]*gain*32767.0)
if s>32767:s=32767
if s<-32768:s=-32768
pcm[i]=s
with wave.open(out_path,'wb') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(pcm.tobytes())
if verbose:
warn = raw_peak > 1.0 and limiter=='none'
print(f" meter: raw peak={raw_peak:.3f} "
f"({20*math.log10(raw_peak) if raw_peak>0 else -120:+.1f} dB) "
f"rms={raw_rms:.3f} limiter={limiter} "
f"→ norm to {headroom_db:+.1f} dBFS (gain={gain:.3f})",
file=sys.stderr)
if warn:
print(f" ⚠ raw mix peaked at {raw_peak:.2f} (>1.0) with no "
f"limiter — voice vols too hot; mix balance is being "
f"rescued by normalization, not by your levels.",
file=sys.stderr)
return n_samples, n_samples/rate, dict(raw_peak=raw_peak, raw_rms=raw_rms,
limiter=limiter, gain=gain)
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Render PFM to WAV (v2)")
ap.add_argument('file')
ap.add_argument('-o','--out', required=True)
ap.add_argument('--loops', type=int, default=1)
ap.add_argument('--rate', type=int, default=22050)
args = ap.parse_args(argv)
with open(args.file) as f:
song = parse_pfm(f.read())
if song.errors:
for e in song.errors: print(f"parse: {e}", file=sys.stderr)
n,dur,_m = mix_and_write(song, args.out, args.rate, args.loops)
sz = os.path.getsize(args.out)
print(f"wrote {args.out}: {n} samples, {dur:.2f}s, {sz} bytes "
f"({sz/1024:.1f} KiB) @ {args.rate}Hz mono s16")
return 0
if __name__=='__main__':
sys.exit(main())
@title Sample Ambient v2
# Puzzle-calm loop. v2.1: bass rewritten to breathe (rests every bar, walks
# between chords), voice vols rebalanced so raw mix stays under 0 dBFS.
# Harm arp-ostinato still carries the 8th-pulse; bass now anchors & moves.
@tempo 104
@timesig 4/4
@key A pent_minor
@loop
@style standard
@master headroom=-3 limiter=none
voice lead wave=pulse duty=25 adsr=0.004,0.07,0.55,0.06 vol=0.38 range=E4-A6 role=lead
voice harm wave=pulse duty=12.5 adsr=0.003,0.05,0.40,0.04 vol=0.22 range=G3-A6 role=harmony
voice bass wave=triangle adsr=0.003,0.02,0.90,0.03 vol=0.42 range=E1-A4 role=bass
voice drums wave=drums vol=0.22
# ══ HARM: arp-macro chord ostinato, 8ths — unchanged. This IS the pulse.
# TECHNIQUE: arp-macro ostinato → metroid-brinstar bars 1-12.
# Progression: Am ×4 → C ×4 → G ×4 → Am ×4
harm: [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 |
harm: [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 |
harm: [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 |
harm: [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 |
harm: [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 |
harm: [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 |
harm: [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 [C4 G4 C5]:8 |
harm: [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 |
harm: [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 |
harm: [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 |
harm: [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 [G3 D4 G4]:8 |
harm: [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [G3 D4 A4]:8 [E4 A4 C5]:8 [E4 A4 C5]:8 [E4 A4 C5]:8 [E4 A4 C5]:8 |
harm: [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 |
harm: [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 |
harm: [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 |
harm: [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 C5]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 |
# ══ BASS: anchor & walk. Root on 1, gap on 2, move on 3/4, rest → breathe.
# TECHNIQUE: anchored-walk bass with rests → smb-overworld (44% rest, runs
# ≤5 notes). Every bar contains at least one 8th or quarter rest; the last
# bar of each 4-bar phrase walks stepwise into the next chord root.
# ── Am (bars 1-4)
bass: A2:4 R:8 E2:8 R:8 A2:8 R:8 C3:8 | A2:4 R:8 E2:8 A2:8 R:8 G2:8 R:8 |
bass: A2:8 R:8 E2:8 R:8 A2:4 R:8 C3:8 | A2:4 R:8 G2:8 E2:8 D2:8 C2:8 R:8 |
# ── C (bars 5-8)
bass: C3:4 R:8 G2:8 R:8 C3:8 R:8 E3:8 | C3:4 R:8 G2:8 C3:8 R:8 D3:8 R:8 |
bass: C3:8 R:8 G2:8 R:8 E3:4 R:8 C3:8 | C3:4 R:8 C3:8 A2:8 G2:8 A2:8 R:8 |
# ── G (bars 9-12)
bass: G2:4 R:8 D2:8 R:8 G2:8 R:8 A2:8 | G2:4 R:8 D2:8 G2:8 R:8 E2:8 R:8 |
bass: G2:8 R:8 D2:8 R:8 G2:4 R:8 A2:8 | G2:4 R:8 D2:8 E2:8 G2:8 A2:8 R:8 |
# ── Am' (bars 13-16) — settle for loop seam
bass: A2:4 R:8 E2:8 R:8 A2:8 R:8 C3:8 | A2:4 R:8 E2:8 A2:8 R:8 G2:8 R:8 |
bass: A2:8 R:8 E2:8 R:8 A2:4 R:8 E2:8 | A2:4 R:8 E2:8 R:4 A2:8 R:8 |
# ══ LEAD: unchanged — pushed-8th pentatonic phrases.
lead: A4:8 C5:8 E5:8 R:8 R:8 D5:8 C5:8 A4:8 | R:8 E5:8 R:8 C5:8 D5:8 C5:8 A4:8 G4:8~ |
lead: -:8 A4:8 C5:8 E5:8 R:8 G5:8 E5:8 D5:8 | C5:8 R:8 A4:8 R:8 G4:8 A4:8 C5:8 E5:8~ |
lead: -:8 G5:8 E5:8 R:8 R:8 C5:8 D5:8 E5:8 | R:8 G5:8 R:8 E5:8 G5:8 A5:8 G5:8 E5:8~ |
lead: -:8 D5:8 C5:8 R:8 E5:8 D5:8 C5:8 G4:8 | R:8 C5:8 E5:8 G5:8 R:8 E5:8 D5:8 D5:8~ |
lead: -:8 G5:8 D5:8 R:8 R:8 A4:8 C5:8 D5:8 | R:8 G5:8 A5:8 G5:8 R:8 D5:8 E5:8 D5:8~ |
lead: -:8 C5:8 D5:8 G5:8 R:8 D5:8 C5:8 A4:8 | G4:8 R:8 A4:8 C5:8 R:8 D5:8 E5:8 E5:8~ |
lead: -:8 A4:8 C5:8 E5:8 R:8 D5:8 C5:8 A4:8 | R:8 E5:8 R:8 C5:8 D5:8 C5:8 A4:8 G4:8~ |
lead: -:8 A4:8 C5:8 E5:8 D5:8 C5:8 A4:8 R:8 | E4:8 G4:8 A4:8 R:8 R:8 C5:8 A4:8 A4:8 |
# ══ DRUMS: unchanged — K+H hat-pulse, no snare.
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 K:8 H:8 O:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 | K:8 H:8 K:8 H:8 K:8 H:8 K:8 O:8 |
@title Sample Complete
# Victory sting — 1 bar, under 2s. Same iv→V→I Picardy resolution
# compressed: 4-8th ascending run (A4→E5→G#5 leading tone) then
# resolve to A5 held over C#5 Picardy third.
@tempo 180
@timesig 4/4
@key A pent_minor
@master headroom=-1 limiter=none
voice p1 wave=pulse duty=50 adsr=0.001,0.03,0.65,0.12 vol=0.38 range=A3-B6 role=lead
voice p2 wave=pulse duty=25 adsr=0.001,0.04,0.55,0.10 vol=0.26 range=D4-E6 role=harmony
voice tri wave=triangle adsr=0.001,0.02,0.90,0.18 vol=0.38 range=A1-A4 role=bass
voice drums wave=drums vol=0.20
# Beats 1-2: ascending run over iv→V. Beat 2.5: G#5 (leading tone).
# Beats 3-4: resolve — A5 held, C#5 Picardy in harmony, A2 root in bass.
p1: R:8 A4:8 E5:8 G#5:8 A5:2 |
p2: D4:8 F4:8 B4:8 G#4:8 C#5:2 |
tri: D3:4 E3:4 A2:2 |
drums: R:8 K:8 S:4 K:4 C:4 |
@title Sample Fail
@tempo 140
@timesig 2/4
@key A chromatic
@master headroom=-1 limiter=none
voice p1 wave=pulse duty=50 adsr=0.002,0.05,0.40,0.08 vol=0.45 range=A3-A6
# A4 -> G#4 (m2 down) -> Eb4 (tritone below A). Total = 2/4.
p1: A4:16 G#4:16 Eb4:8 R:4 |
@title Sample SFX (catalogue)
@tempo 160
@timesig 1/4
@key A pent_minor
voice tap wave=pulse duty=25 adsr=0.001,0.04,0.00,0.02 vol=0.70 range=A3-A7
voice connect wave=pulse duty=50 adsr=0.002,0.08,0.30,0.05 vol=0.80 range=A3-A7
voice undo wave=triangle adsr=0.002,0.06,0.20,0.04 vol=0.80 range=A2-A6
voice star wave=pulse duty=12.5 adsr=0.002,0.05,0.50,0.10 vol=0.80 range=A4-A7
# tap: very short high click on E6 (5th above tonic, bright)
# connect: rising A5->C6 sixteenth pair (affirming minor 3rd up)
# undo: falling C5->A4 (minor 3rd down, gentle triangle)
# star: shimmering A6->E7 arpeggio (octave+5th, thin 12.5% pulse)
# unlock: re-uses star voice but deeper: E5->A5->C6 (door-open chime)
tap: E6:16 R:16 R:8 |
connect: A5:16 C6:16 R:8 |
undo: C5:16 A4:16 R:8 |
star: A6:32 C7:32 E7:32 A6:32 R:8 |
star: E5:16 A5:16 C6:8 |
name pfm-chiptune-composer
description Compose, validate, render, and analyze chiptune music using the PFM (Programmable Fun Music) format — a plain-text, line-oriented score notation targeting NES-2A03-style 4-voice synthesis (2 pulse + 1 triangle + 1 noise). Use when the user wants to create game audio, write chip music, design SFX, analyze compositions, or work with the PFM toolchain. Covers composition, validation, rendering to WAV, metering/headroom analysis, and corpus-based quality comparison. Examples: "write a victory jingle", "compose ambient music", "create chiptune SFX", "analyze this PFM file", "fix the mix levels", "render to WAV".

PFM Chiptune Composer

Compose, validate, render, and analyze chiptune music in the PFM format.

Inputs

  • $goal: What to compose or analyze (e.g., "ambient loop", "victory sting", "SFX set")
  • $key: Musical key and mode (e.g., "A pent_minor", "C major", "E phrygian")
  • $tempo: BPM (integer)
  • $style: One of standard, ambient, drone, energetic

Architecture

The PFM toolchain is a pure-Python-stdlib pipeline:

.pfm source → validate_pfm.py (parse + check) → render_pfm.py (synthesize) → .wav
                                                    ↓
                                            meter_pfm.py (headroom)
                                            analyze_pfm.py (musical metrics)

All tools live in a single directory and import the parser from validate_pfm.py.

Goal

Create musically sound chiptune compositions that pass validation, render cleanly without limiter saturation, and sit within the quality envelope established by 8 NES/GB reference transcriptions (the "corpus").

Steps

1. Understand the PFM Format

PFM is a plain-text, line-oriented, human-diffable score format. Key concepts:

File structure:

# comments start with '#'
@title My Song
@tempo 120
@timesig 4/4
@key C major
@loop
@style standard
@master headroom=-3 limiter=none

voice lead  wave=pulse duty=25 adsr=0.004,0.07,0.55,0.06 vol=0.38 range=E4-A6 role=lead
voice bass  wave=triangle       adsr=0.003,0.02,0.90,0.03 vol=0.42 range=E1-A4 role=bass
voice drums wave=drums vol=0.22

lead: C5:8 E5:8 G5:8 R:8  R:8 D5:8 C5:8 A4:8 |
bass: C2:4    R:8 G2:8   R:8 C3:8 R:8 E3:8   |
drums: K:8 H:8 S:8 H:8 K:8 H:8 S:8 H:8      |

Directives: @title, @tempo (required), @timesig (required), @key (required), @loop, @style, @swing <0.5-0.75>, @master headroom=<dB> limiter=none|soft|hard

Voice declaration: voice <name> wave=<pulse|triangle|saw|noise|drums> [duty=<12.5|25|50>] [adsr=<a,d,s,r>] [vol=<0-1>] [range=<lo-hi>] [role=<lead|bass|harmony|drums>]

Events (inside bars delimited by |):

  • Notes: C4:8 (C4 eighth note), A5:4. (dotted quarter), E4:8t (triplet eighth)
  • Rests: R:4 (quarter rest)
  • Holds: -:4 (continue previous note without retrigger)
  • Ties: C4:8~ (tie into next event)
  • Volume: C4:8!0.3 (per-note volume)
  • ADSR override: {0.001,0.04,0,0.02} before a note
  • Arpeggios: [C4 E4 G4]:4 (cycle pitches at ~45Hz)
  • Vibrato: C4~v:2 (±25 cents @ 6Hz during sustain)
  • Pitch bend: C4>E4:4 (linear glide)
  • Drum hits: K:8 S:8 H:16 O:8 C:4 (Kick/Snare/Hat/Open-hat/Crash)
  • Echo voice: voice echo wave=pulse follows=lead delay=3/16 (auto-copies with delay)

Duration notation: Integer N means 1/N of whole note. 4=quarter, 8=eighth, 16=sixteenth. Dot . = ×1.5. Double-dot .. = ×1.75. Triplet t = ×⅔. Durations use exact fractions internally.

Bar validation: Sum of all events in each | ... | must equal the time signature (e.g., 4/4 = 1.0 whole notes). This is checked with exact Fraction arithmetic.

Success criteria: You can write valid PFM from memory. Every bar sums correctly.

2. Follow Composition Principles

Core rules derived from analyzing 8 NES/GB reference transcriptions:

  1. Ambient ≠ empty. Even calm pieces need 8+ events/sec (use arp ostinato + hat pulse).
  2. Bass must move. Use octave-pump, oom-pah, walking, or anchored-walk patterns. Never whole-note pedals.
  3. Bass must breathe. Include rests (target 25-45% rest). Max continuous run ≤2 bars.
  4. Drums are non-negotiable. Every looped piece needs a noise voice. For calm: K+H only (no snare).
  5. Syncopation floor ~0.25. Put at least 25% of onsets off the beat.
  6. Push, don't drag. Anticipate downbeats by an 8th/16th and tie over.
  7. One channel can imply a triad. Arp macros [C E G]:8 or echo voices free up channels.
  8. Harmony: 4-7 changes per 8 bars. Static harmony >4 bars reads as broken.
  9. Rests are events. Use silence deliberately — don't fill every 8th in every voice.
  10. Vary bar 8 and bar 16. Mark phrase boundaries with fills, open-hat, or crashes.
  11. Headroom is compositional. Keep voice vols so raw Σ peak stays under ~1.5 with no limiter.
  12. Loop seam: last→first note ≤ P5 in every voice. End on tonic. No leading tone.

Success criteria: Composition follows these principles. No mechanical/static passages.

3. Use Proven Techniques

13 documented techniques with corpus citations:

# Technique When to Use Template
1 Octave-pump bass Need relentless pulse A2:8 A3:8 A2:8 A3:8...
2 Oom-pah root-fifth Bass needs movement + breathing room C2:8 R:8 G2:8 R:8...
3 Pushed anticipation Lead sounds mechanical Attack note 8th/16th early, tie over
4 Silence-as-rhythm Need tension/menace Staccato stabs then empty bars
5 Dotted-8th echo Need shimmer from one melody follows=lead delay=3/16
6 Dual-pulse 3rds Need harmony cheaply Pulse2 shadows pulse1 a 3rd below
7 Arp-macro ostinato Need pad + pulse from one channel [A3 E4 A4]:8 ×8 per bar
8 Triangle-as-melody Need a second melody Give tri stepwise lines, role=lead
9 Chromatic approach bass Need funk/groove Half-step approach to each root
10 Call-and-response Two melodies, zero clutter Lead bars 1,3,5; harm bars 2,4,6
11 Four drum grooves Need rhythm (a) backbeat (b) hat-pulse (c) gallop (d) 16th-hat
12 Triplet fanfare Need cadence punch :8t :8t :8t burst
13 Anchored-walk bass Need bass movement + breathing Root on 1, rest on 2, walk into next bar

Success criteria: At least 2-3 techniques are applied deliberately. Choices are documented in comments.

4. Handle SFX Design

SFX rules (for game audio sets):

  • All SFX except fail must be 100% in-key with the ambient's @key
  • SFX use a pitch ladder tied to scale degrees (see reference)
  • tap: 5̂, octave+2, ≤80ms, barely-there tick
  • connect: 1̂→♭3̂ rising, octave+1, ≤200ms, small affirmation
  • undo: ♭3̂→1̂ falling (inverse of connect), ≤200ms
  • complete: 1̂-3̂-5̂-1̂ arpeggio, 2 bars, the only SFX with a melody
  • fail: Uses @key X chromatic, m2 or tritone descent, ≤500ms
  • star: 1̂-3̂-5̂-1̂ at octave+2-3, thinnest duty (12.5%), sparkle
  • unlock: 5̂→1̂→3̂, ends on 3rd (warmer), ≤500ms

Success criteria: SFX pass validation in their game's key. Durations stay within budget.

5. Validate

Run python3 validate_pfm.py <file.pfm> and fix all errors. Warnings are advisory.

Common errors:

  • Bar duration mismatch (most common — count your durations!)
  • Notes outside declared range
  • Loop seam interval >P5
  • Missing required directives

Musicality warnings (fix if possible):

  • [density]: Voice too sparse. Add arp ostinato or hat pulse.
  • [percussion]: No drums on a looped piece. Add a noise voice.
  • [static-harmony]: Same implied chord too many bars. Change arp shapes.
  • [stagnation]: Lead repeating same pitch >3×. Add melodic variety.
  • [pedal-bass]: Bass stuck on one pitch-class. Make it move/walk.
  • [bass-breathe]: Bass has 0% rest. Add gaps.
  • [swing-grid]: @swing + sub-8th values conflict. Pick one approach.
  • [headroom]: Sum of voice vols exceeds style budget. Lower vols.

Success criteria: Zero errors. Warnings understood and addressed (or justified).

6. Render and Meter

python3 render_pfm.py song.pfm -o song.wav [--loops N] [--rate 22050]
python3 meter_pfm.py song.pfm    # headroom analysis

Meter targets:

  • Raw peak ≤ 1.5 (with limiter=none)
  • Saturation < 5%
  • No single voice > 55% energy share
  • Crest factor > 3 dB

If meter fails: Lower voice vol values. Don't reach for the limiter — fix levels at source.

Success criteria: WAV renders cleanly. Meter reports no warnings. No limiter needed.

7. Analyze Against Corpus

python3 analyze_pfm.py song.pfm                          # single-file analysis
python3 analyze_pfm.py --corpus corpus/ --compare song.pfm  # compare to envelope

Corpus envelope (from 8 NES/GB references):

  • Density: 4.58–33.29 ev/s (median 14.4)
  • Syncopation: 0.25–0.74
  • Harmonic changes/8 bars: 1.3–7.2
  • 8/8 have drums, 6/8 have backbeat

Success criteria: Composition metrics fall within or near the corpus envelope. Outliers are intentional and documented.

Resolution Techniques (for stings/cadences)

For victory stings and resolution moments, use these cadence patterns:

Cadence Feeling Use For
V→I Triumphant, definitive Victory, boss defeat
IV→I (plagal) Warm, benediction Level complete, save point
♭VII→I Heroic, folk, modal Quest complete
♭VI→♭VII→I Anthemic, ascending Final boss, credits
V→vi (deceptive) Delayed yearning Mid-level "not yet"
i→I (Picardy) Transformation, hope Story twist

Melodic landing: 1̂ = triumphant, 3̂ = bittersweet, 5̂ = hopeful/open.

Voice Volume Guidelines

For a 4-voice mix targeting @master headroom=-3 limiter=none:

Role Typical vol Notes
Lead (pulse) 0.35–0.45 Melody sits on top but doesn't dominate
Harmony (pulse) 0.20–0.30 Bed, not feature
Bass (triangle) 0.35–0.45 Triangle has fewer harmonics, needs volume
Drums (noise) 0.18–0.25 Just enough to feel pulse

Sum of vols should be ~1.1–1.4 for clean headroom. If raw peak >1.5, lower vols.

Common Pitfalls

  1. Bar arithmetic errors. Always verify: 8th=0.125, quarter=0.25, dotted quarter=0.375, half=0.5, triplet 8th=1/12≈0.0833. Four 8th triplets ≠ a half note.
  2. Forgetting rests in bass. The #1 mixer complaint. Add R:8 gaps.
  3. Echo voice with score lines. An echo voice (follows=) must have NO score lines.
  4. Swing + 16ths. @swing warps ALL onsets. Don't use it with 16th-note syncopation.
  5. Leading tone at loop seam. Don't end on 7̂ — it demands resolution and makes the loop sound like it "resets" instead of cycling.
  6. All voices same rhythm. Stagger entries, use call-and-response, vary note lengths.
  7. Drum fill on bar 8/16 forgotten. Without it, the loop seam is inaudible.

Composer's Handbook — PFM Techniques from the Corpus

Each technique: name · corpus citation (file:bar) · why it works · PFM template snippet you can paste and transpose.


1. Octave-Pump Bass

Cite: tetris-a.pfm:1-8 (bass voice)

Why: Eight unbroken root-octave 8ths per bar nail the pulse so hard that the lead is free to hold long notes or rest. The octave leap adds brightness without changing harmony — one pitch-class, two registers.

voice bass wave=triangle adsr=0.002,0.02,0.9,0.04 vol=0.85 range=E1-E4 role=bass
# root-octave pump, change root on chord change
bass: A2:8 A3:8 A2:8 A3:8 A2:8 A3:8 A2:8 A3:8 | E2:8 E3:8 E2:8 E3:8 E2:8 E3:8 E2:8 E3:8 |

2. Oom-Pah Root-Fifth Bass

Cite: smb-overworld.pfm:3-8 (bass voice)

Why: Root on the beat, fifth (or octave) on the off-8th, rests in between. The rests let the bass breathe and the off-beat fifth implies the chord without a harmony voice. With swing it becomes a Latin tumbao.

voice bass wave=triangle adsr=0.002,0.02,0.9,0.04 vol=0.85 range=G1-G4 role=bass
# C chord: root-rest-fifth-rest ×2
bass: C2:8 R:8 G2:8 R:8  C2:8 R:8 G2:8 R:8 | F2:8 R:8 C3:8 R:8  F2:8 R:8 C3:8 R:8 |

3. Syncopated Anticipation / Pushed 16th (Kondo Signature)

Cite: smb-overworld.pfm:1 (lead), drmario-fever.pfm:1-4 (lead)

Why: Land the melody note a 16th (or 8th) before the expected downbeat and tie it over. The ear hears the resolution early → perpetual forward lean without tempo change. This is THE thing that makes Kondo's lines bounce.

# pushed-8th: target note arrives on the "&" of 4 and ties into beat 1
lead: R:8 A5:8 C6:8 A5:8  R:8 E5:8 G5:8 E5:8~ | -:8 A5:8 C6:8 A5:8  E6:8 D6:8 C6:8 A5:8 |
# pushed-16th (Kondo): melody attacks land a 16th before the grid
lead: E5:16 E5:16 R:16 E5:16  R:16 C5:16 E5:16 R:16  G5:4  R:4 |

4. Silence-as-Rhythm

Cite: smb-underground.pfm:1-4 (all voices)

Why: Six staccato stabs (ADSR sustain=0) then 2½ beats of nothing. No pad, no hat. The rest is the hook — the listener's brain supplies the missing pulse, which is more engaging than hearing it. Use for menace/tension.

voice lead wave=pulse duty=50 adsr=0.001,0.06,0.0,0.02 vol=0.6 role=lead
# 6 stabs in 1.5 beats, then vacuum. Sustain=0 → pure plink.
lead: C4:8 C5:8 A3:8 A4:8 Bb3:8 Bb4:8 R:4 | R:1 |
bass: C2:8 R:8 R:4 R:2                    | R:1 |

5. Dotted-8th Echo / "Capcom Shimmer"

Cite: megaman2-wily1.pfm (echo voice decl, bars 1-10)

Why: A second pulse auto-copies the lead delayed by 3/16. The offset never lines up with the 8th grid → a rolling, galloping canon that thickens a single melodic line into implied two-part harmony. Zero extra writing.

voice lead wave=pulse duty=50   adsr=0.002,0.04,0.6,0.03 vol=0.55 role=lead
voice echo wave=pulse duty=25   adsr=0.002,0.04,0.5,0.03 vol=0.30 follows=lead delay=3/16 role=harmony
# echo voice has NO score lines — renderer copies lead with 3/16 offset
lead: A5:8. G#5:16 F#5:8 E5:8  F#5:8 G#5:8 E5:4 |

6. Dual-Pulse 3rds Harmony

Cite: megaman2-wily1.pfm:3-6, kirby-greengreens.pfm:1-8, smb-overworld.pfm:3-6 (harm voice)

Why: Pulse2 shadows pulse1 a diatonic 3rd (or 6th) below with identical rhythm. Two monophonic channels imply full triads — "two-finger harmony." Analyzer shows smb-overworld lead↔harm at 100% parallel motion.

voice lead wave=pulse duty=50 vol=0.60 role=lead
voice harm wave=pulse duty=25 vol=0.40 role=harmony
lead: E5:8 G5:8 A5:8 B5:8  C6:4     B5:4     |
harm: C5:8 E5:8 F5:8 G5:8  A5:4     G5:4     |

7. Arp-Macro Chord Ostinato

Cite: metroid-brinstar.pfm:1-12 (arp voice), zelda-overworld.pfm:1-8 (harm)

Why: [root 5th oct]:8 repeated 8× per bar turns one channel into a pad and a pulse. Density stays high (8 ev/bar) while dynamics stay soft — the key to "atmospheric but driven." Shift the chord shape every 2-4 bars.

voice arp wave=pulse duty=12.5 adsr=0.002,0.04,0.4,0.03 vol=0.35 role=harmony
arp: [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 [A3 E4 A4]:8 |
arp: [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 [C4 G4 E5]:8 |

8. Triangle-as-Melody (Role Inversion)

Cite: zelda-overworld.pfm:1-8 (tri voice), metroid-brinstar.pfm:1-12 (tri)

Why: The triangle's near-sine timbre reads as "warm voice" in octave 3-4. Give it a singable stepwise line (with ~v vibrato for expression) and let a pulse handle the bass role. You get a true second melody for free.

voice tri wave=triangle adsr=0.005,0.03,0.95,0.08 vol=0.9 range=E2-E5 role=lead
# long notes, vibrato on held tones, stepwise contour
tri: R:2            E3:4   G3:4   | B3~v:2.             A3:4   |
tri: G3:2           E3:4   G3:4   | A3~v:1                     |

9. Chromatic Approach-Tone Bass

Cite: drmario-fever.pfm:1-8 (bass voice)

Why: Approach each target root from a half-step below on the preceding 8th: …G#2:8 A2:8…, …C#3:8 D3:8…. The out-of-key note resolves immediately so it reads as funk tension, not wrong-note. Validator treats short stepwise chromatics as passing tones.

voice bass wave=triangle adsr=0.002,0.02,0.9,0.04 vol=0.9 range=D2-E4 role=bass
# Am: target A2/D3/E3 approached from G#2/C#3/D#3
bass: A2:8 A2:8 E2:8 G2:8  G#2:8 A2:8 C3:8 A2:8 | A2:8 A2:8 E2:8 G2:8  G#2:8 A2:8 C3:8 C#3:8 |

10. Call-and-Response

Cite: kirby-greengreens.pfm:1-8 (p1 ↔ p2)

Why: Lead plays bars 1,3,5,7 and rests in 2,4,6,8; harmony answers in the gaps. You get double the melodic material with zero vertical clutter, and the built-in rests give the lead breathing room.

# p1 calls (bar 1), rests (bar 2); p2 holds under the call, answers in the rest
p1: F5:8. G5:16 A5:8. F5:16 C6:8 C6:8 A5:8 F5:8 | G5:4      R:4     R:2                  |
p2: A4:4        C5:4        F4:4       A4:4     | R:4  C5:8. D5:16 E5:8 D5:8 C5:8 Bb4:8  |

11. The Four Drum Grooves

All on voice drums wave=drums. One bar each, 4/4.

(a) Straight-8 backbeattetris-a.pfm:1-8, kirby-greengreens.pfm:1-8 K on 1&3, S on 2&4, hats fill. The default rock beat.

drums: K:8 H:8 S:8 H:8 K:8 H:8 S:8 H:8 |

(b) Half-time / hat-pulse (no snare)metroid-brinstar.pfm:1-12 K on 1 & 3 (or 1 & 2.5), hats on every 8th, NO snare. Heartbeat, not backbeat — use for puzzle-calm / ambient.

drums: K:8 H:8 H:8 H:8 K:8 H:8 H:8 H:8 |
# or syncopated kick (1 and "& of 2"):
drums: K:8 H:8 H:8 K:8 H:8 H:8 H:8 H:8 |

(c) Gallop / marchzelda-overworld.pfm:1-8 Dotted-8th+16th snare cell, 16th rolls into the next bar.

drums: K:8 H:8 S:8. S:16  K:8 H:8 S:16 S:16 S:16 S:16 |

(d) 16th-hat drivemegaman2-wily1.pfm:1-10 Constant 16th hats, K on 1 / 3 / 3.5, S on 2&4. Maximum energy.

drums: K:16 H:16 H:16 H:16 S:16 H:16 H:16 H:16 K:16 H:16 K:16 H:16 S:16 H:16 H:16 H:16 |

12. Triplet Fanfare

Cite: zelda-overworld.pfm:1-2,5-6 (lead)

Why: Three :8t triplet 8ths fill one beat — a sudden 3-against-4 burst that reads as brass fanfare. Use at phrase ends to punctuate a cadence or launch an ascending run into the next section.

# dotted gallop → triplet launch → held arrival
lead: Bb4:4     F4:8. F4:16  Bb4:8. Bb4:16 C5:16 D5:16 Eb5:16 F5:16 | F5:2     F5:8. Gb5:16  Ab5:8t Ab5:8t Bb5:8t |

Quick-Reference: Which technique for which problem?

Problem Technique
Piece feels empty but must stay calm #7 arp ostinato + #11b hat-pulse
Lead sounds mechanical / on-grid #3 pushed anticipation
Only one pulse free for harmony #5 echo OR #6 parallel 3rds OR #7 arp
Bass is a boring pedal #1 octave-pump OR #2 oom-pah OR #9 chromatic approach
Need a second melody #8 triangle-as-melody OR #10 call-and-response
Cadence needs punch #12 triplet fanfare + crash on downbeat
Too dense / cluttered #4 silence-as-rhythm, #10 call-and-response

13. Anchored-walk bass with gaps

What: Root on beat 1 (quarter or half), rest on 2, optional fifth on &-of-2 or 3, rest, approach-tone into the next bar's root on beat 4. Why: The harm/arp voice already supplies 8th-note pulse; doubling it in the bass just thickens mud and drives the mix into the limiter. Gaps let the kick breathe through and make the root land. Corpus: smb-overworld bass (44% rest, ≤5-note runs). PFM:

bass: A2:4    R:8 E2:8   R:8 A2:8 R:8 C3:8 |    # anchor · gap · move
bass: A2:4    R:8 G2:8   E2:8 D2:8 C2:8 R:8 |   # walk down into C

Check: validate_pfm.py [bass-breathe], meter_pfm.py bass energy%.

#!/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())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment