QMidiGen 0.1.4: Dead Fields, Live Presets, and a Grudge Against cxx-qt

TL;DR: QMidiGen 0.1.4 shipped on 2026-06-01. The headline isn’t a feature — it’s that the rotation test from 0.1.3 turned out to be a dead-code detector. Used as a continuous quality gate all through 0.1.4 development, it kept surfacing config fields that were parsed out of YAML, stored, and then never actually used — drum_intensity ignored in two of three drum paths, battle urgency gated on the wrong flag so FleetingEscape silently lost its escalation, velocity ceilings missing from the standard melody path, a boss-bass check covering 2 of 4 boss subtypes. None of these were crashes. They were “the metrics look wrong and you’d never have noticed by ear” bugs, which is exactly the class the fast loop is good at. Also in here: why the JRPG presets are YAML (and how that file grew into something that needed its own docs), a section where I complain about cxx-qt and then go check whether the alternatives are any better (they aren’t, yet), and a round of melody/harmony quality work that includes a helper whose entire job is to stop a Trumpet above A5 from taking the roof off the mix.


SectionSummary
Dead Code Found, Dead Code FixedThe rotation test stops finding crashes and starts finding fields nobody was reading
The JRPG YAML PresetWhy YAML, and how one config file grew into a documented format
cxx-qt: Complaints Are In OrderThe friction, named, plus a look at the alternatives
Melody and Harmony QualityConsonant motifs, answering voices, and capping the screech
DocsNew authoring guide, MCP reference, and a stale comment that finally died

Dead Code Found, Dead Code Fixed

The rotation test I added in 0.1.3 was supposed to catch crashes and structural weirdness — empty bars, doubled tracks, flat dynamics. That’s what I built it for. What it actually does, when you run it after every change for two weeks straight, is make the feedback loop fast enough that you start noticing fields you’re reading but not using.

A procedural generator parses a pile of config — instrument pools, intensities, algorithm flags — and the failure mode nobody warns you about is that you can plumb a field all the way from YAML into a struct, store it, and then forget to actually branch on it. It compiles. It runs. The metrics just come out flat or wrong in a way that looks like a design decision instead of a bug. Without a loop that’s fast enough to compare 51 subtypes at a glance, you assume it sounds that way on purpose. 0.1.4 was mostly me finding out it did not, in fact, sound that way on purpose.

drum_intensity was dead in two of three drum paths

Every JRPG YAML style carries a drum_intensity field, 0.0–1.0. The orchestral percussion path read it and scaled accordingly. The combat kit path and the light kit path ignored it entirely. Every style produced the same drum volume regardless of what the YAML said.

The tell was in the rotation metrics: drum velocity was a flat line across subtypes that were supposed to differ. ModernBattle sits at drum_intensity: 0.90 in jrpg.yaml. BossFight sits at 1.0. They were hitting at exactly the same level. The fix maps intensity into a usable range and applies it as a velocity multiplier:

let intensity = 0.85 + 0.15 * drum_intensity;
let vel = (base_vel as f32 * int_scale) as u8;

ModernBattle now hits softer than BossFight, which is the entire reason the field exists.

is_battle_algo was bypassing the YAML preset

This is the sneaky one. Battle-urgency features — accent skeleton, response voices, step-aside solos — were gated on subtype.is_battle(). That’s the enum flag. It checks which category the subtype falls into in Rust. It does not check the YAML preset’s drum_algorithm field.

Those two things agree most of the time, which is why this hid for so long. They disagree on FleetingEscape. FleetingEscape lives in the Tension & Escape category, so is_battle() returns false — but its YAML preset sets drum_algorithm: battle on purpose, because that’s the sound it wants. The result: FleetingEscape had its battle urgency silently switched off, against the explicit instruction in its own preset.

The fix is to read is_battle_algo from the resolved preset’s drum_algorithm everywhere, not from the enum:

let is_battle_algo = preset.drum_algorithm == DrumAlgorithm::Battle;

The point of a preset system is that the preset wins. When it doesn’t, you get exactly this — a config value that’s right there in the file, being quietly overruled by a hardcoded enum check three layers down.

The standard melody path had no velocity ceiling

