Convert an MP3 (or WAV/FLAC/...) recording into an AY-3-8910 VGM chiptune that plays in the MSX AY-3-8910/YM2149 VGM Analyzer.
A .vgm file is not audio — it is a timed log of register writes to the
AY-3-8910 PSG (3 square-wave tone channels + 1 noise generator, 16 volume
levels). This tool performs automatic music transcription: it analyses the
recording frame-by-frame and re-synthesises it as a 3-voice chiptune. The
AY-3-8910/YM2149 is the sound chip in the MSX, ZX Spectrum, Atari ST and many
arcade boards.
- Fast (default) — analyses the raw mix and assigns the loudest distinct pitches each frame to the three voices. Quick, depends only on numpy/librosa/soundfile. Best for sparse, melodic material.
- Best (
--separate) — first splits the song into vocals / bass / other / drums stems with Demucs, then gives each stem its own voice: vocals→lead, other→harmony, bass→bass voice, drums→noise. Much cleaner on full mixes. Adds a one-time Demucs/PyTorch install and ~10–60 s of separation per track (cached afterwards).
Either way the result is lossy — three squares plus one noise channel can't hold a full arrangement — but melodic lines and basslines come through well.
pip install -r requirements.txt
# ffmpeg must be on PATH for MP3 decoding:
# macOS: brew install ffmpegdemucs (and the PyTorch it pulls in) is only needed for --separate; the fast
path runs without it. The first --separate run downloads the htdemucs model
weights (~80 MB).
# fast path
python -m mp3_to_vgm.cli song.mp3 # -> song.vgm and song.vgz
python -m mp3_to_vgm.cli song.mp3 out.vgm --self-check
# best quality: stem separation
python -m mp3_to_vgm.cli song.mp3 out.vgm --separate --self-checkLoad the resulting .vgm or .vgz at https://hrubix.github.io/VGM_Analyzer/.
Options:
| Option | Effect |
|---|---|
--separate |
split into stems and map one source per voice (best quality) |
--demucs-model NAME |
Demucs model for --separate (default htdemucs) |
--demucs-device D |
torch device mps/cpu/cuda (auto-detected) |
--max-voices {1..3} |
cap simultaneous tone channels (drop inner harmony) |
--no-noise |
disable the noise/percussion channel |
--gain X |
volume scaling before quantisation (e.g. 2.0 if too quiet) |
--fps {50|60} |
frame/timing rate: 50 = PAL, 60 = NTSC |
--clock HZ |
AY input clock (default 1789772, MSX = 3.579545 MHz / 2) |
--no-vgz |
don't also write the gzipped .vgz |
--self-check |
validate the written .vgm header and stream |
The pipeline is a chain of small stages, each owning one concern:
- separate.py (only with
--separate) — split the mix into four stem WAVs with Demucs (get_model/apply_model), written viasoundfileand cached under.stems_cache/next to the output so re-runs are instant. - audio.py — decode to mono PCM and run an STFT with one column per VGM
frame (50 fps → 882-sample hop, matching the VGM
0x63wait), plus per-frame onset / flatness / centroid features. - transcribe.py — two paths producing the same
Framelist:transcribe(mix): peak-pick the strongest distinct pitches, assign to channels A/B/C with continuity, route percussive frames to noise.transcribe_stems(--separate): one source per voice (bass low-clamped, vocals as lead,otherpolyphonic for harmony) with adaptive channel allocation and gated drum→noise.
- ay.py — map frequency → 12-bit tone period and amplitude → 4-bit log
volume; the
RegisterFiletracks the 14 registers and emits only changed values (delta encoding). - vgm.py — write the VGM v1.51 header (AY8910 clock at
0x74) plus theA0 aa dd/63/66command stream; also gzip to.vgz.
Demucs always emits a drums stem; on a kit-less acoustic recording it contains
only breath/bow/bleed. Routing that to noise unconditionally produces a constant
hiss, so the drum→noise mapping is gated: noise is disabled entirely unless
drums are a meaningful share of the whole-mix energy (≥8%), and per frame it
only fires on energy-dominant onset transients. The converter prints
drums share of mix = X% — check that first if percussion is missing or
over-present.
Reducing full polyphonic audio to 3 squares + 1 noise is inherently lossy.
Sparse, melodic source material transcribes best; dense mixes and vocals come
out approximate even with --separate. Tune results with --separate,
--gain, --max-voices, and --no-noise.