QMidiGen 0.1.3: The Generator Got a Stress Test

TL;DR: QMidiGen 0.1.3 is out. The headlining addition is a rotation test tool that generates all 51 JRPG subtypes across hundreds of seeds — no UI, no audio hardware — and reports structural quality metrics in about 103 seconds for 51k songs. Alongside that, a librosa-based WAV analysis pipeline is now running across 470+ measured tracks: crank the BPM way up, render over 500 WAV files in under a second, parallelize feature extraction across 8 workers, and score everything against the source JRPG corpus — which had a few things to say about assumptions that were based entirely on vibes. Battle generation got the most substantive overhaul yet: stinger outro archetypes, a double-channel-3 bug that had been quietly duplicating tracks, and a two-tier velocity ceiling that stopped flat dynamics from washing out the driving energy of 16th-note combat cells. Raising the base velocity from 65 to 82 to match measured JRPG source material was the right call, and also immediately broke all 27 soundfont profiles, which then each needed individual recalibration. The build system also hit a Cargo 1.77 regression that took some creative build.rs surgery to survive.


SectionSummary
The Rotation TestGenerating thousands of songs fast to find structural bugs
WAV Analysis: librosa, Parallel Processing, and What the Data Actually Said500+ WAVs in under a second, parallel feature extraction, and calibration findings that were humbling
The Velocity ProblemWhy 65 was wrong, and the cascade that followed
Battle Generation OverhaulOutro archetypes, the ch3 double bug, guitars that don’t belong, and texture additions
Soundfont Profile RecalibrationRecalibrating all 27 profiles after the base velocity change
Build System PainCargo 1.77+, lib+bin conflicts, and lib_stub.rs
UI AdditionsPer-track randomize buttons and a transport visualizer
CI/CD: SHA256 ChecksumsArtifact checksums across three platforms

The Rotation Test

The fundamental problem with a procedural music generator is that it has a combinatorially large output space and you can only listen to one song at a time. Manual testing means: generate a song, notice something weird, trace it, fix it, generate another song. That works fine when the bug is obvious and reproducible. It works badly when the bug only shows up in certain subtypes, certain seed ranges, or only after a specific combination of stanza roles fires. By the time 0.1.2 shipped, there were 51 subtypes and “does this still generate correctly” had become an open question for any generator change.

The rotation_test tool solves this by generating every subtype in JrpgSubtype::all() across N seeds — no UI, no audio driver, no FluidSynth — and analyzing each song structurally. It lives in tools/rotation_test/ as a standalone workspace member, pulls the pure-Rust music modules directly via #[path], and runs via cargo run -p rotation_test. At 1000 seeds (51k songs), it finishes in about 103 seconds. At 10 seeds — the quick sanity check — it takes a few.

What it actually checks, per stanza:

The output for a clean run looks like this:

[OK] Combat                10/10 m2:22% rep:54% vel:31
[OK] BossFight             10/10 m2:19% rep:61% vel:28
[!!] ModernBattle          10/10 m2:18% rep:48% vel: 3
       → flat dynamics (vel range = 3)

That vel: 3 on ModernBattle was real. The unconditional velocity ceiling on all notes — including the fast 16th-note cells — was compressing everything to the same value. The rotation test caught it; I would not have caught it in manual listening without specifically generating a lot of ModernBattle and paying close attention to that specific problem.


WAV Analysis: librosa, Parallel Processing, and What the Data Actually Said

Structural metrics are useful right up until you realize they can’t hear. The rotation test will catch flat dynamics, an empty bar, a crash. It won’t catch “the counter melody sounds like it wandered in from a different song” or “this subtype always resolves in the wrong key.” Those are listening problems, and you don’t solve them with counters.

The approach: render every song at a dramatically elevated BPM — fast enough that the audio compresses to a fraction of its normal runtime — then slow it back down to something listenable and analyze it as actual audio. The speed trick is what makes the whole thing tractable. FluidSynth’s export API handles the render, and cranking the tempo means over 500 WAV files land on disk in under a second — before you’d finish the first bar at normal tempo. That number alone made the overnight session worthwhile.

The Pipeline

samples/analyze_wav.py is 561 lines of Python built on librosa and concurrent.futures.ProcessPoolExecutor. Eight workers by default, configurable higher. The worker function is a top-level module function — _analyze_one(args) — not a lambda or nested closure. That detail matters: ProcessPoolExecutor ships work to subprocesses via pickle, and anything that isn’t picklable just silently dies in ways that are fun to debug at midnight. Top-level functions are picklable. You can do anything in Python, as long as you know what you’re doing.

Per file, it extracts:

The companion script, analyze_jrpg.py (another 567 lines), does the same work at the MIDI level — weighted-average BPM across tempo maps, loop-bar estimation via autocorrelation on bar-density histograms (testing 4, 8, 12, 16, 24, 32, 48, and 64-bar periods), key detection from pitch-class distributions, GM program assignment by role. Melody = highest note count. Bass = lowest average pitch. Harmony = everything else. Between the two scripts there are 1,128 lines of Python doing nothing but measuring music.

What the Data Actually Said

Running both pipelines against 470+ tracks across three complete JRPG soundtracks — a remastered 16-bit RPG (41 tracks), a modern HD-2D RPG (84 tracks), and a classic SNES RPG (56 tracks) — plus 13 individual cues from half a dozen other titles spanning the 1990s through modern indie RPGs produced a correction table that was, diplomatically, humbling:

SubtypeOld BPMNew BPMWhat the data said
Combat170165One classic = 162, one modern equivalent ≈ 168
ModernBattle152165Aligned with measured combat range
EpicConfrontation162138Two boss variants from the same OST: 110 and 165; midpoint 138
Dungeon115132Measured dungeon cues are considerably busier than assumed
LastDungeon92136(see below)
VictoryFanfare density0.85/sec1.35/secOne measured fanfare = 6.1 onsets/sec — busiest cue in the corpus
FinalBoss keyminor onlymajor + minorOne well-known final boss theme is actually C major

That LastDungeon row is the one. The assumption baked into the old generator was that the final dungeon is a slow, oppressive, atmospheric crawl — appropriately grim for the end of the world. The measured data disagreed entirely. Endgame areas are consistently the fastest and densest dungeon cues in every corpus measured. Not the battle themes — the dungeons. The urgency is pegged because you’re supposed to feel like you’re out of time. The old generator had LastDungeon at 92 BPM like it was a leisurely stroll through a haunted hedge maze. Forty-four BPM off, in the wrong direction, based entirely on vibes. Burn.

FinalBoss being minor-only was also vibes. One of the most iconic climactic boss themes in the genre — the kind you’d recognize immediately — is C major. VictoryFanfare at 0.85 onsets per second was a polite shuffle; one measured victory fanfare hits 6.1, the single most event-dense cue in 470 tracks. It’s not a victory jingle, it’s a wall of notes.

Universal patterns that held across every title measured:

These aren’t guesses anymore. They’re numbers from a script.

Variance Flagging

The --compare mode loads generated WAVs alongside the source JRPG corpus and scores MFCC cosine distance per subtype. A score of 1.0 is identical timbre; 0.0 is unrelated. The same corpus that calibrated BPMs and key distributions now has a timbral benchmark to check generated output against.

The intra-subtype variance report is the part with actual teeth. For each subtype with two or more generated songs, it flags:

The rotation test tells you a song exists and has notes. The variance report tells you whether your 51 subtypes are generating 51 distinct kinds of music or just shuffling the same 3 ideas around with different names. Those are distinct products.

The LLM Layer

Routing slow-motion audio through an LLM to reason about melodic phrasing, call-and-response structure, and “why does this harmony keep landing in the wrong register” is still ahead. The waveform pipeline is running and already finding things the structural pass misses entirely. That’s enough for now.


The Velocity Problem

A comparison of 51k generated songs against 228 source JRPG MIDIs — a half-dozen classic RPG soundtracks spanning the 16-bit era — turned up a stark discrepancy:

MetricGeneratedSource
Mean velocity67101
Bar repetition40%76%
Tracks per song~8~13
Counter melody threshold8 bars

Generated songs were about 34 velocity points too quiet across the board. The source composers were hitting hard, repeating their hooks far more than felt comfortable, and stacking more voices than the generator was building. These aren’t aesthetic preferences — they’re measurable facts about what the source material actually sounds like.

Raising phrase_velocity base from 65 to 82 was the straightforward part. The measured output mean of 67 is higher than the base because velocity variation pushes some notes above the floor — the base is what actually needed moving. In practice, raising it immediately invalidated every soundfont profile that had velocity ceilings calibrated against the old 65 base. The GeneralUser GS profile went from 5 ceilings to 47. All 27 included profiles needed to be remeasured and adjusted to avoid double-compression — a ceiling of 80 that barely touched velocity at the old base was now catching notes too aggressively and squashing dynamics in the wrong direction.

