317 lines
11 KiB
Python
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)
|