Last active
September 29, 2023 17:28
-
-
Save mmimigaa/308109b6e595c990832c8fe5cbf20806 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
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.
here are the format specifications i based the file parsing part of this script on, for your reference:
https://vgmrips.net/wiki/VGM_Specification
thanks ^^
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 😅.