Skip to content

Instantly share code, notes, and snippets.

@txmutt
Last active September 29, 2023 17:28
Show Gist options
  • Save txmutt/308109b6e595c990832c8fe5cbf20806 to your computer and use it in GitHub Desktop.
Save txmutt/308109b6e595c990832c8fe5cbf20806 to your computer and use it in GitHub Desktop.
from argparse import ArgumentParser
import subprocess
import shutil
import gzip
import sys
import os
# build list of offsets to check for chip clock rates based on the VGM version.
# if a chip's clock rate is not null, then it's (probably) used during playback,
# and stems for it should be rendered
def getVGMChips(header):
version = header[8]
print(f"VGM format version 1.{version:02X}\n")
chisps = []
# version 1.00
clocks = {0x0C: ("SN76496" , 4), 0x10: ("YM2413" , 14)}
if version >= 0x10: # version 1.10
clocks.update({0x2C: ("YM2612" , 7), 0x30: ("YM2151" , 8)})
if version >= 0x51: # version 1.51
clocks.update({0x38: ("SegaPCM" , 16), 0x40: ("RF5C68" , 8), 0x44: ("YM2203" , 6), 0x48: ("YM2608" , 16), 0x4C: ("YM2610" , 16),
0x50: ("YM3812" , 14), 0x54: ("YM3526" , 14), 0x58: ("Y8950" , 15), 0x5C: ("YMF262" , 23), 0x60: ("YMF278B" , 47),
0x64: ("YMF271" , 12), 0x68: ("YMZ280B", 8), 0x6C: ("RF5C68" , 8), 0x70: ("PWM" , 1), 0x74: ("AY8910" , 3)})
if version >= 0x61: # version 1.61
clocks.update({0x80: ("GameBoy" , 4), 0x84: ("NES APU", 6), 0x88: ("YMW258" , 28), 0x8C: ("uPD7759", 1), 0x90: ("OKIM6258", 1),
0x98: ("OKIM6295", 4), 0x9C: ("K051649", 5), 0xA0: ("K054539", 8), 0xA4: ("HuC6280", 6), 0xAC: ("K053260" , 4),
0xB0: ("Pokey" , 4), 0xB4: ("QSound" , 16)})
# the C140 and C219 share the same offset, so a separate flag must be checked
# to determine which of the two is actually used
if header[0xA8:0xAC] != b'\0' * 4:
if header[0x96] == 0x02:
chisps.append(("C219", 16))
else:
chisps.append(("C140", 24))
if version >= 0x71: # version 1.71
clocks.update({0xB8: ("SCSP" , 32), 0xC0: ("WSwan" , 4), 0xC4: ("VSU" , 6), 0xC8: ("SAA1099", 6), 0xCC: ("ES5503" , 32),
0xD8: ("X1-010" , 16), 0xDC: ("C352" , 32), 0xE0: ("GA20" , 4)})
# ^ ES5506 at 0xD0, but doesn't seem to be implemented ^ #
# check to see which chips are used
for o, c in clocks.items():
if (header[o:o + 4] != b'\0' * 4) and (c not in chisps):
chisps.append(c)
return chisps
# similar thing for S98 files
# TODO: support versions earlier than 3
def getS98Chips(header):
print("S98 format version 3\n")
chisps = []
chipIDs = {1: ("AY8910", 3), 2: ("YM2203", 6), 3: ("YM2612", 7), 4: ("YM2608", 16), 5: ("YM2151", 8), 6: ("YM2413", 14),
7: ("YM3526", 14), 8: ("YM3812", 14), 9: ("YMF262", 23), 15: ("AY8910", 3), 16: ("SN76496", 4)}
nChips = header[0x1C] # uint32, but max value is 64, so eh
# assume single YM2608 if no chips listed, for back compat with older format versions
if not nChips:
chisps.append(chipIDs[4])
else:
seek = 0x20
for _ in range(nChips):
cID = header[seek]
if cID:
chisps.append(chipIDs[cID])
seek += 0xF
return chisps
def main():
parser = ArgumentParser(description="Generate master and stem tracks from VGM/VGZ/S98 files for use with corrscope")
parser.add_argument("file", help="Path to input file")
parser.add_argument("-l", "--loops", type=int)
args = parser.parse_args()
# check if file is compressed
with open(args.file, "rb") as f:
header = f.read(2)
if header == b"\x1f\x8b": # gzip magic number. most likely a vgz. decompress it
header = gzip.decompress(header + f.read())[:256]
else: # something else
header += f.read(254)
# check file format
if header.startswith(b"Vgm "):
chisps = getVGMChips(header)
elif header.startswith(b"S983"):
chisps = getS98Chips(header)
else:
sys.exit(f"'{args.file}' is not a valid input file")
################################################################################
# additional configuration parameters to pass to VGMPlay. useful for setting things like chip core
# e.g. "YM2612.Core=NUKE"
# consult VGMPlay.ini for more info
additionalConfig = []
ps = []
basecmd = ["VGMPlay64", "-w"]
if args.loops:
basecmd += ["-c", f"General.MaxLoops={args.loops}"]
for a in additionalConfig:
basecmd += ["-c", a]
# VGMPlay does not allow the user to specify a custom output filename,
# so copies of the input file must be made with the filenames we want
# (these will be cleaned up afterwards)
# generate master track
shutil.copy(args.file, "master")
print("starting subprocess for master track...\n")
ps.append(subprocess.Popen(basecmd + ["master"], stdout=subprocess.DEVNULL))
for c in chisps:
mask = (2 ** c[1]) - 1
cmd = basecmd.copy()
# disable all other chips
for d in chisps:
if d[0] != c[0]:
cmd += ["-c", f"{d[0]}.Disabled=True"]
if d[0] == "YM2203" or d[0] == "YM2608" or d[0] == "YM2610":
cmd += ["-c", f"{d[0]}.DisableSSG=True"]
elif d[0] == "YMF278B":
cmd += ["-c", "YMF278B.DisableFM=True"]
for e in range(c[1]):
filename = f"{c[0]}-ch{e + 1}"
shutil.copy(args.file, filename)
mm = mask - (2 ** e)
# handle special MuteMask args for special chips
# TODO: figure out a less gross way of doing this
if c[0] == "YM2203":
if e <= 2: # first 3 FM channels
mm = mm & 0x7
nc = cmd + ["-c", "YM2203.DisableSSG=True", "-c", f"YM2203.MuteMask_FM=0x{mm:X}"]
else: # last 3 SSG channels
mm = mm >> 3
nc = cmd + ["-c", "YM2203.Disabled=True", "-c", f"YM2203.MuteMask_SSG=0x{mm:X}"]
elif c[0] == "YM2608" or c[0] == "YM2610":
if e <= 5: # first 6 FM channels
mm = mm & 0x3F
nc = cmd + ["-c", f"{c[0]}.DisableSSG=True", "-c", f"{c[0]}.MuteMask_PCM=0x7F", "-c", f"{c[0]}.MuteMask_FM=0x{mm:X}"]
elif e <= 12: # next 6 ADPCM channels + 1 Delta-T channel
mm = (mm >> 6) & 0x7F
nc = cmd + ["-c", f"{c[0]}.DisableSSG=True", "-c", f"{c[0]}.MuteMask_FM=0x3F", "-c", f"{c[0]}.MuteMask_PCM=0x{mm:X}"]
else:
mm = mm >> 13 # last 3 SSG channels
nc = cmd + ["-c", f"{c[0]}.Disabled=True", "-c", f"{c[0]}.MuteMask_SSG=0x{mm:X}"]
elif c[0] == "YMF278B":
if e <= 22: # first 23 FM channels
mm = mm & 0x7FFFFF
nc = cmd + ["-c", "YMF278B.Disabled=True", "-c", f"YMF278B.MuteMask_FM=0x{mm:X}"]
else:
mm = mm >> 23 # last 24 wavetable channels
nc = cmd + ["-c", "YMF278B.DisableFM=True", "-c", f"YMF278B.MuteMask_WT=0x{mm:X}"]
else:
nc = cmd + ["-c", f"{c[0]}.MuteMask=0x{mm:X}"]
nc.append(filename)
print(f"starting subprocess for {c[0]} channel {e + 1}...")
#print(nc) # debug
ps.append(subprocess.Popen(nc, stdout=subprocess.DEVNULL))
# wait for all subprocesses to terminate
for p in ps:
p.wait()
print("\ndone. cleaning up...")
# delete copies of input file
os.remove("master")
for c in chisps:
for e in range(c[1]):
os.remove(f"{c[0]}-ch{e + 1}")
if __name__ == "__main__":
main()
@RiedleroD
Copy link

hi, can I use this for my project newdump? I don't have a vgm/vgz backend yet, and this looks complicated, so I'm thinking I don't want to reimplement what's already been done 😅.

@txmutt
Copy link
Author

txmutt commented Sep 3, 2023

you're free to do whatever you want with the code, consider it public domain.

i feel it's important to mention that while this script does parse part of the VGM format, it only does this to determine which command line arguments must be passed to VGMPlay to create the desired stems. it does not perform any sound chip emulation of its own.

with that in mind though, you're more than welcome to take any part of this code and use it wherever you'd like.

@txmutt
Copy link
Author

txmutt commented Sep 3, 2023

here are the format specifications i based the file parsing part of this script on, for your reference:
https://vgmrips.net/wiki/VGM_Specification

@RiedleroD
Copy link

thanks ^^

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment