#!/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 = 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} \n\n") f.write(f"#ifndef {guard}\n#define {guard}\n\n") f.write("#include \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)