#!/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)
