circus2bmson
whatever feels fun to bmson
| Written in | C++ |
|---|---|
| Last updated | 13 Jul 2026 |
Convert vintage tracker music into bmson,
the JSON chart format for BMS. Accepts any format libopenmpt plays — MOD
(ProTracker & variants), XM, S3M, IT, and dozens more — plus General MIDI
(.mid/.midi), rendered through a SoundFont with FluidSynth, chip music
(NSF/GBS/VGM/SPC/… via game-music-emu) and chiptune trackers (DefleMask
.dmf, Furnace .fur), rendered by the Furnace tracker.
The output is a standard bmson folder with every note placed on the BGM lane
(x: 0) so the result plays back like the original module and can then be
charted by hand (e.g. dragged into BmsONE).
Design
A headless C++ core library plus a thin CLI wrapper, so the core can later link into other projects natively.
The score is read format-agnostically through libopenmpt itself: the module is played once and every row's onset is pinned to the exact sample, so control flow (jumps, breaks, loops, pattern delay) and tempo semantics are whatever libopenmpt actually played — no per-format sequencer to maintain. An independent MOD parser + sequencer is kept in-tree as a validation oracle and must agree with the generic engine in CI.
Keysounds are produced by rendering each pattern channel in isolation (mute all-but-one via libopenmpt's interactive interface) and slicing each stem at note-on times — pitch, effects and panning baked in, reusing libopenmpt's accurate playback instead of reimplementing one.
Timing stays on a musical pulse grid (resolution 240, one tracker row =
60 pulses). BPM events use 6 * tempo / speed when it matches the measured
row duration, and the measured duration otherwise — so pattern delay and tempo
slides keep the grid exactly aligned with the rendered audio.
The stem backends (chip, .dmf/.fur) share a second path — src/stemconv.cpp,
resolution 480 — which takes a song already rendered to one stem per voice and
turns it into keysounds. Note timing there is exact regardless of the grid,
because pulses are computed from frames; the BPM only decides where the
gridlines fall.
Two tiers of fidelity
Backends fall into two groups, and it is worth knowing which one your file is in.
Exact tier — libopenmpt (MOD/XM/S3M/IT/…), MIDI, and the chiptune trackers
(.dmf/.fur). Notes come from the file's own note events, so keysounds are
sliced at the exact onsets and the pulse grid is the module's real tempo.
Reconstruction of the libopenmpt path is gated in CI at ~-80 dB.
For .dmf/.fur those note events come out of Furnace's -view commands
stream, which timestamps every NOTE_ON in engine ticks — no pattern parsing,
and it covers both formats. The tick rate and the real BPM (row duration × the
composer's own rows-per-beat highlight) are read from the module header, so
notes land on exact rows: on a Genesis test module, 100 % of 5 558 notes fall on
an integer row of a 150.00 BPM grid.
This matters more than it sounds. Recovering these notes from audio misses every note that is only a pitch change — a square wave gliding from pitch to pitch never dips in amplitude, so there is no onset to detect — and chip music does that constantly. On that same module, onset detection found 1 473 of the 5 558 notes and turned each melodic phrase into one long keysound.
Inferred tier — chip files (NSF/GBS/VGM/… via libgme). Register-dump formats
carry no note events at all, so their notes still have to be recovered from the
audio (onset.hpp), with exactly the legato blindness described above, and the
BPM is guessed. This is the tier to be suspicious of.
Chip songs produce a lot of keysounds, because every note is sliced out of the rendered audio and so carries its own pitch, envelope state and effects — nothing can be reused the way a normal chart reuses one sample across many notes. A dense Genesis module runs to ~5 500. Two settings collapse that (GUI: Merge keysounds), and the difference between them is worth understanding.
--dedup-tolerance N merges slices that differ by less than N dB. It is safe —
the audio is verified to be near-identical — but it barely helps where you would
most expect it to: on the Genesis DAC drum channel it took 505 keysounds to 501.
The hardware re-renders "the same" drum differently on every hit (its resampling
phase lands somewhere new), so 61 distinct (note, volume) pairs come out as 491
distinct waveforms. No sample-wise comparison can see those as one sound.
--dedup-ignore-phase matches on timbre instead, so they collapse: 5 558
slices → 1 543 keysounds. Two things make it safe. Merges may only happen
between notes of the same identity — same instrument, pitch and volume, taken
from Furnace's command stream — because a spectrum coarse enough to ignore phase
is coarse enough to confuse two bass notes, and merging across pitches would
transpose the music. And since the slices are fragments of a continuously
sounding channel rather than self-contained samples, every keysound is faded at
both edges so it starts and ends at zero and no substitution can step at the
join. The fade-in is short (--keysound-attack, 0.3 ms) so percussive attacks
survive; the fade-out is longer (2 ms) because that edge is what keeps a seam
quiet.
| mode | keysounds | error vs the real mix |
|---|---|---|
| byte-exact (default) | 5 045 | −49 dB |
--dedup-tolerance 40 |
4 434 | −47 dB |
--dedup-tolerance 30 |
3 895 | −37 dB |
--dedup-ignore-phase |
1 543 | −6 dB (almost all of it phase) |
That last figure looks alarming and mostly is not: sample-wise error punishes a phase shift brutally, and the ear barely hears one. Judge it by ear.
Chip charts are turned up by default. Played back at the chip's own level a
converted chart is ~5 dB quieter than everything else in a player's library: a
Genesis mix peaks at full scale but has a ~16 dB crest factor, where a mastered
BMS is limited to 8–10 dB. Limiting the mix properly is not open to us — the
player sums the keysounds, and a time-varying gain would make every repeat of
a note unique and undo the deduplication. So --gain auto (the default, chip
backends only) instead finds the loudest flat gain that clips no more than 0.1 %
of the mix, which is worth about +5 dB because chip peaks are short and sparse.
--gain 0 keeps the original level, --gain N sets it by hand.
Both chip tiers: keysounds do not sum back to the original mix. These chips mix their channels non-linearly in hardware and the emulators model that faithfully, but a BMS player sums keysounds linearly. Measured residual of sum-of-stems vs. the real mix: Amiga -55 dB (linear mixer), PC Engine -33 dB, Game Boy -13 dB, NES -11 dB, Genesis/YM2612 -7 dB. So a Genesis chart is a faithful re-performance, not a bit-exact reproduction. That is a property of the hardware, and it stays.
Status — roadmap
- [x] M0 — CMake project (library + CLI + tools), vendored deps, Linux CI.
- [x] M0.5 — de-risk the stem approach: prove per-channel renders sum back
to the full mix (
tools/stem_sum_check; gated in CI for MOD, XM and IT). - [x] M1 — timeline on a clean pulse grid: note grid,
bpm_events,lines,--max-loopsfor looping songs; MOD parser + sequencer kept as the validation oracle for the generic engine. - [x] M2 — per-channel stems sliced into deduplicated stereo keysounds (exact onsets; ~-80 dB reconstruction residual gated in CI).
- [x] Wider formats — generic score via libopenmpt: XM/S3M/IT and every other libopenmpt format use the same pipeline (XM + IT covered by fixtures in CI).
- [x] OGG —
--format oggencodes keysounds with libvorbis (~70 % smaller folders). - [ ] M3 — validate in beatoraja (drag into BmsONE; confirm the convert -> chart workflow).
- [x] Sub-row precision — the note-delay effect (EDx on MOD/XM, SDx on S3M/IT) places a note part-way into its row, in both pulses and the keysound's onset frame, so off-beat notes land correctly.
- [x] Chip formats (game-music-emu) — libgme renders each chip voice in isolation, onsets are recovered from the audio (audio-domain detection), and the shared stem path turns them into keysounds on a pulse grid.
- [x] Chiptune trackers (
.dmf/.fur) — the Furnace binary renders each chip channel (-outmode perchan) and dumps its note events (-view commands), so these are exact-tier: real notes on the module's real BPM grid. A native.dmfparser turned out to be unnecessary — the renderer already knows. Seeinclude/circus2bmson/furnace.hpp. - [ ] M4 — remaining refinements: macOS CI. (Windows CI + prebuilt
.exeartifact: done.)
Building
Requires a C++17 compiler, CMake ≥ 3.16, libopenmpt, libvorbis, zlib and FluidSynth (for MIDI). game-music-emu (libgme) is optional and enables the chip-music backend (NSF/GBS/VGM/...).
Furnace (for .dmf/.fur) is a runtime dependency, not a build one: the
converter shells out to the furnace binary to render the module's channels.
It is GPL-2.0-or-later with no library target, so invoking the binary — rather
than linking it — keeps this project's licensing independent of it. Install it
(pacman -S furnace, apt install furnace, or
tildearrow.org/furnace) and put it on PATH,
or point at it with $C2B_FURNACE / --furnace <path>. Without it, .dmf and
.fur conversions fail with an install hint; everything else is unaffected.
# Debian/Ubuntu
sudo apt-get install -y libopenmpt-dev libvorbis-dev libfluidsynth-dev \
libgme-dev zlib1g-dev cmake ninja-build
sudo apt-get install -y furnace # optional: .dmf / .fur
# Arch
sudo pacman -S --needed libopenmpt libvorbis fluidsynth game-music-emu zlib \
cmake ninja gcc
sudo pacman -S --needed furnace # optional: .dmf / .fur
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build --output-on-failure
nlohmann/json, dr_wav and miniaudio (GUI) are vendored under
third_party/. libopenmpt, libvorbis and FluidSynth are required system
packages; game-music-emu (libgme) is an optional one — without it the chip
backend compiles to a stub.
GUI (early / minimal)
An optional desktop front-end (Dear ImGui, vendored) is built with
-DC2B_BUILD_GUI=ON (needs GLFW: apt libglfw3-dev + libgl1-mesa-dev, or
MSYS2 mingw-w64-ucrt-x86_64-glfw). It wraps the same conversion as the CLI:
point it at a module (type/paste a path, Browse…, or drag one onto the
window), set the options (WAV/OGG, keysound naming, max-loops, volume ramping,
output folder) and Convert. A SoundFont (.sf2) picker supplies the timbres for
MIDI input (inert for tracker input).
For chip and .dmf/.fur input there is also Boost volume to a normal
level (on by default) and a Merge keysounds selector — Identical only,
−40 dB, −30 dB, or Ignore phase, which reveals an Attack slider. The
log box streams each stage as it goes, because a long chip conversion takes
minutes and is otherwise indistinguishable from a hang.
MIDI preview / mixer. When the input is a .mid/.midi, a Load preview
button auditions it live through the SoundFont (real-time playback via
miniaudio) with Play/Stop and a seek bar. Each MIDI channel and each
instrument gets a live level meter and a gain slider; the converter
already honours the file's own volume/expression (CC7/CC11), and any slider
changes are baked into the rendered keysounds on Convert — so you can fix a
file whose channel/instrument balance is off and hear the result before
exporting.
Input methods degrade gracefully by environment: the path field always
works (no dependencies); Browse… needs a system dialog helper
(zenity/kdialog); drag-and-drop needs GLFW's X11 backend — the app
prefers X11 (XWayland under Wayland), but a native Wayland session with no
XWayland cannot deliver file drops, so use the path field there.
CI publishes prebuilt binaries: circus2bmson-gui.exe in the
circus2bmson-windows-x64 artifact, and a Linux
circus2bmson-x86_64.AppImage (CLI + GUI bundled) in the
circus2bmson-linux-x86_64 artifact. Run the AppImage directly (it needs FUSE —
fuse2 on most distros; otherwise
./circus2bmson-x86_64.AppImage --appimage-extract-and-run).
cmake -S . -B build -G Ninja -DC2B_BUILD_GUI=ON && cmake --build build
./build/gui/circus2bmson-gui
Windows
A prebuilt circus2bmson.exe (plus its DLLs and the sample modules) is
published by CI as the circus2bmson-windows-x64 artifact on each run —
download it from the Actions tab, no toolchain required.
To build it yourself, use MSYS2 in the UCRT64 shell:
pacman -S --needed git mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake \
mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-pkgconf \
mingw-w64-ucrt-x86_64-libopenmpt mingw-w64-ucrt-x86_64-libvorbis \
mingw-w64-ucrt-x86_64-fluidsynth mingw-w64-ucrt-x86_64-glfw \
mingw-w64-ucrt-x86_64-libgme
cmake -S . -B build -G Ninja -DC2B_BUILD_GUI=ON && cmake --build build
Convert
./build/cli/circus2bmson song.it -o out # any libopenmpt format
./build/cli/circus2bmson song.mod # -> song/song.bmson beside the file
./build/cli/circus2bmson song.mid --soundfont GM.sf2 # MIDI (needs a SoundFont)
./build/cli/circus2bmson song.nsf # chip music (needs libgme)
./build/cli/circus2bmson song.dmf --format ogg \
--dedup-ignore-phase # DefleMask (needs furnace on PATH)
./build/cli/circus2bmson --list-formats # every input extension this build accepts
MIDI files carry no audio of their own, so the timbres come from a SoundFont
(.sf2). The prebuilt binaries bundle FluidR3_GM, so MIDI works out of the
box; override it with --soundfont (or the GUI picker), C2B_SOUNDFONT, or a
system soundfont (e.g. /usr/share/sounds/sf2/). Each note is rendered in
isolation through FluidSynth and deduplicated like any other keysound. (The
SoundFont is bundled by CI into the artifacts, not committed to the repo.)
With no -o, output goes to a folder named after the module, beside it — so on
Windows you can just drag a module onto circus2bmson.exe and get a
song/song.bmson folder next to the file. Options:
# circus2bmson <module> [-o DIR] [options]
# --format wav|ogg keysound container (default wav; ogg is ~70 % smaller)
# --name-by channel channel1_001.wav (default; simple, per channel)
# --name-by instrument s05_bass_ch01_A-2.wav (descriptive, group by instrument)
# --name-by lane ch01_s05_bass_A-2.wav (descriptive, group by channel)
# --volume-ramping keep libopenmpt's anti-click ramp (yields more keysounds)
# --no-audio emit the bmson skeleton only (structure, no keysounds)
# --max-loops N unroll a looping section N times
# --soundfont FILE SoundFont (.sf2) for MIDI input
#
# chip / .dmf / .fur only:
# --furnace FILE the Furnace binary (else $C2B_FURNACE, else PATH)
# --gain DB|auto keysound gain (default auto; --gain 0 = the chip's level)
# --dedup-tolerance N merge keysounds differing by under N dB (try 40, or 30)
# --dedup-ignore-phase merge keysounds that sound alike but differ in phase
# --keysound-attack MS fade-in per keysound (default 0.3 with ignore-phase)
# --keysound-fade MS fade-out per keysound (default 2 with ignore-phase)
By default keysounds are named channel{N}_{seq}, so they group by pattern
channel in an editor's sound list with no clutter. --name-by instrument/lane
instead encode the instrument number, its name and the note.
Identical notes are deduplicated by exact audio: onsets are pinned to the sample so repeats are byte-identical, and libopenmpt's volume ramping is off by default (more authentic to Amiga, and it stops a note's attack from depending on the previous note). The remaining distinct keysounds reflect genuine differences — pitch, volume, effects, hold length and stereo panning.
Format notes: IT new-note actions let an old note ring past the next note-on on the same channel; that tail is baked into the start of the following keysound (playback is still faithful — it just makes that keysound less "clean" in isolation).
Try the de-risking spike
./build/tools/stem_sum_check tests/fixtures/neurosys.xm
Layout
include/circus2bmson/ public library headers
src/ core library
cli/ command-line wrapper
tools/ diagnostic / de-risking spikes
tests/ ctest entries + fixtures
third_party/ vendored single-header deps (nlohmann/json, dr_wav)
Licensing
Project code: see repository license. Test fixtures under tests/fixtures/
are Public Domain modules from The Mod Archive — see
tests/fixtures/README.md. Vendored deps:
nlohmann/json (MIT), dr_wav (public domain / MIT-0), Dear ImGui (MIT, GUI only),
miniaudio (public domain / MIT-0, GUI only).
This page is synced from README.md in the circus2bmson repo.