Files
Hermes Agent 810f127d01 Harden for agent users: spec validation, key-sig accidentals, N64 chord qualities
- 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
2026-10-17 15:59:46 +00:00

5.1 KiB
Raw Permalink Blame History

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

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

{
  "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 KeyErrors, 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.