joeylib2/examples/spacetaxi/assets/sidToSong.py

111 lines
4 KiB
Python

#!/usr/bin/env python3
"""SID register log (sidCapture.py) -> JoeyLib .song text for songbake.
One row per PAL frame (tickms 20). Per voice, a note-on is a gate rising
edge or a pitch change while gated; gate falling is 'off'. SID frequency ->
Hz exactly (@Hz cells), so no equal-temperament rounding. Attenuation comes
from the sustain nibble. A voice whose waveform is NOISE goes to the noise
column instead of a tone column, with a pitch scaled from its frequency.
Loop detection: the player is deterministic, so once the (frame-relative)
event rows repeat with a fixed period, that period is the loop.
"""
import json, sys
PAL_CLOCK = 985248.0
songs = json.load(open(sys.argv[1]))
outDir = sys.argv[2]
def sidHz(lo, hi):
return ((hi << 8) | lo) * PAL_CLOCK / 16777216.0
def rowsFor(writes, nframes):
# replay register writes frame by frame, producing per-frame voice state
regs = [0] * 0x19
state = []
byFrame = {}
for f, r, v in writes:
byFrame.setdefault(f, []).append((r, v))
for f in range(nframes):
for r, v in byFrame.get(f, []):
regs[r] = v
vs = []
for vc in range(3):
b = vc * 7
vs.append(dict(hz=sidHz(regs[b], regs[b + 1]), gate=regs[b + 4] & 1,
wave=regs[b + 4] & 0xF0, sus=regs[b + 6] >> 4))
state.append(vs)
return state
def atten(sus):
# SID sustain 0..15 -> AGI attenuation 14..0 (0 loudest)
return max(0, min(14, round(14 - sus * 14 / 15)))
def noisePitch(hz):
# brighter hiss for higher SID "frequency"; 0..31, 0 = brightest
p = int(31 - min(31, hz / 400.0))
return max(0, min(31, p))
def toRows(state):
rows = []
prev = [dict(hz=None, gate=0, wave=0, sus=0) for _ in range(3)]
for vs in state:
tone = ["---", "---", "---"]
noise = "---"
for vc in range(3):
cur, was = vs[vc], prev[vc]
isNoise = (cur["wave"] & 0x80) != 0
on = cur["gate"] and (not was["gate"] or abs(cur["hz"] - was["hz"]) > 0.5
or cur["wave"] != was["wave"])
off = was["gate"] and not cur["gate"]
if on:
if isNoise:
noise = f"N{noisePitch(cur['hz'])}:{atten(cur['sus'])}"
else:
hz = max(1, min(65535, int(round(cur["hz"]))))
tone[vc] = f"@{hz}:{atten(cur['sus'])}"
elif off:
if (was["wave"] & 0x80):
noise = "off"
else:
tone[vc] = "off"
prev[vc] = dict(cur)
rows.append((tone, noise))
return rows
def findLoop(rows):
# smallest (offset, period) with rows[o+k] == rows[o+p+k] for the tail
n = len(rows)
for p in range(8, n // 2):
for o in range(0, n - 2 * p):
if rows[o:o + p] == rows[o + p:o + 2 * p] and rows[o:n - p] == rows[o + p:n]:
return o, p
return None
for num, s in sorted(songs.items(), key=lambda kv: int(kv[0])):
num = int(num)
st = rowsFor(s["writes"], s["frames"])
rows = toRows(st)
loop = findLoop(rows) if s["frames"] >= 4000 else None
if loop:
o, p = loop
rows = rows[:o + p]
lines = ["; Space Taxi (C64) song %d, extracted by running the game's own" % num,
"; player ($CB00/$CF52) under cpu6502.py and logging SID writes.",
"; One row = one PAL frame.", "tickms 20"]
for i, (tone, noise) in enumerate(rows):
if loop and i == loop[0]:
lines.append("loop")
cells = tone + ([noise] if noise != "---" or True else [])
lines.append(" ".join(cells))
open(f"{outDir}/song{num}.song", "w").write("\n".join(lines) + "\n")
events = sum(1 for t, n in rows for c in t + [n] if c != "---")
waves = sorted({hex(v["wave"]) for vs in st for v in vs if v["gate"]})
print(f"song {num}: {len(rows)} rows, {events} events, waveforms {waves}"
+ (f", LOOP at row {loop[0]} period {loop[1]}" if loop else ""))