The repetition fix meant changing melody generation to return to literal motif restatement more often, even within a section. The counter melody threshold dropped from 8 bars to 4 so more songs actually get the second voice. Neither of these felt right intuitively but both matched source behavior.


Battle Generation Overhaul

Battle was the most-changed area in 0.1.3, which is fitting because battle generation had accumulated the most technical debt from the earlier “just get something working” era.

Outro archetypes

Battle stingers (the intro-role stanza that fires when combat starts) now have an OutroArchetype enum that mirrors the wind-up gesture system introduced in 0.1.2. Three archetypes:

The bias rules matter: non-boss subtypes are 60% QuickCutoff and capped hard at 2 bars, because a long outro drains urgency from the loop. Boss subtypes get the full range. Pursuit always QuickCutoffs — it literally can’t afford to breathe. The render_outro_anacrusis seam fix runs on the last outro bar to build the rising gesture back into the next wind-up, so the loop transition doesn’t clunk.

The channel 3 double bug

Channel 3 hosts either the Melody 2 double (battle) or the Counter Melody (non-battle) — never both. Before 0.1.3, battle subtypes that happened to have a counter: pool entry in jrpg.yaml were generating both tracks, with both trying to write to the same MIDI channel. The result was instrument-change conflicts mid-song and, occasionally, a doubled lead that read as absurdly loud in the velocity analysis.

One guard sorted it: add_counter gated by !use_battle_melody. If battle melody is active, counter melody doesn’t run. The rotation test’s M2 similarity metric is what made the symptom measurable rather than just a vague sense that something sounded off.

Two-tier velocity ceiling

The velocity ceiling system was applying the per-program profile cap unconditionally to all notes. This is correct for sustained tones — a Trumpet held for two beats at 112 velocity will blow the doors off the mix. It’s wrong for short attack notes. A 16th-note driving cell at 90 BPM with every note capped at the same value produces what the rotation test correctly calls “flat dynamics”: velocity range of 3, which sounds robotic and lifeless.

The two-tier guard:

let vceil = velocity_ceiling_for(program, &cfg.soundfont_profile);
let vel = if d >= 0.75 {
    vel.min(vceil)
} else {
    vel.min(vceil.saturating_add(20))
};

Sustained notes (d ≥ 0.75) get the hard ceiling. Short attack notes get ceiling+20 headroom. The rotation test’s flat dynamics warning is what surfaced this — without it, the only way to catch it was staring at a piano roll and noticing that all velocities were converging on the same number.

Guitars do not belong here

Every guitar program (24–31) was removed from all battle subtype melody pools. Guitars return false from is_sustained_instrument, which means battle melody rendering doesn’t apply the 1.4× note-length extension for them. On a dense 8th-note driving cell at 160 BPM, that dog don’t hunt — you get rapid staccato clicks rather than anything resembling a melody. This is the kind of thing that sounds wrong immediately when you hear it but is easy to miss when you’re staring at instrument list indexes. Replacements: Choir (52), Synth Brass (62), Violin (40), Harmonica (22) for Showdown’s western flavor.

The BossFight bass pool also had Slap Bass in it. The FinalBoss bass pool had Distortion Guitar. Both were removed without ceremony.

Phrygian cadences

EpicConfrontation and AncientEvil always promised Phrygian color in their design notes. They now actually deliver it. Chord progressions [1, 0, 5, 4] and [1, 0, 4, 0] implement the ♭II→i cadence that makes that characteristic dark-theatrical resolution work. These are the only two subtypes with Phrygian cadences — putting them anywhere else would be wrong for the subtype’s intended sound.

Texture additions

A few smaller generator changes worth noting:

Per-section seed — each stanza now draws its chord progression from song_seed.wrapping_add(i * GOLDEN) so sections don’t repeat the same root sequence. Previously all sections were pulling from the same seed position, which meant verse, chorus, and bridge could end up with identical harmonic motion.

Rhythmic pad pulse — ModernBattle, EpicConfrontation, and GrandFinale pad tracks now use a beat-synced 8th-note pulse pattern instead of holding a static chord. This matters more than it sounds; a sustained pad underneath a driving 16th-note battle line reads as glue, but the same pad in a modern/orchestral context was sitting there doing nothing interesting.

