577 lines
22 KiB
Python
Executable file
577 lines
22 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
|
|
# See LICENSE.md for license information.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""vgm2song.py - a VGM chip log as a JoeyLib .song, for tools/songbake.
|
|
|
|
JoeyLib's chip tracker (jlMusicPlay) plays three square tone voices, each a
|
|
frequency and a 4-bit attenuation (2 dB steps), and a noise voice, a pitch
|
|
0..31 (the YM2149's noise period on the Atari ST's 2 MHz chip) and an
|
|
attenuation. A VGM file is a chip's register writes and the waits between
|
|
them, which a tracker exports as its song plays out: Furnace (the
|
|
recommended tracker, docs/Assets.adoc) does, as do most others. Write the
|
|
song for one of these and export VGM:
|
|
|
|
TI SN76489 (Sega Master System, BBC Micro, TI-99/4A): three tones and a
|
|
noise channel, as JoeyLib has; the attenuation carries over as it is.
|
|
Its noise runs at one of three rates or tone 2's, taken to the noise
|
|
pitch nearest that rate. Its "periodic" noise is a pitched pulse (the
|
|
noise rate over the shift register's width: the Master System's bass
|
|
trick), so it plays on a tone voice that is free, at that pitch; with
|
|
all three busy it plays as noise, with a warning. A chip without the
|
|
/8 prescaler (the VGM's flag says so, or its clock is under
|
|
SN_DIRECT_CLOCK_MAX: the TI-99/4A's 447 kHz SN94624, whose files
|
|
Furnace leaves unflagged) divides its clock by 4 where the SN76489
|
|
divides by 32.
|
|
AY-3-8910 / YM2149 (the Atari ST's chip): three tones; its noise mixes into
|
|
the tone channels, so the noise voice sounds while any channel that has
|
|
it enabled sounds, at the loudest of them. Volume v is attenuation
|
|
15 - v (exact on the ST). The volume envelope is followed tick by tick
|
|
(its period and shape, restarted by each write to its shape); one that
|
|
repeats faster than two rows is a timbre (the "buzzer"), not a volume:
|
|
it plays at full volume, and a channel whose tone is off sounds at the
|
|
envelope's own frequency, as the buzzer does, with a warning.
|
|
|
|
The song is the chip's state at the end of each tick: one .song row a tick
|
|
(its tickms), sampled from the VGM's own timing, so the length and the loop
|
|
point are kept to within a tick and nothing drifts. The default tick is the
|
|
VGM's update interval rounded down to whole milliseconds (16 for 60 Hz, 20
|
|
for 50 Hz), short enough that every update keeps a row of its own: its
|
|
header's rate, or the shortest gap between writes that comes more than once
|
|
when that is shorter (a song whose engine outruns the system's rate). A row
|
|
holds what changed since the one before it; the loop row restates every
|
|
voice, so a loop starts from the right state. Runs of empty rows become
|
|
repeat blocks. A tone too high to hear (above MAX_AUDIBLE_HZ: the chips' PCM
|
|
trick) is silence.
|
|
|
|
vgm2song.py [--chip sn|ay] [--tick MS] IN.vgm OUT.song
|
|
|
|
IN may be gzip-compressed (.vgz). --chip picks the chip when the VGM drives
|
|
both; only the first of a pair of chips is converted, and any other chip
|
|
(the Master System's FM unit, say) is left out, with a warning. The JYM1
|
|
stream songbake makes must stay under 64 KB: without --tick, a song that
|
|
would not fit gets the shortest tick, a whole multiple of the default, that
|
|
does (merging changes faster than it: vibrato, arpeggios), with a warning;
|
|
--tick is taken as it is.
|
|
"""
|
|
|
|
import argparse
|
|
import gzip
|
|
import math
|
|
import struct
|
|
import sys
|
|
|
|
VGM_IDENT = b"Vgm "
|
|
VGM_RATE = 44100
|
|
VGM_HEADER_MIN = 0x40
|
|
VGM_DATA_OFFSET_FIELD = 0x34
|
|
VGM_DATA_OFFSET_V150 = 0x150
|
|
VGM_AY_CLOCK_FIELD = 0x74
|
|
VGM_AY_CLOCK_V151 = 0x151
|
|
VGM_RATE_FIELD = 0x24
|
|
VGM_CLOCK_MASK = 0x3FFFFFFF
|
|
VGM_DUAL_CHIP = 0x40000000
|
|
GZIP_MAGIC = b"\x1f\x8b"
|
|
GD3_IDENT = b"Gd3 "
|
|
GD3_TITLE = 0
|
|
GD3_AUTHOR = 6
|
|
|
|
CMD_SN = 0x50
|
|
CMD_SN2 = 0x30
|
|
# Writes to other chips: two-byte register writes (YM2413 through YM2612,
|
|
# YMF262...) and the others' ranges.
|
|
OTHER_CHIP_RANGES = ((0x51, 0x5F), (0xA1, 0xBF), (0xC0, 0xDF), (0xE1, 0xFF))
|
|
CMD_AY = 0xA0
|
|
CMD_WAIT = 0x61
|
|
CMD_WAIT_NTSC = 0x62
|
|
CMD_WAIT_PAL = 0x63
|
|
CMD_END = 0x66
|
|
CMD_DATA_BLOCK = 0x67
|
|
WAIT_NTSC = 735
|
|
WAIT_PAL = 882
|
|
AY_SECOND_CHIP = 0x80
|
|
|
|
# Operand bytes of every command but the waits, end and data blocks, by
|
|
# range (VGM 1.71).
|
|
COMMAND_LENGTHS = (
|
|
(0x30, 0x3F, 1), (0x40, 0x4E, 2), (0x4F, 0x50, 1), (0x51, 0x5F, 2),
|
|
(0x64, 0x64, 3), (0x68, 0x68, 11), (0x70, 0x8F, 0), (0x90, 0x91, 4),
|
|
(0x92, 0x92, 5), (0x93, 0x93, 10), (0x94, 0x94, 1), (0x95, 0x95, 4),
|
|
(0xA0, 0xBF, 2), (0xC0, 0xDF, 3), (0xE0, 0xFF, 4),
|
|
)
|
|
SHORT_WAIT_FIRST = 0x70
|
|
SHORT_WAIT_LAST = 0x7F
|
|
DAC_WAIT_FIRST = 0x80
|
|
DAC_WAIT_LAST = 0x8F
|
|
|
|
# JoeyLib's voices (tools/songbake/songbake.py, src/core/music.c).
|
|
TONE_VOICES = 3
|
|
ATTEN_OFF = 15
|
|
ATTEN_MAX = 14
|
|
NOISE_PITCH_MAX = 31
|
|
FREQ_MAX = 0xFFFF
|
|
MAX_AUDIBLE_HZ = 20000
|
|
# The noise pitch is the YM2149's noise period on the ST's 2 MHz chip,
|
|
# whose noise generator runs at 2 MHz / 16 / period.
|
|
YM_NOISE_HZ = 2000000 / 16
|
|
JYM_LIMIT = 0xFFFF
|
|
TICK_MS_MAX = 1000
|
|
JYM_NOTE_BYTES = 4
|
|
JYM_HEADER_BYTES = 8
|
|
JYM_WAIT_MAX = 127
|
|
REPEAT_MIN = 4
|
|
REPEAT_MAX = 256
|
|
# The .song's columns: the widest cell is "@65535:14".
|
|
CELL_WIDTH = 10
|
|
EMPTY_ROW = ("---",) * (TONE_VOICES + 1)
|
|
|
|
# The SN76489: tone = clock / (32 * period); noise rates clock / 512, 1024,
|
|
# 2048, or tone 2's. Without the /8 prescaler, an eighth of those dividers.
|
|
SN_TONE_DIVIDER = 32
|
|
SN_NOISE_DIVIDERS = (512, 1024, 2048)
|
|
SN_PRESCALER = 8
|
|
SN_DIRECT_CLOCK_MAX = 1000000
|
|
SN_NOISE_TONE2 = 3
|
|
SN_NOISE_WHITE = 0x04
|
|
SN_PERIOD_ZERO = 1024
|
|
SN_SHIFT_WIDTH_DEFAULT = 16
|
|
VGM_SN_SHIFT_WIDTH = 0x2A
|
|
VGM_SN_FLAGS = 0x2B
|
|
SN_FLAG_ZERO_IS_400 = 0x01
|
|
SN_FLAG_NO_PRESCALER = 0x08
|
|
# The AY: tone = clock / (16 * period), noise clock / (16 * period); the
|
|
# envelope steps 16 levels a cycle of 256 * period clocks. A YM2149 with
|
|
# pin 26 low halves its clock (the VGM's AY flags say so).
|
|
AY_DIVIDER = 16
|
|
AY_REG_MIXER = 7
|
|
AY_REG_NOISE = 6
|
|
AY_REG_VOLUME = 8
|
|
AY_REG_ENVELOPE_LO = 11
|
|
AY_REG_ENVELOPE_HI = 12
|
|
AY_REG_SHAPE = 13
|
|
AY_ENVELOPE = 0x10
|
|
AY_LEVEL_MAX = 15
|
|
AY_ENVELOPE_STEPS = 16
|
|
AY_ENVELOPE_CYCLE = 256
|
|
AY_SHAPE_CONTINUE = 0x08
|
|
AY_SHAPE_ATTACK = 0x04
|
|
AY_SHAPE_ALTERNATE = 0x02
|
|
AY_SHAPE_HOLD = 0x01
|
|
VGM_AY_FLAGS = 0x79
|
|
AY_FLAG_HALF_CLOCK = 0x10
|
|
# An envelope that repeats faster than this many rows is a timbre; its
|
|
# frequency is a cycle's, or half that when it alternates (a triangle).
|
|
AY_ENVELOPE_ROWS_MIN = 2
|
|
AY_TRIANGLE_CYCLES = 2
|
|
|
|
|
|
def u32(data, at):
|
|
return struct.unpack_from("<I", data, at)[0] if at + 4 <= len(data) else 0
|
|
|
|
|
|
def commandLength(op):
|
|
for first, last, length in COMMAND_LENGTHS:
|
|
if first <= op <= last:
|
|
return length
|
|
return None
|
|
|
|
|
|
def gd3Strings(data, at):
|
|
"""The GD3 tag's strings at at (UTF-16LE, each ended by a 0), ASCII."""
|
|
if data[at:at + 4] != GD3_IDENT:
|
|
return []
|
|
size = u32(data, at + 8)
|
|
text = data[at + 12:at + 12 + size].decode("utf-16-le", "replace")
|
|
return ["".join(c if 32 <= ord(c) < 127 else "?" for c in s) for s in text.split("\0")]
|
|
|
|
|
|
class Sn76489:
|
|
"""The SN76489's registers, written byte by byte."""
|
|
|
|
def __init__(self, clock, flags, shiftWidth):
|
|
self.clock = clock
|
|
self.shiftWidth = shiftWidth or SN_SHIFT_WIDTH_DEFAULT
|
|
scale = SN_PRESCALER if flags & SN_FLAG_NO_PRESCALER or clock < SN_DIRECT_CLOCK_MAX else 1
|
|
self.toneDivider = SN_TONE_DIVIDER / scale
|
|
self.noiseDividers = tuple(d / scale for d in SN_NOISE_DIVIDERS)
|
|
self.periodZero = SN_PERIOD_ZERO if flags & SN_FLAG_ZERO_IS_400 else 1
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
"""Power-on state: every channel silent."""
|
|
self.periodicAsNoise = 0
|
|
self.period = [0, 0, 0]
|
|
self.atten = [ATTEN_OFF] * 4
|
|
self.noise = 0
|
|
self.latch = (0, True)
|
|
|
|
def write(self, value, time):
|
|
if value & 0x80:
|
|
channel = (value >> 5) & 3
|
|
isVolume = bool(value & 0x10)
|
|
self.latch = (channel, isVolume)
|
|
low = value & 0x0F
|
|
if isVolume:
|
|
self.atten[channel] = low
|
|
elif channel < TONE_VOICES:
|
|
self.period[channel] = (self.period[channel] & 0x3F0) | low
|
|
else:
|
|
self.noise = low & 7
|
|
return
|
|
channel, isVolume = self.latch
|
|
if isVolume:
|
|
self.atten[channel] = value & 0x0F
|
|
elif channel < TONE_VOICES:
|
|
self.period[channel] = (self.period[channel] & 0x0F) | ((value & 0x3F) << 4)
|
|
else:
|
|
self.noise = value & 7
|
|
|
|
def toneHz(self, channel):
|
|
period = self.period[channel] or self.periodZero
|
|
return self.clock / (self.toneDivider * period)
|
|
|
|
def voices(self, time, rowSamples):
|
|
out = []
|
|
for channel in range(TONE_VOICES):
|
|
out.append(toneVoice(self.toneHz(channel), self.atten[channel]))
|
|
if self.atten[3] >= ATTEN_OFF:
|
|
out.append(None)
|
|
return tuple(out)
|
|
rate = self.toneHz(2) if (self.noise & 3) == SN_NOISE_TONE2 else self.clock / self.noiseDividers[self.noise & 3]
|
|
if not self.noise & SN_NOISE_WHITE:
|
|
# Periodic noise is a pulse at rate / width: a free tone voice
|
|
# plays it, tone 2's first when tone 2 is its clock.
|
|
order = (2, 0, 1) if (self.noise & 3) == SN_NOISE_TONE2 else (0, 1, 2)
|
|
for voice in order:
|
|
if out[voice] is None:
|
|
out[voice] = toneVoice(rate / self.shiftWidth, self.atten[3])
|
|
out.append(None)
|
|
return tuple(out)
|
|
self.periodicAsNoise += 1
|
|
out.append((noisePitch(rate), self.atten[3]))
|
|
return tuple(out)
|
|
|
|
|
|
class Ay8910:
|
|
"""The AY-3-8910 / YM2149's registers, and its envelope's progress."""
|
|
|
|
def __init__(self, clock):
|
|
self.clock = clock
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
"""Power-on state: tone and noise off on every channel."""
|
|
self.regs = [0] * 16
|
|
self.regs[AY_REG_MIXER] = 0xFF
|
|
self.envelopeStart = 0
|
|
self.buzzRows = 0
|
|
|
|
def write(self, value, time):
|
|
register, data = value
|
|
self.regs[register & 0x0F] = data
|
|
if (register & 0x0F) == AY_REG_SHAPE:
|
|
self.envelopeStart = time
|
|
|
|
def envelopeLevel(self, time, rowSamples):
|
|
"""The envelope's level (0..15) at time; None when it repeats too
|
|
fast for rows to follow."""
|
|
period = max(1, self.regs[AY_REG_ENVELOPE_LO] | (self.regs[AY_REG_ENVELOPE_HI] << 8))
|
|
shape = self.regs[AY_REG_SHAPE] & 0x0F
|
|
cycleSamples = AY_ENVELOPE_CYCLE * period * VGM_RATE / self.clock
|
|
repeats = shape & AY_SHAPE_CONTINUE and not shape & AY_SHAPE_HOLD
|
|
if repeats and cycleSamples < AY_ENVELOPE_ROWS_MIN * rowSamples:
|
|
return None
|
|
step = int((time - self.envelopeStart) * AY_ENVELOPE_STEPS / cycleSamples)
|
|
cycle, position = divmod(step, AY_ENVELOPE_STEPS)
|
|
attack = bool(shape & AY_SHAPE_ATTACK)
|
|
if cycle > 0:
|
|
if not shape & AY_SHAPE_CONTINUE:
|
|
return 0
|
|
if shape & AY_SHAPE_HOLD:
|
|
end = AY_LEVEL_MAX if attack else 0
|
|
return AY_LEVEL_MAX - end if shape & AY_SHAPE_ALTERNATE else end
|
|
if shape & AY_SHAPE_ALTERNATE and cycle % 2:
|
|
attack = not attack
|
|
return position if attack else AY_LEVEL_MAX - position
|
|
|
|
def envelopeHz(self):
|
|
"""The frequency of a repeating envelope: a cycle a period, two when
|
|
it alternates."""
|
|
period = max(1, self.regs[AY_REG_ENVELOPE_LO] | (self.regs[AY_REG_ENVELOPE_HI] << 8))
|
|
cycles = AY_TRIANGLE_CYCLES if self.regs[AY_REG_SHAPE] & AY_SHAPE_ALTERNATE else 1
|
|
return self.clock / (AY_ENVELOPE_CYCLE * period * cycles)
|
|
|
|
def level(self, channel, time, rowSamples):
|
|
volume = self.regs[AY_REG_VOLUME + channel]
|
|
if not volume & AY_ENVELOPE:
|
|
return volume & 0x0F
|
|
level = self.envelopeLevel(time, rowSamples)
|
|
return AY_LEVEL_MAX if level is None else level
|
|
|
|
def voices(self, time, rowSamples):
|
|
out = []
|
|
mixer = self.regs[AY_REG_MIXER]
|
|
noiseLevel = 0
|
|
if any(self.regs[AY_REG_VOLUME + channel] & AY_ENVELOPE for channel in range(TONE_VOICES)) and self.envelopeLevel(time, rowSamples) is None:
|
|
self.buzzRows += 1
|
|
for channel in range(TONE_VOICES):
|
|
level = self.level(channel, time, rowSamples)
|
|
period = self.regs[channel * 2] | ((self.regs[channel * 2 + 1] & 0x0F) << 8)
|
|
buzzing = self.regs[AY_REG_VOLUME + channel] & AY_ENVELOPE and self.envelopeLevel(time, rowSamples) is None
|
|
if level == 0:
|
|
out.append(None)
|
|
elif not (mixer >> channel) & 1:
|
|
out.append(toneVoice(self.clock / (AY_DIVIDER * max(period, 1)), AY_LEVEL_MAX - level))
|
|
elif buzzing:
|
|
out.append(toneVoice(self.envelopeHz(), 0))
|
|
else:
|
|
out.append(None)
|
|
if not (mixer >> (TONE_VOICES + channel)) & 1:
|
|
noiseLevel = max(noiseLevel, level)
|
|
if noiseLevel == 0:
|
|
out.append(None)
|
|
else:
|
|
period = max(self.regs[AY_REG_NOISE] & 0x1F, 1)
|
|
out.append((noisePitch(self.clock / (AY_DIVIDER * period)), AY_LEVEL_MAX - noiseLevel))
|
|
return tuple(out)
|
|
|
|
|
|
def toneVoice(hz, atten):
|
|
"""A tone voice's (Hz, attenuation), or None when silent or too high to
|
|
hear."""
|
|
if atten >= ATTEN_OFF or hz > MAX_AUDIBLE_HZ:
|
|
return None
|
|
return (max(1, min(FREQ_MAX, round(hz))), min(atten, ATTEN_MAX))
|
|
|
|
|
|
def noisePitch(rate):
|
|
"""The noise pitch (0..31) whose rate on the ST is nearest rate."""
|
|
return max(0, min(NOISE_PITCH_MAX, round(YM_NOISE_HZ / rate)))
|
|
|
|
|
|
def readVgm(path, chip):
|
|
"""(clock name, the chip model, [(time, write)], loop time or None, end
|
|
time, header rate, title strings, warnings) of the VGM at path."""
|
|
data = open(path, "rb").read()
|
|
if data[:2] == GZIP_MAGIC:
|
|
data = gzip.decompress(data)
|
|
if data[:4] != VGM_IDENT or len(data) < VGM_HEADER_MIN:
|
|
raise SystemExit("vgm2song: %s is not a VGM file" % path)
|
|
version = u32(data, 0x08)
|
|
start = VGM_HEADER_MIN
|
|
if version >= VGM_DATA_OFFSET_V150 and u32(data, VGM_DATA_OFFSET_FIELD):
|
|
start = VGM_DATA_OFFSET_FIELD + u32(data, VGM_DATA_OFFSET_FIELD)
|
|
snClock = u32(data, 0x0C)
|
|
ayClock = u32(data, VGM_AY_CLOCK_FIELD) if version >= VGM_AY_CLOCK_V151 and start > VGM_AY_CLOCK_FIELD else 0
|
|
loopAt = 0x1C + u32(data, 0x1C) if u32(data, 0x1C) else None
|
|
rate = u32(data, VGM_RATE_FIELD)
|
|
title = gd3Strings(data, 0x14 + u32(data, 0x14)) if u32(data, 0x14) else []
|
|
warnings = []
|
|
|
|
writes = {"sn": [], "ay": []}
|
|
secondChip = 0
|
|
otherChip = 0
|
|
time = 0
|
|
loopTime = None
|
|
at = start
|
|
while at < len(data):
|
|
if at == loopAt:
|
|
loopTime = time
|
|
op = data[at]
|
|
if op == CMD_END:
|
|
break
|
|
if op == CMD_WAIT:
|
|
time += struct.unpack_from("<H", data, at + 1)[0]
|
|
at += 3
|
|
elif op == CMD_WAIT_NTSC:
|
|
time += WAIT_NTSC
|
|
at += 1
|
|
elif op == CMD_WAIT_PAL:
|
|
time += WAIT_PAL
|
|
at += 1
|
|
elif SHORT_WAIT_FIRST <= op <= SHORT_WAIT_LAST:
|
|
time += op - SHORT_WAIT_FIRST + 1
|
|
at += 1
|
|
elif DAC_WAIT_FIRST <= op <= DAC_WAIT_LAST:
|
|
time += op - DAC_WAIT_FIRST
|
|
at += 1
|
|
elif op == CMD_DATA_BLOCK:
|
|
at += 7 + u32(data, at + 3)
|
|
elif op == CMD_SN:
|
|
writes["sn"].append((time, data[at + 1]))
|
|
at += 2
|
|
elif op == CMD_SN2:
|
|
secondChip += 1
|
|
at += 2
|
|
elif op == CMD_AY:
|
|
if data[at + 1] & AY_SECOND_CHIP:
|
|
secondChip += 1
|
|
else:
|
|
writes["ay"].append((time, (data[at + 1], data[at + 2])))
|
|
at += 3
|
|
else:
|
|
length = commandLength(op)
|
|
if length is None:
|
|
raise SystemExit("vgm2song: unknown VGM command 0x%02X at 0x%X" % (op, at))
|
|
if any(first <= op <= last for first, last in OTHER_CHIP_RANGES):
|
|
otherChip += 1
|
|
at += 1 + length
|
|
if secondChip:
|
|
warnings.append("%d writes to a second chip left out" % secondChip)
|
|
if otherChip:
|
|
warnings.append("%d writes to other chips (FM, PCM) left out: their parts do not play" % otherChip)
|
|
|
|
found = [name for name in ("sn", "ay") if writes[name]]
|
|
if chip is None:
|
|
if len(found) != 1:
|
|
raise SystemExit("vgm2song: the VGM drives %s; pick one with --chip" % (" and ".join(found) or "neither the SN76489 nor the AY-3-8910"))
|
|
chip = found[0]
|
|
if not writes[chip]:
|
|
raise SystemExit("vgm2song: the VGM has no %s writes" % ("SN76489" if chip == "sn" else "AY-3-8910"))
|
|
if chip == "sn":
|
|
model = Sn76489(snClock & VGM_CLOCK_MASK, data[VGM_SN_FLAGS], data[VGM_SN_SHIFT_WIDTH])
|
|
name = "TI SN76489 at %d Hz" % model.clock
|
|
else:
|
|
clock = ayClock & VGM_CLOCK_MASK
|
|
if len(data) > VGM_AY_FLAGS and data[VGM_AY_FLAGS] & AY_FLAG_HALF_CLOCK:
|
|
clock //= 2
|
|
model = Ay8910(clock)
|
|
name = "AY-3-8910/YM2149 at %d Hz" % model.clock
|
|
if model.clock == 0:
|
|
raise SystemExit("vgm2song: the VGM gives the chip no clock")
|
|
return name, model, writes[chip], loopTime, time, rate, title, warnings
|
|
|
|
|
|
def defaultTick(writes, rate):
|
|
"""The update interval, rounded down to whole milliseconds: the header's
|
|
rate, or the shortest gap between writes that comes more than once when
|
|
that is shorter."""
|
|
times = sorted(set(t for t, _ in writes))
|
|
gaps = {}
|
|
for a, b in zip(times, times[1:]):
|
|
gaps[b - a] = gaps.get(b - a, 0) + 1
|
|
frame = VGM_RATE / rate if rate else WAIT_NTSC
|
|
repeated = [g for g, count in gaps.items() if count > 1]
|
|
if repeated:
|
|
frame = min(frame, min(repeated))
|
|
return max(1, math.floor(frame * 1000 / VGM_RATE))
|
|
|
|
|
|
def rows(model, writes, tickMs, endTime):
|
|
"""The chip's voices at the end of every row, the first to the last the
|
|
song lasts."""
|
|
rowSamples = tickMs * VGM_RATE / 1000
|
|
count = max(1, math.ceil(endTime / rowSamples))
|
|
out = []
|
|
at = 0
|
|
for row in range(count):
|
|
rowEnd = (row + 1) * rowSamples
|
|
while at < len(writes) and writes[at][0] < rowEnd:
|
|
model.write(writes[at][1], writes[at][0])
|
|
at += 1
|
|
out.append(model.voices(rowEnd, rowSamples))
|
|
return out
|
|
|
|
|
|
def cell(voice, state):
|
|
if state is None:
|
|
return "off"
|
|
if voice < TONE_VOICES:
|
|
return "@%d:%d" % state
|
|
return "N%d:%d" % state
|
|
|
|
|
|
def rowText(cells):
|
|
return " ".join(c.ljust(CELL_WIDTH) for c in cells).rstrip()
|
|
|
|
|
|
def songLines(states, loopRow):
|
|
"""The .song rows: each voice that changed, every voice on the loop row;
|
|
runs of empty rows as repeat blocks. Also the JYM1 bytes they bake to."""
|
|
lines = []
|
|
events = 0
|
|
empty = 0
|
|
waitRows = 0
|
|
last = (None,) * (TONE_VOICES + 1)
|
|
|
|
def flushEmpty():
|
|
nonlocal empty
|
|
while empty:
|
|
run = min(empty, REPEAT_MAX)
|
|
if run >= REPEAT_MIN:
|
|
lines.extend(["repeat %d" % run, rowText(EMPTY_ROW), "endrepeat"])
|
|
else:
|
|
lines.extend([rowText(EMPTY_ROW)] * run)
|
|
empty -= run
|
|
|
|
for row, state in enumerate(states):
|
|
cells = []
|
|
for voice in range(TONE_VOICES + 1):
|
|
if row == loopRow or state[voice] != last[voice]:
|
|
cells.append(cell(voice, state[voice]))
|
|
events += JYM_NOTE_BYTES if state[voice] is not None else 1
|
|
else:
|
|
cells.append("---")
|
|
last = state
|
|
if row == loopRow:
|
|
flushEmpty()
|
|
lines.append("loop")
|
|
if all(c == "---" for c in cells):
|
|
empty += 1
|
|
else:
|
|
flushEmpty()
|
|
lines.append(rowText(cells))
|
|
waitRows += 1
|
|
flushEmpty()
|
|
# A wait byte at most every row that has events, plus the long runs.
|
|
size = JYM_HEADER_BYTES + events + waitRows + len(states) // JYM_WAIT_MAX + 1
|
|
return lines, size
|
|
|
|
|
|
def main(argv):
|
|
parser = argparse.ArgumentParser(description="A VGM chip log as a JoeyLib .song (see the module's docstring).")
|
|
parser.add_argument("--chip", choices=("sn", "ay"), help="the chip to convert when the VGM drives both")
|
|
parser.add_argument("--tick", type=int, help="milliseconds a row (default: the VGM's update interval, rounded down)")
|
|
parser.add_argument("vgm")
|
|
parser.add_argument("song")
|
|
args = parser.parse_args(argv[1:])
|
|
|
|
name, model, writes, loopTime, endTime, rate, title, warnings = readVgm(args.vgm, args.chip)
|
|
baseTick = args.tick if args.tick else defaultTick(writes, rate)
|
|
if not 1 <= baseTick <= TICK_MS_MAX:
|
|
raise SystemExit("vgm2song: --tick is 1..%d ms" % TICK_MS_MAX)
|
|
tickMs = baseTick
|
|
while True:
|
|
model.reset()
|
|
states = rows(model, writes, tickMs, endTime)
|
|
rowSamples = tickMs * VGM_RATE / 1000
|
|
loopRow = int(loopTime // rowSamples) if loopTime is not None and loopTime < endTime else None
|
|
lines, size = songLines(states, loopRow)
|
|
if args.tick or size < JYM_LIMIT or tickMs + baseTick > TICK_MS_MAX:
|
|
break
|
|
tickMs += baseTick
|
|
if tickMs != baseTick:
|
|
warnings.append("%d ms rows would bake past JYM1's 64 KB; using %d ms rows" % (baseTick, tickMs))
|
|
if isinstance(model, Sn76489) and model.periodicAsNoise:
|
|
warnings.append("%d rows have periodic noise with all three tone voices busy, played as noise" % model.periodicAsNoise)
|
|
if isinstance(model, Ay8910) and model.buzzRows:
|
|
warnings.append("%d rows have an envelope repeating faster than two rows (a timbre), played at full volume" % model.buzzRows)
|
|
|
|
header = ["; Converted by tools/vgm2song.py from %s" % args.vgm.rsplit("/", 1)[-1],
|
|
"; %s, %d rows of %d ms%s" % (name, len(states), tickMs, ", looping at row %d" % loopRow if loopRow is not None else "")]
|
|
if len(title) > GD3_AUTHOR and title[GD3_TITLE]:
|
|
header.append("; title: %s" % title[GD3_TITLE])
|
|
if len(title) > GD3_AUTHOR and title[GD3_AUTHOR]:
|
|
header.append("; author: %s" % title[GD3_AUTHOR])
|
|
with open(args.song, "w", encoding="ascii") as out:
|
|
out.write("\n".join(header + ["", "tickms %d" % tickMs] + lines) + "\n")
|
|
for warning in warnings:
|
|
sys.stderr.write("vgm2song: warning: %s\n" % warning)
|
|
if size >= JYM_LIMIT:
|
|
sys.stderr.write("vgm2song: warning: this bakes to about %d bytes, over JYM1's 64 KB; try a longer --tick\n" % size)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|