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