Grace notes, ghost notes, neighbor tones, anticipations — added to melody and harmony tracks for textural variety. Not dramatic, but the difference between a generator that places pitches and one that phrases them.


Soundfont Profile Recalibration

All 27 bundled profiles were remeasured after the base velocity change. The GeneralUser GS profile (generaluser_gs.yaml) went from 5 ceilings to 47, calibrated against newly-pooled bright programs that were now hitting at 82 instead of 65. Two new profiles were added: colombo_mt32.yaml and phoenix_mt32.yaml.

The Timbres of Heaven overhaul from 0.1.2 also needed a follow-up: the velocity_scale: 0.90 multiplier had been a dead field — the code that applied it didn’t exist yet. It now actually runs in 0.1.3, which meant Timbres of Heaven’s effective velocities dropped by 10% globally, requiring ceiling adjustments to avoid double-compressing the programs that already had caps. Trumpet (56) had been in avoid_programs but turned out to be fine once the scale was active and the ceiling was tuned — it’s available again for Castle and VictoryFanfare use.

The pattern for all profiles: velocity_scale multiplies vel_boost at generation time; ceilings are applied after scaling. If both exist on the same program, they stack, which is almost always too aggressive. The recalibration work was mostly finding and removing that double-compression.


Build System Pain

Adding a src/lib.rs for the rotation test crate created a lib target alongside the existing bin. In Cargo 1.77+, when a build script emits any cargo:: (double-colon) directive, cargo: (single-colon) directives from cxx-qt-build stop reaching the linker. The symptom is undefined symbol: cxx_qt_init_qml_module_com_qmidigen at link time — not a compile error, a link error, which takes slightly longer to trace back to “oh, Cargo changed how it handles mixed directive styles.”

Re-emitting the critical archives in build.rs via cargo::rustc-link-arg-bins after CxxQtBuilder::build() sorted it. Platform-conditional:

// Linux
"-Wl,--push-state,--whole-archive",
"-lcxx_qt_lib_qml_plugin",
"-Wl,--pop-state",

// Windows MSVC
"/WHOLEARCHIVE:cxx_qt_lib_qml_plugin.lib",

// macOS
"-Wl,-force_load,libcxx_qt_lib_qml_plugin.a",

lib_stub.rs is the companion piece: the actual src/lib.rs points at the stub with required-features = ["_internal_lib_target"], which suppresses the lib target from every normal build invocation. All playbook cargo build calls use --bin qmidigen for the same reason.

This is not elegant. It’s the kind of thing you write when Cargo changes behavior on you and you need the build to work on Monday.


UI Additions

Two UI additions in 0.1.3:

Per-track randomize buttons in the piano roll header — ↻ randomizes the instrument, ?♪ randomizes the notes. Both are lock-aware: locked tracks skip randomization silently. The motivation was making it easier to explore variations on a generated song without regenerating the entire stanza.

Audio visualizer on the transport bar — an 8-bar beat-synchronized canvas animation. Uses fillRect, not roundRect — the latter was added in Qt 6.4 and silently does nothing on earlier versions. Uses a Connections block to react to isPlayingChanged from outside the Canvas rather than a local onIsPlayingChanged property handler, which would be unreliable under cxx-qt’s signal model. Both of these are documented gotchas at this point.


CI/CD: SHA256 Checksums

Release artifacts now ship with checksums. Each build job generates per-artifact .sha256 files using the platform-native tool (sha256sum on Linux, shasum on macOS, Get-FileHash on Windows). Both create-release and update-latest-release concatenate them into a single SHA256SUMS file in standard format, upload to the Generic Package Registry, and link it from the release page.

The pipeline also got deduplication rules to stop double-builds on push+tag events, and the latest tag is now suppressed from pipeline triggers so a latest tag update doesn’t immediately re-trigger its own release pipeline. Neither of these were showstoppers but both were producing confusing CI behavior that was worth cleaning up.


Open Questions

The measurement pipeline answered a lot of “is this calibrated” questions and raised new ones. The corpus is three full soundtracks plus scattered individual cues — is that actually representative of JRPG music, or am I now overfitting the generator to three specific composers’ habits? The LLM audio analysis layer is still ahead of me — is routing slowed-down WAVs through a model going to surface anything the MFCC and onset metrics can’t, or am I about to spend a weekend confirming what the numbers already say? And the rotation test catches structural bugs across 51k songs in 103 seconds — what’s the class of bug that only shows up by ear, that no amount of fast structural checking will ever flag?