- 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
113 lines
5.1 KiB
Markdown
113 lines
5.1 KiB
Markdown
# Notes for AI agents
|
||
|
||
This repo is designed to be driven by agents. Everything is deterministic,
|
||
self-verifying, and dependency-light.
|
||
|
||
## Fastest path: generate a tune from a JSON spec
|
||
|
||
```bash
|
||
python3 tools/make_tune.py tunes/specs/04_tea_break_at_the_terminal.json --render
|
||
```
|
||
|
||
Copy that spec, change `name`, `bpm`, `key`, `chords`, `melody`, `loop`.
|
||
The style preset (`dos_adventure`, `n64_ambient`, `chiptune_racing`) handles
|
||
arrangement: instruments, accompaniment patterns, drums, vibrato, mix levels.
|
||
You supply the music theory: a per-bar chord chart and a melody.
|
||
|
||
## Spec schema
|
||
|
||
```jsonc
|
||
{
|
||
"name": "file_name_no_extension",
|
||
"style": "dos_adventure | n64_ambient | chiptune_racing",
|
||
"bpm": 112,
|
||
"key": ["A", "minor"], // "major" or "minor"
|
||
"chords": [["A","m"], ["F",""], ...], // one per bar; qualities:
|
||
// maj m dim aug 7 maj7 m7 m7b5 6 m6 9 add9 sus2 sus4 sus
|
||
"melody_start_bar": 4,
|
||
"melody": "A4-8 C5-8 E5-4 A5-2 | ...", // see score grammar below
|
||
"loop": {"start_bar": 4, "end_bar": 20}, // optional; writes LOOP_START/END,
|
||
// RPR LOOP START/END, LoopBegin/End markers
|
||
"counter": "...", // optional 2nd line (dos_adventure only)
|
||
"fills_at": [11, 19] // optional drum-fill bars (styles with drums)
|
||
}
|
||
```
|
||
|
||
## Indexing convention (read this once, then never guess)
|
||
|
||
- **Bars are 0-based everywhere**: bar 0 is the first bar of the chord chart.
|
||
- **`loop.end_bar` is EXCLUSIVE**: `{"start_bar": 4, "end_bar": 20}` loops
|
||
bars 4..19 — 16 bars. `start_bar` is inclusive.
|
||
- The melody must fit the chart: `melody_start_bar + (number of '|' bars)
|
||
<= len(chords)`. Bars past the melody (e.g. chords 20–23 above) are an
|
||
outro the engine plays before jumping back to `start_bar`.
|
||
- `fills_at` entries are 0-based bar numbers, `0 <= bar < len(chords)`.
|
||
|
||
## Validation behaviour
|
||
|
||
`make_tune.py` validates the whole spec up front and reports **every**
|
||
problem in one go (missing keys, unknown style, bad chord quality,
|
||
`bpm <= 0`, out-of-range loop/fill bars, melody longer than the chart,
|
||
unsafe `name`) — no raw `KeyError`s, no silently wrong output. Fields a
|
||
style can't use (e.g. `counter` for `n64_ambient`, `fills_at` for styles
|
||
without drums) print `WARNING:` to stderr and are skipped, never dropped
|
||
in silence. `name` must match `[A-Za-z0-9][A-Za-z0-9._-]*` — it becomes
|
||
`midi/<name>.mid`, so no `/`, no `..`.
|
||
|
||
## Score grammar (melody strings)
|
||
|
||
- Tokens: `PITCH-dur-vel` — `A4-8-90` (A4 eighth, vel 90). Durations:
|
||
`1` whole, `2` half, `4` quarter (default), `8` eighth, `16` sixteenth.
|
||
- Rests: `r4`, `r8`, `r16`, `r2`, `r1`.
|
||
- `|` separates bars. **If you use `|`, every bar — including the final
|
||
one — must total exactly 4/4** — the parser raises
|
||
`ValueError: bar N totals X ticks` and tells you which bar is wrong.
|
||
Use it; it is the single most useful bug-catcher here.
|
||
- Pitches: letter + optional `#`/`b` + octave, e.g. `Bb3`, `F#5`.
|
||
|
||
## Verification loop (do this, don't skip it)
|
||
|
||
1. `python3 tools/make_tune.py spec.json --render` — writes MIDI, re-parses
|
||
it, renders WAV, checks for clipping / dead air / wrong duration.
|
||
2. If a check fails, fix the spec and re-run. Never declare success without
|
||
a passing run.
|
||
3. Optional theory audit: parse the lead track with `midikit.read()`, flag
|
||
notes outside the key scale and outside the current bar's chord tones,
|
||
and treat each flag as either intentional colour or a bug.
|
||
|
||
## Looping for game background music
|
||
|
||
`loop` in the spec (or `MidiFile.set_loop(start_tick, end_tick)` in Python)
|
||
writes markers in three conventions so any engine/DAW finds them:
|
||
`LOOP_START/LOOP_END`, `RPR LOOP START/RPR LOOP END` (Reaper),
|
||
`LoopBegin/LoopEnd`. Convention: intro bars sit *before* the loop start so
|
||
the engine plays the intro once, then jumps back to loop start and returns
|
||
to loop end.
|
||
|
||
## Library use (Python)
|
||
|
||
`lib/midikit.py` and `lib/songkit.py` are stdlib-only — drop them into any
|
||
project. Key entry points: `MidiFile`, `Track`, `MPQN.from_bpm`,
|
||
`set_loop`, `verify`; songkit: `bar`, `melody`, `arp`, `bass_drive`,
|
||
`drums`, `vibrato`, `parse_score`. Times are ticks; `bar(n)` converts bars
|
||
to ticks at 480 tpq.
|
||
|
||
## Rendering
|
||
|
||
Two routes, both verified:
|
||
- **Sample-based**: `timidity -Ow -s 44 in.mid -o out.wav` + a GM soundfont.
|
||
FluidR3 is MIT-licensed and one command away: `apt install fluid-soundfont-gm`
|
||
(148MB; lighter: `timgm6mb-soundfont`, ~6MB). Not bundled in-repo because of
|
||
size, not licence. Debian's timidity: use `-Ow -s 44` (no `-O2` mode).
|
||
- **OPL3 FM (authentic DOS sound, no soundfont)**: `./tools/opl/build.sh`
|
||
once (needs cmake+g++, fetches libADLMIDI), then `./tools/render_opl.sh`.
|
||
`OPL_MODEL=` env picks a game-driver volume model: 3 DMX/Doom (default),
|
||
4 Apogee/Duke Nukem 3D, 2 id-style, 8 AIL, 10 HMI. Output is normalised.
|
||
|
||
## Known gotchas
|
||
|
||
- Pitch bends extend file duration (vibrato tails count).
|
||
- No comments inside melody strings — `#` gets parsed as a note.
|
||
- GM2 patches (ocarina etc.) are unreliable on GM1 synths; use pan flute.
|
||
- Five hot tracks sum to clipping; lower CC7 volumes, not velocities.
|