- make_tune: validate_spec() reports every spec problem in one run (missing keys, unknown style, bad qualities, bpm<=0, out-of-range loop/fills, melody longer than chart, unsafe name) — no raw KeyErrors - make_tune: key_sig_for handles accidentals in minor and major keys, any case (Bb minor, F# minor, bb major); real spellings kept (C# major) - make_tune: n64 pad uses QUALITIES table (dim/aug/sus now correct) - make_tune: WARNING on stderr for fields a style can't use (counter for non-DOS, fills_at for n64) instead of silent drops - make_tune: name sanitised ([A-Za-z0-9][A-Za-z0-9._-]*), no traversal - make_tune: render errors actionable (timidity presence check, stderr surfaced, sys.executable instead of bare python3) - songkit: parse_score lints the FINAL bar too (was unchecked); this caught a real 5-beat bar in 01_service_station_2am COUNTER_A (fixed) - build_all.sh: dependency + soundfont checks, removes stale midi/wav outputs with no current source, fails if nothing was produced - smoke_test: runs every spec, checks loop marker tick positions, feeds malformed bars to the linter, covers validation + key sigs - AGENTS.md: documents 0-based bars and exclusive end_bar, validation behaviour; SKILL.md added so the repo loads as an agent skill
148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
"Service Station at 2 AM" — DOS adventure main theme
|
|
Style: early-90s point-and-click / LucasArts-meets-OPL. D minor, 132 BPM.
|
|
Structure: 4-bar intro, A (8), B (8), A' with counter-melody (8), outro (4).
|
|
|
|
The DOS fingerprints:
|
|
- OPL-style 16th-note arpeggios on electric piano
|
|
- driving 8th-note synth bass with chromatic approach notes
|
|
- square lead with pitch-bend vibrato on held notes
|
|
- the G# in bar 4 (harmonic-minor leading tone — instant 1993)
|
|
"""
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
|
|
from midikit import MidiFile, Track, MPQN, verify
|
|
from songkit import (bar, melody, arp, bass_drive, drums, vibrato, crash,
|
|
parse_score, TPQ)
|
|
|
|
BPM = 132
|
|
OUT = os.path.join(os.path.dirname(__file__), "..", "midi",
|
|
"01_service_station_2am.mid")
|
|
|
|
# Chord chart, one (root, quality) per bar, 32 bars
|
|
CHORDS = (
|
|
[("D", "m"), ("Bb", ""), ("G", "m"), ("A", "")] + # intro
|
|
[("D", "m"), ("Bb", ""), ("G", "m"), ("A", ""),
|
|
("D", "m"), ("Bb", ""), ("G", "m"), ("D", "m")] + # A
|
|
[("Bb", ""), ("C", ""), ("D", "m"), ("Bb", ""),
|
|
("G", "m"), ("D", "m"), ("Bb", ""), ("A", "")] + # B
|
|
[("D", "m"), ("Bb", ""), ("G", "m"), ("A", ""),
|
|
("D", "m"), ("Bb", ""), ("G", "m"), ("D", "m")] + # A'
|
|
[("D", "m"), ("Bb", ""), ("A", ""), ("D", "m")] # outro
|
|
)
|
|
|
|
# --- melodies (bar grids) -------------------------------------------------
|
|
MELODY_A = """
|
|
D4-8 F4-8 A4-4 A4-8 Bb4-8 A4-4 | F4-8 A4-8 Bb4-4 A4-8 F4-8 D4-4 |
|
|
G4-8 Bb4-8 D5-8 C5-8 Bb4-4 A4-4 | B4-8 A4-8 G#4-8 A4-8 E4-2 |
|
|
D5-8 C5-8 D5-8 F5-8 E5-4 D5-4 | C5-8 Bb4-8 C5-8 D5-8 Bb4-2 |
|
|
A4-8 Bb4-8 C5-4 E5-4 D5-4 | D5-2 r2
|
|
"""
|
|
MELODY_B = """
|
|
F5-4 F5-8 Eb5-8 F5-4 D5-4 | E5-4 G5-8 F5-8 E5-4 C5-4 |
|
|
D5-8 F5-8 A5-4 G5-8 F5-8 E5-4 | F5-2 D5-4 Bb4-8 C5-8 |
|
|
Bb5-4 A5-8 G5-8 F5-4 D5-4 | E5-4 F5-8 E5-8 D5-4 A4-4 |
|
|
Bb4-8 C5-8 D5-4 E5-8 F5-8 G#5-4 | A5-2 G#5-4 E5-4
|
|
"""
|
|
MELODY_OUTRO = """
|
|
A4-8 D5-8 F5-8 A5-8 D6-2 | C6-4 Bb5-4 F5-4 D5-4 |
|
|
E5-4 D5-4 C#5-4 A4-4 | D5-1
|
|
"""
|
|
PICKUP = "r2 A4-8 Bb4-8 A4-8 G4-8" # intro bar 4 pickup
|
|
# A' bars: octave-ish answer underneath the main melody
|
|
COUNTER_A = """
|
|
r2 D5-8 F5-8 E5-4 | r2 F5-8 D5-8 Bb4-4 |
|
|
r2 G5-8 F5-8 D5-4 | r2 E5-8 G5-8 F5-8 E5-8 |
|
|
r4 A5-8 G5-8 F5-4 A5-4 | r4 F5-8 A5-8 D5-4 F5-4 |
|
|
r2 C6-4 Bb5-4 | r2 F5-4 D5-4
|
|
"""
|
|
|
|
|
|
def build():
|
|
mf = MidiFile(tpq=TPQ)
|
|
mf.time_sig = (4, 2)
|
|
mf.key_sig = (-1, True) # D minor
|
|
mf.tempos = [MPQN.from_bpm(0, BPM)]
|
|
|
|
lead = Track("Lead (square)", channel=0)
|
|
lead.program(0, "square lead")
|
|
lead.cc(0, 7, 104); lead.cc(0, 10, 54) # vol, pan centre
|
|
counter = Track("Counter (EP2)", channel=1)
|
|
counter.program(0, "electric piano 2")
|
|
counter.cc(0, 7, 80); counter.cc(0, 10, 90)
|
|
bass = Track("Bass", channel=2)
|
|
bass.program(0, "synth bass 1")
|
|
bass.cc(0, 7, 92); bass.cc(0, 10, 30)
|
|
acomp = Track("Arp (OPL)", channel=3)
|
|
acomp.program(0, "electric piano 1")
|
|
acomp.cc(0, 7, 66); acomp.cc(0, 10, 20)
|
|
d = Track("Drums", channel=9)
|
|
d.cc(0, 7, 100)
|
|
|
|
# intro: arp + bass only, pickup lead in bar 4
|
|
arp(acomp, 0, CHORDS[0:4], pattern="012321", velocity=64)
|
|
bass_drive(bass, 0, CHORDS[0:4], velocity=80)
|
|
melody(lead, 3, PICKUP)
|
|
drums(d, 3, 1, "shufflesnare", velocity_scale=0.8)
|
|
|
|
# A (bars 4-11)
|
|
arp(acomp, 4, CHORDS[4:12], pattern="012321", velocity=70)
|
|
bass_drive(bass, 4, CHORDS[4:12])
|
|
melody(lead, 4, MELODY_A)
|
|
vibrato(lead, bar(4), bar(12), tempo_bpm=BPM, depth=0.55)
|
|
drums(d, 4, 8, "rock")
|
|
drums(d, 6, 1, "dos_roll", velocity_scale=0.7)
|
|
drums(d, 11, 1, "dos_roll", velocity_scale=0.9)
|
|
crash(d, bar(12))
|
|
|
|
# B (bars 12-19)
|
|
arp(acomp, 12, CHORDS[12:20], pattern="021321", velocity=72)
|
|
bass_drive(bass, 12, CHORDS[12:20])
|
|
melody(lead, 12, MELODY_B)
|
|
vibrato(lead, bar(12), bar(20), tempo_bpm=BPM, depth=0.6)
|
|
drums(d, 12, 8, "rock")
|
|
drums(d, 15, 1, "dos_roll", velocity_scale=0.7)
|
|
drums(d, 19, 1, "break", velocity_scale=0.9)
|
|
crash(d, bar(20))
|
|
|
|
# A' (bars 20-27): same melody + counter-melody, busier hats
|
|
arp(acomp, 20, CHORDS[20:28], pattern="012321", velocity=74)
|
|
bass_drive(bass, 20, CHORDS[20:28])
|
|
melody(lead, 20, MELODY_A)
|
|
vibrato(lead, bar(20), bar(28), tempo_bpm=BPM, depth=0.65)
|
|
melody(counter, 20, COUNTER_A)
|
|
drums(d, 20, 8, "rock")
|
|
drums(d, 23, 1, "dos_roll", velocity_scale=0.7)
|
|
drums(d, 27, 1, "dos_roll")
|
|
crash(d, bar(28))
|
|
|
|
# outro
|
|
arp(acomp, 28, CHORDS[28:32], pattern="0121", velocity=66)
|
|
bass_drive(bass, 28, CHORDS[28:32], subdivision=1, velocity=75)
|
|
melody(lead, 28, MELODY_OUTRO)
|
|
vibrato(lead, bar(28), bar(32), tempo_bpm=BPM, depth=0.5)
|
|
crash(d, bar(28))
|
|
drums(d, 28, 1, "break", velocity_scale=0.8)
|
|
# final sustained chord (Dm) on counter
|
|
from midikit import note as N
|
|
for p in ("D3", "A3", "D4", "F4", "A4", "D5"):
|
|
counter.note(bar(31), N(p), TPQ * 4, 70)
|
|
|
|
mf.add_track(lead); mf.add_track(counter); mf.add_track(bass)
|
|
mf.add_track(acomp); mf.add_track(d)
|
|
# loop points for game engines: intro plays once, A-B-A' loops
|
|
mf.set_loop(bar(4), bar(28))
|
|
return mf
|
|
|
|
|
|
if __name__ == "__main__":
|
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
|
mf = build()
|
|
mf.write(OUT)
|
|
d = verify(OUT, min_sec=40, max_sec=120)
|
|
print("OK:", OUT)
|
|
print(" duration:", d["duration_sec"], "s | notes:",
|
|
sum(len(t["notes"]) for t in d["tracks"]),
|
|
"| tracks:", len(d["tracks"]))
|