In 0.1.3 I added velocity ceilings to short notes on the battle melody path, to stop the fast cells from screeching. The fill_bar standard melody path never got the same treatment. So quiet-register subtypes — Castle, Frontier — were letting Trumpet and Solo Violin notes run uncapped in their upper registers. The battle path was fixed; the standard path was just missed. Now both apply the ceiling.

is_boss only covered half the boss subtypes

The combat bass is_boss flag — the one that switches from a regular bass fill to the ominous boss pattern — checked exactly two subtypes:

matches!(subtype, BossFight | FinalBoss)  // old

There are four subtypes in JrpgCategory::Boss. EpicConfrontation and GrandFinale were getting the regular bass fill, despite being bosses. Fixed by asking the category instead of enumerating subtypes by hand:

cfg.jrpg_subtype.category() == JrpgCategory::Boss  // new

Enumerating cases by hand is how you end up two short. Ask the data what category it’s in.

ALSA and SDL3 console spam

Not a generator bug, but the same flavor of “the wrapper wasn’t covering what I thought it was.” suppress_stderr was only wrapping new_fluid_audio_driver. The trouble is that ALSA device enumeration happens earlier — at new_fluid_settings() and new_fluid_synth() — so dozens of unable to open slave and SDL3 not initialized lines were firing before the wrapper ever took effect. The fix extends the suppression to wrap the entire FluidSynth engine construction sequence, not just the driver call.

The through-line for this whole section: the rotation test doesn’t find these because it’s clever. It finds them because it’s fast. When drum_intensity does nothing, the flat-velocity line is visible at a glance across 51 subtypes. When is_battle_algo overrules a preset, FleetingEscape’s row reads wrong. Slow the loop down and every one of these reads as “I guess that’s just how it sounds.”

The JRPG YAML Preset

People ask why the presets are YAML and not, say, a Rust module or a binary format baked into the build. Fair question. Here’s the case.

Diffable artistic data. Instrument pool changes, chord progressions, tempo ranges — these are the content of the generator, and they change constantly. In YAML they show up in git diff as meaningful lines. In a binary blob they show up as “binary files differ,” which is useless. In Rust they show up as a recompile.

Separation of artistic data from generator code. This is the real reason. A musician or a hobbyist who doesn’t read Rust can fork jrpg.yaml, swap the instrument pools, rewrite the chord progressions, retune the tempos, and produce a completely different genre without touching a line of generator code. That’s only possible if the artistic decisions live somewhere a non-programmer can reach.

User-extensible at runtime. Drop a .yaml file in ~/Documents/qmidigen/presets/ and it loads on startup. No recompile, no rebuild, no toolchain. You can ship a new style as a single text file.

Validated at load time. Bad enum names, out-of-range values, unknown fields — all caught and reported with a clear message when the file loads, not swallowed silently. The format being human-readable doesn’t mean it’s a free-for-all.

Parity tests enforce lockstep. There’s a test that checks jrpg.yaml against the JrpgSubtype enum in Rust. Add a subtype to one without the other and cargo test fails on the spot. The format is editable by hand, so the tests make sure a hand edit can’t drift out of sync with the code that consumes it.

How jrpg.yaml grew

It started small. In 0.1.0 the YAML was a thin config layer over hardcoded Rust pools — it barely did anything. As more fields got plumbed through, it turned into the primary instrument data source: 0.1.2 added avoid_programs and drum_intensity, 0.1.3 activated velocity_scale. By 0.1.4 the format was complex enough that it needed its own documentation, so docs/preset-authoring.md now ships — a full field reference with a worked “Seaside Town” example, the enum value catalog, and the rhythm cell and chord progression formats written out. You write a doc when the thing stops being self-explanatory, and the preset format stopped being self-explanatory. The format also has a few mechanics that didn’t make it into the docs.

cxx-qt: Complaints Are In Order

I use cxx-qt to bridge the Rust generator to a Qt 6 / QML front end. It works. I want to be clear about that before I spend several paragraphs being unkind to it. It works, and I keep using it, and that’s roughly where the enthusiasm ends.

The complaints, in order of how much time they’ve cost me:

Signal handlers fail silently when naming conventions change. QML Connections handlers use camelCase internally, but cxx-qt 0.8 officially moved generated signal names to snake_case. If you don’t update your handler names to match, they just… don’t fire. No compile error. No runtime error. Silence. The only hint anything was wrong came from Qt 6’s validation warnings, which I eventually suppressed with ignoreUnknownSignals: true — so now the warnings are gone too and the failure mode is even quieter. Great.

