joeylib2/tools/songbake/songbake.py

317 lines
11 KiB
Python

#!/usr/bin/env python3
# songbake: bake JoeyLib tracker text notation into a JYM1 event stream.
#
# One authored .song file plays on every JoeyLib port through the
# jlMusicPlay sequencer (src/core/music.c), which drives the
# jlAudioVoice 3-voice tone layer and the jlAudioNoise percussion
# generator. See music.c for the JYM1 stream layout this tool emits.
#
# Input format (one row per line, ';' starts a comment):
#
# tickms 110 directive: ms per row. Required before the
# first row; later occurrences emit a tempo
# change event at that point in the song.
# loop directive: the NEXT row is the loop-back target
# repeat 4 directive: repeat the rows down to 'endrepeat'
# endrepeat 4 times (repeats may nest; 'loop' inside a
# repeat block is rejected)
# C-2:2 A-4:5 --- row: 3 tone cells, or 4 cells with noise last
# C-2:2 --- --- N4:6
#
# Tone cell forms:
# C-4:3 note-on, note name + octave, ':' + AGI attenuation 0..14
# (0 = loudest). ':atten' may be omitted (defaults to 0).
# Sharps use '#': C#4:3. Note range C-0..B-7.
# @440:3 note-on at an exact frequency in Hz (1..65535) -- for
# chip-accurate transcription work where equal-temperament
# note names would round the pitch.
# --- hold: no event for this voice this row
# off silence this voice
#
# Noise cell forms (4th column):
# N4:3 noise-on, pitch 0..31 (0 = brightest hiss, 31 = lowest
# rumble), ':' + attenuation as above
# --- hold
# off silence the noise voice
#
# On the Atari ST the noise generator shares channel C's volume with
# tone voice 2; songbake warns when both are audible at once.
#
# A row lasts one tick. Rows with no events compress into wait bytes.
#
# Usage:
# songbake.py song.song song.jym binary stream
# songbake.py -c gName song.song song.h C header (uint8_t array)
import sys
NOTE_INDEX = {
"C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5,
"F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11,
}
OP_END = 0x00
OP_NOTE = 0x10
OP_OFF = 0x20
OP_TEMPO = 0x40
OP_WAIT = 0x80
WAIT_MAX = 0x7F
LOOP_NONE = 0xFFFF
TONE_VOICES = 3
NOISE_VOICE = 3
NOISE_PITCH_MAX = 31
ATTEN_MAX = 14
TICK_MS_MAX = 1000
REPEAT_MAX = 256
def fail(lineNum, msg):
where = f"line {lineNum}: " if lineNum else ""
sys.stderr.write(f"songbake: {where}{msg}\n")
sys.exit(1)
def noteToFreq(cell, lineNum):
# 'C-4' / 'C#4' -> equal-temperament Hz, A-4 = 440.
name = cell[:-1].rstrip("-")
octave = cell[-1]
if name not in NOTE_INDEX or not octave.isdigit():
fail(lineNum, f"bad note '{cell}' (want e.g. C-4, C#4, A-2)")
octave = int(octave)
if octave > 7:
fail(lineNum, f"octave out of range in '{cell}' (0..7)")
midi = (octave + 1) * 12 + NOTE_INDEX[name]
freq = round(440.0 * (2.0 ** ((midi - 69) / 12.0)))
if freq < 1 or freq > 0xFFFF:
fail(lineNum, f"'{cell}' is out of the 16-bit Hz range")
return freq
def splitAtten(cell, lineNum):
if ":" in cell:
body, atten = cell.rsplit(":", 1)
if not atten.isdigit() or int(atten) > ATTEN_MAX:
fail(lineNum, f"bad attenuation in '{cell}' (0..{ATTEN_MAX})")
return body, int(atten)
return cell, 0
def parseToneCell(cell, lineNum):
# Returns None (hold), 'off', or (freqHz, atten).
if cell == "---":
return None
if cell == "off":
return "off"
body, atten = splitAtten(cell, lineNum)
if body.startswith("@"):
if not body[1:].isdigit():
fail(lineNum, f"bad frequency cell '{cell}' (want e.g. @440:3)")
freq = int(body[1:])
if freq < 1 or freq > 0xFFFF:
fail(lineNum, f"frequency out of range in '{cell}' (1..65535)")
return (freq, atten)
return (noteToFreq(body, lineNum), atten)
def parseNoiseCell(cell, lineNum):
# Returns None (hold), 'off', or (pitch, atten).
if cell == "---":
return None
if cell == "off":
return "off"
body, atten = splitAtten(cell, lineNum)
if not body.startswith("N") or not body[1:].isdigit():
fail(lineNum, f"bad noise cell '{cell}' (want e.g. N4:3, N31)")
pitch = int(body[1:])
if pitch > NOISE_PITCH_MAX:
fail(lineNum, f"noise pitch out of range in '{cell}' (0..{NOISE_PITCH_MAX})")
return (pitch, atten)
def expandRepeats(lines):
# Unroll repeat/endrepeat blocks (nesting allowed) at bake time,
# keeping original line numbers for error reporting. Returns a
# list of (lineNum, text, inRepeat).
out = []
stack = []
for lineNum, raw in enumerate(lines, 1):
line = raw.split(";", 1)[0].strip()
if not line:
continue
cells = line.split()
if cells[0] == "repeat":
if len(cells) != 2 or not cells[1].isdigit():
fail(lineNum, "usage: repeat <count>")
count = int(cells[1])
if count < 1 or count > REPEAT_MAX:
fail(lineNum, f"repeat count out of range (1..{REPEAT_MAX})")
stack.append((count, []))
continue
if cells[0] == "endrepeat":
if not stack:
fail(lineNum, "endrepeat without repeat")
count, block = stack.pop()
expanded = block * count
if stack:
stack[-1][1].extend(expanded)
else:
out.extend(expanded)
continue
item = (lineNum, line, len(stack) > 0)
if stack:
stack[-1][1].append(item)
else:
out.append(item)
if stack:
fail(0, "repeat without endrepeat")
return out
def bake(lines):
events = bytearray()
tickMs = None
loopOfs = LOOP_NONE
loopPending = False
pendingWait = 0
sawRow = False
voice2On = False
noiseOn = False
overlapRows = []
def flushWait():
nonlocal pendingWait
while pendingWait > 0:
chunk = min(pendingWait, WAIT_MAX)
events.append(OP_WAIT | chunk)
pendingWait -= chunk
for lineNum, line, inRepeat in expandRepeats(lines):
cells = line.split()
if cells[0] == "tickms":
if len(cells) != 2 or not cells[1].isdigit():
fail(lineNum, "usage: tickms <1..1000>")
ms = int(cells[1])
if ms < 1 or ms > TICK_MS_MAX:
fail(lineNum, f"tickms out of range (1..{TICK_MS_MAX})")
if tickMs is None:
tickMs = ms
else:
# Mid-song tempo change: flush waits owed at the old
# tempo, then emit the change event.
flushWait()
events += bytes((OP_TEMPO, ms & 0xFF, (ms >> 8) & 0xFF))
continue
if cells[0] == "loop":
if len(cells) != 1:
fail(lineNum, "'loop' takes no arguments")
if inRepeat:
fail(lineNum, "'loop' inside a repeat block is ambiguous")
if loopOfs != LOOP_NONE or loopPending:
fail(lineNum, "second 'loop' directive")
loopPending = True
continue
if len(cells) not in (TONE_VOICES, TONE_VOICES + 1):
fail(lineNum, f"want {TONE_VOICES} or {TONE_VOICES + 1} voice cells, got {len(cells)}")
rowEvents = bytearray()
for voice in range(TONE_VOICES):
parsed = parseToneCell(cells[voice], lineNum)
if parsed is None:
continue
if parsed == "off":
rowEvents.append(OP_OFF + voice)
if voice == TONE_VOICES - 1:
voice2On = False
else:
freq, atten = parsed
rowEvents += bytes((OP_NOTE + voice, freq & 0xFF, (freq >> 8) & 0xFF, atten))
if voice == TONE_VOICES - 1:
voice2On = True
if len(cells) == TONE_VOICES + 1:
parsed = parseNoiseCell(cells[NOISE_VOICE], lineNum)
if parsed == "off":
rowEvents.append(OP_OFF + NOISE_VOICE)
noiseOn = False
elif parsed is not None:
pitch, atten = parsed
rowEvents += bytes((OP_NOTE + NOISE_VOICE, pitch & 0xFF, 0, atten))
noiseOn = True
if voice2On and noiseOn:
overlapRows.append(lineNum)
# The loop target is the first byte this row contributes; flush
# queued waits first so the jump replays them from the row
# itself, not from silence belonging to earlier rows.
if loopPending or rowEvents:
flushWait()
if loopPending:
loopOfs = len(events)
loopPending = False
events += rowEvents
pendingWait += 1
sawRow = True
if tickMs is None:
fail(0, "missing 'tickms' directive")
if not sawRow:
fail(0, "song has no rows")
if loopPending:
fail(0, "'loop' directive with no row after it")
flushWait()
events.append(OP_END)
if len(events) >= LOOP_NONE:
fail(0, "event stream exceeds 64 KB")
if overlapRows:
shown = ", ".join(str(n) for n in sorted(set(overlapRows))[:8])
sys.stderr.write(
f"songbake: warning: tone voice 2 and noise are audible together "
f"(lines {shown}); on the Atari ST they share channel C's volume\n")
header = bytes((ord("J"), ord("Y"), ord("M"), ord("1"),
tickMs & 0xFF, (tickMs >> 8) & 0xFF,
loopOfs & 0xFF, (loopOfs >> 8) & 0xFF))
return header + bytes(events)
def writeCHeader(path, name, blob, srcName):
guard = f"JOEYLIB_BAKED_{name.upper()}_H"
with open(path, "w", encoding="ascii") as f:
f.write(f"// Generated by tools/songbake/songbake.py from {srcName}.\n")
f.write("// Do not hand-edit; edit the .song source and rebake:\n")
f.write(f"// python3 tools/songbake/songbake.py -c {name} {srcName} <this file>\n\n")
f.write(f"#ifndef {guard}\n#define {guard}\n\n")
f.write("#include <stdint.h>\n\n")
f.write(f"static const uint8_t {name}[{len(blob)}] = {{\n")
for i in range(0, len(blob), 12):
row = ", ".join(f"0x{b:02X}" for b in blob[i:i + 12])
f.write(f" {row},\n")
f.write("};\n\n")
f.write(f"#endif\n")
def main(argv):
cName = None
args = argv[1:]
if args and args[0] == "-c":
if len(args) < 2:
fail(0, "-c needs a symbol name")
cName = args[1]
args = args[2:]
if len(args) != 2:
sys.stderr.write("usage: songbake.py [-c NAME] input.song output\n")
sys.exit(2)
inPath, outPath = args
with open(inPath, "r", encoding="ascii") as f:
blob = bake(f.readlines())
if cName is not None:
writeCHeader(outPath, cName, blob, inPath)
else:
with open(outPath, "wb") as f:
f.write(blob)
sys.stderr.write(f"songbake: {outPath}: {len(blob)} bytes\n")
if __name__ == "__main__":
main(sys.argv)