Rust-side property setters don’t reliably trigger QML Connections handlers. Setters called from QML do. Setters called from Rust are a coin flip. This asymmetry has burned the project more than once, to the point where it’s a documented gotcha in the project’s own SKILL.md. It’s not a bug you find and fix — it’s a pattern you have to remember every single time you add a property.

The Cargo 1.77+ lib+bin conflict. Carried over from 0.1.3 and still ugly. Adding src/lib.rs caused cargo: directives from cxx-qt-build to stop reaching the linker, because of how Cargo 1.77 changed mixed-directive handling. The fix is platform-conditional build.rs surgery plus a lib_stub.rs behind a feature gate. It works on Monday. That’s the bar it clears.

Qt 6’s stricter QML linting. anchors.verticalCenter inside a Layout context now generates a warning. The .qml files mostly work fine — these are warnings, not errors — but you have to go hunt them down across files that aren’t actually broken, which is its own kind of tax.

Version churn. Signal handler naming changed between 0.7 and 0.8, which meant sweeping through every QML file to rename handlers. When the bridge layer changes its conventions, the cost lands on your whole UI.

So what are the alternatives

I went and checked, because complaining without checking is just whining. The state of Rust-to-Qt right now:

So the conclusion is the unsatisfying one: cxx-qt is the least-bad option available. The alternatives are preliminary, abandoned, or a ground-up rewrite. This isn’t an endorsement — it’s a grudging “it works, and the others don’t work better yet.” When qtbridge-rust lands on crates.io I’ll look again. Until then I’ll keep remembering that Rust-side setters don’t fire QML handlers, because nobody’s going to remind me.

Melody and Harmony Quality

The generator side got real attention in 0.1.4, mostly aimed at making generated lines sound intended rather than like a random walk that happened to land on pitches.

Consonant-biased motifs. Motif generation is now weighted toward consonant intervals — roughly 50% stepwise motion, 30% thirds. A motif built out of stepwise and triadic motion reads as a deliberate hook; one built out of uniformly random intervals reads as someone falling down a piano. The bias is the difference between a melody and a sequence of notes.

An answering voice that actually answers. The response voice on channel 7 now cycles through motif transforms per phrase: inversion → retrograde → retrograde-of-inversion → augmentation. Contour analysis steers the answer’s starting register to complement the call’s arc — a rising call gets a descending answer. And the whole thing is attenuated 15% in velocity so it answers the lead instead of fighting it. Call and response only works if the response knows its place.

pitch_register_vel — the anti-screech helper. This is the fix for the “too hot” trumpet entries on Castle and Frontier. Bright sustained instruments in their extreme upper register are physically loud, and the generator was treating a Trumpet on a high A the same as one in the staff. The helper reduces velocity for those instruments above a per-instrument threshold:

Above the threshold it pulls 4 velocity points per semitone, capped at 28 points of reduction, with a floor of 40 so the note doesn’t disappear entirely. The result is that a high entry sits in the mix instead of becoming the whole mix.

Section-aware drums. Chorus and Bridge sections hit harder; Verse and Outro pull back. Every non-Intro section gets a crash accent on bar 0 to mark the transition. Drums following the song’s energy arc instead of holding one level the whole way through.

Battle boost cap raised 1.50 → 1.60. The old 1.50 cap was quietly preventing genuine escalation in final sections. The velocity ceiling work from 0.1.3 left more headroom than 1.50 could use, so the cap was the thing holding the final push back. Raising it to 1.60 lets the end of a battle actually escalate.

There’s a soundfont-side change in the same spirit: electric guitars — Overdriven (29) and Distortion (30) — got promoted to the 1.40× note-length extension tier, up from the 1.15× acoustic tier. They’re sustained instruments in practice and were getting clipped like plucked strings.

Docs

0.1.4 is the first release where the documentation grew faster than the feature set, which feels like a milestone of some kind. New:

And a small correction that’s funnier than it should be: the generaluser_gs.yaml profile carried a comment that read velocity_scale: 1.0 # NO-OP. That field has been active since 0.1.3 — the comment just never got updated. So for an entire release cycle the comment was confidently telling anyone who read it that a live field did nothing. A stale comment that lies to you is strictly worse than no comment, because the no-comment version at least makes you go read the code. It’s corrected now.