Introduction
This book is a contributor-facing description of Harmonicon’s system
architecture — how the codebase is put together, how its major
subsystems talk to each other, and why they were built the way they
were. It is not a player’s guide (that’s docs/book/, the mdBook under
the repository’s docs/ directory) and it is not a line-by-line API
reference (that’s what doc comments and cargo doc are for). It sits
between those two: the level of detail you’d want before making a
non-trivial change to the engine, or before reviewing someone else’s
non-trivial change to it.
Who this is for
Anyone who wants to work on Harmonicon’s Rust codebase, at any level of familiarity with Bevy or with this project specifically:
- A new contributor orienting themselves before their first change, who needs to know which of the ~20 top-level modules owns the thing they want to touch, and what invariants they need to not break.
- A returning contributor who worked on one subsystem months ago and needs a refresher on how it fits into the rest of the game before touching it again.
- A reviewer checking whether a pull request respects the architecture’s existing boundaries (dependency direction, where pure logic vs. ECS systems belong, which resource owns which piece of state) rather than working around them.
How this book is organized
Each chapter covers one architectural concern, roughly ordered from the
most foundational (how the app is built out of Bevy plugins, how
top-level state is organized) to the most specific (how one particular
feature — the Song Editor, Jam Session, Lessons — is put together
internally). Diagrams are PlantUML, rendered
inline by mdbook-plantuml
at build time — see this book’s own README.md (in
contributing/) for how to build it locally, since that needs a
PlantUML installation this book’s own dependency tree doesn’t pull in
for you.
Every chapter tries to explain design rationale, not just structure:
where there was a real alternative and a reason one was chosen over the
other, that reason is written down. Where the codebase’s own extensive
doc comments (see CLAUDE.md at the repository root — Harmonicon’s own
“guidance for Claude Code” file, itself a dense architectural reference)
already carry that reasoning, this book draws on it directly, but
organized by concept rather than by the chronological order features
were added in.
What this book is not
- Not a tutorial for Bevy itself. It assumes working familiarity with Bevy’s ECS (entities, components, resources, systems, states, plugins) and won’t re-explain those primitives from scratch — see Bevy’s own documentation for that.
- Not exhaustive API documentation. Function signatures and struct fields change; this book describes shapes and relationships that are meant to stay stable across many such changes. When you need the exact current signature of something, read the code — every module this book references is named so you can find it.
- Not a substitute for
CLAUDE.md. That file is deliberately exhaustive, chronologically organized, and optimized for an AI coding agent to load in full on every session; it is the single most information-dense description of “how does X actually work” in this repository, and it is kept rigorously up to date as a hard project rule. This book is a different cut through much of the same knowledge: organized by concept for a human reading it start-to-finish or jumping to one chapter, with diagrams, and with more room to explain why at a levelCLAUDE.md’s bullet-point style doesn’t always leave space for.
Keeping this book current
Architecture documentation rots the moment it stops being read as part of
the normal course of making changes. If you land a change that moves a
type between modules, introduces a new top-level subsystem, or reverses
a dependency direction this book describes, update the relevant chapter
in the same change — the same discipline CLAUDE.md and the player’s
guide already hold themselves to (see that file’s own instructions: keep
planning docs current, prune what’s no longer true rather than letting it
accumulate as stale history).
System Overview
Harmonicon is a rhythm game for diatonic and chromatic harmonica, written in Rust on top of the Bevy game engine (version 0.19). The player plays a real harmonica into a microphone; the game listens, detects the pitch being played in real time, and scores it against a scrolling chart — the same core loop as any note-highway rhythm game (Guitar Hero, Clone Hero, osu!), except the “controller” is an acoustic instrument and a live audio pipeline instead of a button press.
The single-crate-with-many-modules shape
Harmonicon is one Cargo package (harmonicon, edition = "2024")
structured as a library plus several binaries, not a Cargo workspace
of many crates. src/lib.rs is the library root; src/main.rs (the game
itself) and everything in src/bin/ (hole-editor, note-editor,
note-bench — small developer tools, described in
Testing Strategy and the Song Editor chapter) are separate binary crates that
depend on that library and share every subsystem through it. This is a
deliberate, low-ceremony choice: a full Cargo workspace with separate
crates per subsystem would enforce dependency direction at the compiler
level (a real advantage — see Module Boundaries and Dependency Rules for how those rules are enforced without
that today), but at this project’s size the extra Cargo.toml
boilerplate, the friction of moving code between crates while a module’s
boundaries are still being found, and the loss of being able to freely
pub(crate) things across what would become crate boundaries outweigh
that benefit. The module tree inside the one library crate mirrors what
separate crates would look like closely enough that splitting it later,
if the project ever grows to need that, is a mechanical refactor rather
than a redesign.
The top-level modules
src/lib.rs re-exports every subsystem as a pub mod. Roughly grouped
by what they’re for (this grouping is informal — Rust doesn’t nest
these into sub-namespaces beyond the module tree itself, and the
“Module Boundaries” chapter covers the
actual, enforced dependency rules between them):
Low-level, widely shared vocabulary — depended on by almost everything else, and deliberately kept ignorant of the features built on top of them:
song— the chart file format (HarpChart), harmonica layouts and tunings, MIDI-file parsing, and the customAssetLoaderthat turns a chart folder on disk into a loadedSongManifest.audio_system— microphone capture (cpal), the five pitch-detection algorithms, the additive harmonica-voice synthesizer used for playback previews and generated backing, and WAV encode/decode helpers.theme,localization— see Localization and Theming.settings,profile— see Persistence.dialogs— shared, generic UI widgets (buttons, comboboxes, file dialogs, tooltips, scroll areas) used by every screen in the game; intentionally has no idea what a “song” or a “harmonica” is.assets_management— non-chart asset discovery (which songs, themes, harmonica 3D models, and note-head themes exist) and the live filesystem watcher for the~/Harmoniconexternal content folder.scoring— the pure, timing-window/combo-multiplier math shared by real gameplay and the Song Editor’s own practice mode. Deliberately just functions operating on plain data, no ECS types at all — see The Scoring System.
Features built on that vocabulary:
gameplay— the scored Play 2D/3D modes, the gameplay clock, the Bending Trainer, and every in-song HUD overlay. Also whereAppState::Playing’s system schedule is assembled for everyGameplayMode(2D, 3D, and Jam Session) — see the composition-root discussion in Module Boundaries.jam— free-play Jam Session, the generated 12-bar backing track, MIDI multi-track backing, and the improv/call-and-response practice modes.lessons— the guided curriculum: lesson manifests, catalog discovery, prerequisite gating, and per-player progress.song_editor— the in-game chart authoring tool.spectrogram— the live audio visualizer (bar spectrum and oscilloscope styles), reusing the sameAudioFramethe pitch pipeline already publishes rather than re-analyzing audio itself.menu— every menu screen, app-level state routing, and the guided tutorial tour.app— pure, feature-agnostic vocabulary shared across features:AppState,GameplayMode, the currently-selected song, and a handful of “which menu page to land on when this state exits” routing flags. See Application States and Modes.note_bench— pure comparison logic for the pitch-detection benchmark tool (note-bench); see Testing Strategy.
The engine and its major dependencies
Beyond Bevy itself, a handful of dependencies carry specific, non-interchangeable responsibilities worth knowing about up front — each gets its own discussion in the chapter its responsibility belongs to:
| Crate | Role | Discussed in |
|---|---|---|
bevy 0.19 | ECS, rendering, UI, audio playback, asset system | throughout |
cpal | Cross-platform microphone capture | Audio Pipeline |
rustfft | FFT for pitch detection and the spectrogram | Audio Pipeline |
midly | MIDI file parsing | Chart Format, Jam Session |
serde_json / jsonschema | Chart/theme/lesson JSON parsing and schema validation | Chart Format |
bevy_fluent / fluent_content | Fluent-based localization | Localization and Theming |
figment | Layered settings-file loading | Persistence |
notify-debouncer-full | Filesystem watching for ~/Harmonicon | Persistence |
rodio (decode-only) | OGG/WAV waveform pre-analysis | Chart Format |
Where to go next
If you’re orienting yourself for the first time, read The Plugin Architecture and Application States and Modes next — together they describe the skeleton every other chapter’s subsystem hangs off of.
The Plugin Architecture
Harmonicon is built almost entirely out of Bevy Plugins — the standard
Bevy pattern for packaging a chunk of app configuration (resources,
messages/events, systems, sub-plugins) behind a single type that
App::add_plugins consumes. This chapter describes the conventions
Harmonicon layers on top of that pattern: how plugins are composed, how
resources/components/messages are used to communicate between systems,
and how system ordering is expressed and enforced.
Composition: one plugin per feature, assembled in main.rs
Every top-level feature module exposes exactly one public Plugin type
(GameplayPlugin, MenuPlugin, SongPlugin, ThemePlugin,
LessonsPlugin, LocalizationPlugin, SettingsPlugin, ProfilePlugin,
SpectrogramPlugin, AssetsManagementPlugin, plus one per dialogs
widget: ComboboxPlugin, TooltipPlugin, FileDialogsPlugin, and so
on). src/main.rs is the single place all of them get assembled:
Two ordering details here are load-bearing, not incidental:
- The external asset source is registered before
DefaultPlugins. Bevy’sAssetPlugin(part ofDefaultPlugins) builds every registeredAssetSourcewhen it is added, not on first use — registering"external"afterward would meanexternal://...paths silently never resolve, with no assets at all under~/Harmoniconever loading and no error pointing at why. - Microphone capture starts after settings load.
audio_input::start_capture.after(settings::apply_loaded_settings)— a savedAudioSettings::input_devicepreference has to already be in theAudioSettingsresource before capture picks a device, or the game would always start on the system default regardless of what the player configured last session.
A handful of things that aren’t plugins live directly on App in
main.rs instead: app.add_message::<PitchEvent>() and a few
init_resource calls for the microphone pipeline’s own state
(AudioFrame, PitchRange), plus four bare systems (spawn_camera,
process_audio, log_pitches, change_scaling). These are
deliberately not wrapped in their own plugin: they’re two or three
lines each, used only here, and a MicPipelinePlugin wrapping four
lines of registration would be ceremony without payoff. The line is
drawn pragmatically, not by a hard rule — see
Module Boundaries and Dependency Rules for
where this project does enforce structure mechanically (file-size
budgets, a build-time lint for unregistered Message types) versus
where it leans on convention and review.
Sub-plugins for large features
A feature large enough to have its own internal sub-features composes
its own Plugins the same way main.rs composes top-level ones.
GameplayPlugin (in gameplay/plugin.rs) is the largest example: it
add_pluginss nine smaller plugins (CountdownPlugin,
TwelveBarBluesPlugin, MetronomePlugin, ModifierLegendPlugin,
PhrasePlugin, NoteTail2dPlugin, NoteTail3dPlugin,
SongProgressPlugin, WaitFreezePlugin — each one HUD overlay or
gameplay sub-concern) before going on to register roughly twenty
init_resource/add_message calls and around two dozen add_systems
calls of its own for the parts that don’t warrant their own plugin type
(scoring, the clock, pause handling, Jam-Session-specific systems,
Bending-Trainer-specific systems). This mirrors the top-level pattern:
plugin-per-self-contained-overlay where a type gives real encapsulation
value, bare registration calls where it wouldn’t.
Resources vs. components vs. messages
Harmonicon follows Bevy’s own idiomatic split, applied consistently enough across the codebase that it’s worth naming explicitly:
- A
Resourceholds state that exists at most once, globally or per active mode —GameplayClock,Score,AudioSettings,SelectedSong,EditorState(the Song Editor’s entire in-memory document). Most of Harmonicon’s actual “model” data lives in resources, not components — see the callout on this in The Scoring System, which explains why scored notes live in aSongNotesresource (aVec+ cursor) rather than as one ECS component per note. - A
Componenttags an entity — usually either a visual (a spawned note sprite, a UI button) or a marker used to find a particular kind of entity via aQueryfilter (MusicPlayertags the currently-playing background-music entity so pause/volume systems can find it without threading a resource-heldEntityhandle through every call site that needs it). - A
Message(Bevy 0.19’s renamedEvent) is used for discrete, one-shot occurrences a system wants to react to on the frame they happen, rather than poll for continuously —PitchEvent(one per analyzed audio chunk),NoteScored(fired the instantscore_notesjudges a note, consumed by the HUD to animate a hit-feedback burst rather than the HUD pollingScoreevery frame and trying to detect “did this go up just now”),SongsRescanned/ThemesRescanned/LessonsRescanned(fired only when a live filesystem-watcher rescan actually found something new — see Persistence — distinct from the resource simply existing, which a page’s ownresource_changedchange-detection would otherwise also catch on every re-entry into that page).
Every #[derive(Message)] type in the codebase must be registered with
.add_message::<T>() somewhere, or Bevy panics at runtime the first
time some system’s MessageReader/MessageWriter for it actually
runs — a failure mode that’s easy to introduce (the type still compiles
fine unregistered) and easy to not notice until that code path
happens to fire, sometimes well after the type was added. build.rs
statically scans for this at every build and fails the build if it finds
an unregistered one — see Testing Strategy for
the other build-time checks living alongside it.
Expressing system ordering: SystemSets and .after()
Bevy runs a frame’s systems according to a schedule the developer only partially constrains — anything not explicitly ordered may run in any relative order (parallelized where the borrow checker allows it), which is fine for most systems but actively wrong for a few. Harmonicon uses two mechanisms to constrain the parts that need it:
SystemSets name a group of systems so other systems can order themselves relative to the whole group at once, without listing every member. The most important one in the codebase isGameplayLogic(gameplay/plugin.rs): the chain of systems that ticks the gameplay clock, judges scoring, and advances the current bar. Every system that reads the clock — note movement, HUD displays, overlay tints — is ordered.after(GameplayLogic), or it risks reading a stale clock value on some frames and visibly stuttering. See The Gameplay Clock for why this matters as much as it does..after(some_system)orders one system directly after a specific other one, used where the relationship is narrower than “after this whole named phase” — for instance,jam::midi_tracks:: apply_midi_track_mute(see Jam Session) is ordered.after(gameplay::lifecycle::apply_music_volume)so a mid-song global-volume change can never accidentally un-mute a track the player muted a moment earlier: both systems touch the same sinks’ volume, and whichever ran second wins, so the ordering is what guarantees mute always has the final say.
.chain() is used where an entire tuple of systems needs to run in the
literal order written, most commonly for a short setup sequence where a
later system genuinely depends on an earlier one’s resource writes
having already landed (e.g. gameplay_2d::setup/gameplay_3d::setup
reading AdaptiveDifficulty while setup_adaptive_difficulty writes it,
in the same OnEnter(AppState::Playing) tuple).
run_if: conditional execution instead of conditional logic
Rather than a system checking “is this the right mode/state?” as its
first lines and returning early, Harmonicon prefers run_if predicates
attached at registration time — in_state(AppState::Playing),
resource_changed::<AudioSettings>, or a custom closure like
|m: Res<GameplayMode>| *m == GameplayMode::JamSession. This keeps a
system’s own body free of state-checking noise, makes the conditions
under which something runs visible at a glance in the plugin’s
registration block (several such conditions are frequently .and_then-
chained together, e.g. “only in Playing, only when not paused, only in
Jam Session mode” for the Jam-Session-specific systems), and — not
purely cosmetic — means Bevy’s own scheduler can skip a system’s query
initialization entirely on a frame its condition is false, rather than
the system paying that cost and then no-op’ing internally.
Application States and Modes
Harmonicon uses Bevy’s States/SubStates machinery to drive which
screen is showing and which set of gameplay systems is active. This
chapter describes the two state machines involved — AppState (the
top-level screen) and GameplayMode (which of three different gameplay
experiences AppState::Playing currently means) — plus MenuPage, the
sub-state that only exists while AppState::Menu is active, and the
routing-flag pattern used to hand information across a state transition
that the state machinery itself doesn’t carry.
AppState: the top-level screen
AppState (src/app.rs) is a plain Bevy States enum:
Each transition triggers Bevy’s OnExit/OnEnter systems for the
states involved — this is the backbone every feature’s own setup/
teardown hangs off: gameplay::plugin sets up the whole scored-gameplay
schedule OnEnter(AppState::Playing) and tears every gameplay entity
down OnExit(AppState::Playing) via a shared GameplayRoot marker
component (every entity gameplay spawns is tagged with it, so cleanup is
one Query<Entity, With<GameplayRoot>> despawn rather than tracking
individual entities); song_editor does the same around
AppState::SongEditor2.
Startup exists to give localization a real starting point.
AppState defaults to Startup, and main.rs only transitions to
Menu once localization::localization_ready is true — otherwise the
very first frame of the menu would render with raw Fluent keys instead
of translated text, because the locale bundles haven’t finished loading
yet (see Localization and Theming for why
that load is itself asynchronous).
SongLoading exists because asset loading is asynchronous.
Picking a song sets SelectedSong to a Handle<SongManifest> and moves
to SongLoading; check_loading polls
AssetServer::is_loaded_with_dependencies every frame and only advances
to Playing once the whole manifest — chart, background image, music,
sibling note-theme assets — has actually finished loading. See
Chart Format and Asset Loading for what “the
whole manifest” includes and why a missing sibling asset needs a
fallback rather than becoming a load failure (a hard dependency that
never resolves would otherwise hang the loading screen forever, with no
error to explain why).
GameplayMode: what Playing actually means
A single AppState::Playing covers three quite different experiences,
selected by the GameplayMode resource before entering it:
Play2D— falling notes, one lane per hole.Play3D— the same scoring, rendered around a rotating 3D harmonica.JamSession— free play over a 12-bar backing, nothing scored.
All three share the same AppState::Playing OnEnter/OnExit and the
same GameplayLogic system set (clock tick, scoring, loop handling —
see The Gameplay Clock), which is precisely why
they’re one AppState value with a mode selector, rather than three
separate AppState variants: AppState::Play2D, ::Play3D,
::JamSession would each need their own copy of every shared
OnEnter/OnExit/run_if(in_state(...)) registration (pause handling,
music volume application, HUD overlays that all three modes share), or
force those registrations to accept a slice of three near-identical
match arms. A Res<GameplayMode> read inside a run_if closure —
.run_if(|m: Res<GameplayMode>| *m == GameplayMode::Play2D) — is the
one line of difference each mode-specific system actually needs.
Jam Session doesn’t populate SongNotes (nothing is scored there), so
every scoring-adjacent system either no-ops gracefully against an empty
SongNotes or is itself gated to Play2D/Play3D only — see
The Scoring System for the specific places this
matters.
MenuPage: a sub-state scoped to Menu
MenuPage (src/menu/routing.rs) is a Bevy SubStates, declared with
#[source(AppState = AppState::Menu)] — it only exists, and only
resets to its default (Main), while AppState is Menu. Every menu
screen (Play, ArtistList, SongList, ModeSelect, Options,
Theme, Lessons, LessonReader, JamSessionMenu, JamGenerate,
HelpAbout, About) is one MenuPage value, with its own
OnEnter/OnExit pair spawning and despawning that page’s UI.
The routing-flag pattern
A recurring, slightly awkward problem: when some other AppState
transitions back into Menu, which MenuPage should it land on?
“Wherever it makes sense for where the player just was” — Quit Song
should return to the song list, not the main menu; finishing the
Calibration screen should return to Options, where the player was
adjusting input lag; leaving the Song Editor should return to the Play
page. The obvious-looking approach — the exiting screen just calls
next_page.set(MenuPage::SongList) directly — doesn’t work: setting
NextState<MenuPage> in the same tick as NextState<AppState>
loses to SubStates’ own machinery resetting MenuPage to its default
the moment AppState actually changes to Menu.
The fix is a small resource per destination — ReturnToSongList,
ReturnToOptions, ReturnToPlay, ReturnToHelpAbout (src/app.rs) —
each a bare bool. The exiting screen sets its flag to true on every
exit path, and route_menu_entry (which runs OnEnter(AppState::Menu),
strictly after SubStates’ own reset has already happened) reads the
flags and issues the real next_page.set(...) call, one tick later
than the naive approach would have. LessonContext and
GeneratedJamSession (an in-flight lesson run, and a procedurally
generated Jam Session with no real song behind it) follow the same
“flag/resource read on arrival, in priority order” pattern for their own
end-of-run routing, and the guided tutorial tour
(menu::tutorial::TutorialTour) takes priority over all of them while
it’s actively driving the screen — see that module for the full
priority order route_menu_entry resolves between a running tour, a
lesson context, a generated jam, and the four ReturnTo* flags.
The Audio Input Pipeline
This is the pipeline that turns “sound coming out of a real harmonica
into a real microphone” into “a PitchEvent message an ECS system can
score against a chart.” It is the one place in the codebase where
Harmonicon has to cross from a real-time, non-ECS execution context
(a cpal audio callback on its own OS thread) back into Bevy’s frame
loop, and the design is shaped almost entirely by the constraints that
crossing imposes.
Why this can’t just be “read the mic in a system”
Bevy systems run once per frame, on frame-rate cadence (commonly 60Hz,
i.e. roughly every 16.7ms). A microphone, through cpal, delivers audio
on its own callback, driven by the OS audio backend’s real-time
scheduling — a fundamentally different, higher-priority, and stricter
timing domain. Two things follow from that:
- The capture callback must never block. OS audio backends run this callback on a real-time-priority thread; anything that can block (allocating memory, taking a lock also held by a lower-priority thread, doing I/O) risks an audible dropout (“xrun”) if it stalls even briefly. This constraint shapes essentially every implementation detail below.
- Audio and frame rate aren’t the same clock. The callback fires
however often the audio backend wants a new buffer, completely
decoupled from whatever the game’s frame rate happens to be. A system
can’t just “read the microphone” the way it reads a
KeyCode— there has to be a hand-off between the two timing domains.
The pipeline, end to end
Capture: audio_system::audio_input
start_capture opens a cpal::Stream against the configured (or
system-default) input device and registers push_chunks as its data
callback. push_chunks accumulates incoming samples into fixed
CHUNK_SIZE (4096-sample) buffers with 50% overlap — each chunk shares
its second half with the next chunk’s first half (HOP_SIZE = 2048),
which is standard practice for windowed-FFT pitch detection: it halves
the effective latency between “the reed starts sounding” and “the chunk
containing that onset gets analyzed,” at the cost of running the FFT
roughly twice as often for the same audio.
No allocation in the real-time callback. A Vec<f32> chunk buffer
that would need malloc on every hand-off is exactly the kind of
blocking-risk operation the callback can’t afford. Instead,
AudioCapture::free_sender is a channel the consumer side
(process_audio, safely inside the Bevy frame loop) uses to hand a
buffer back once it’s done with it — push_chunks drains that channel
first and only allocates a fresh buffer as a fallback if the pool is
ever empty (startup, or the consumer briefly falling behind). In
steady state, the consumer drains far faster than chunks arrive (one FFT
per ~46ms chunk vs. an Update frame roughly every 16.7ms), so the pool
essentially never runs dry.
Hand-off: crossbeam-channel
The callback thread and the Bevy Update schedule communicate purely
through a bounded crossbeam_channel (AudioCapture::receiver /
free_sender, both stored on the AudioCapture resource) — no shared
mutable state, no locks either side has to reason about, just
send/receive across a well-tested MPSC channel. process_audio drains
every pending chunk with while let Ok(...) = receiver.try_recv()
each frame rather than reading just one — because chunks arrive on their
own real-time cadence, more than one can legitimately land within a
single (slower) Update frame, and dropping the extras would just be
discarding already-captured audio for no reason.
Analysis: pitch_detect::analyze
analyze (audio_system/pitch_detect.rs) takes one chunk, the sample
rate, the selected PitchAlgorithm, and the current PitchRange, and
returns an Analysis: the detected Vec<PitchInfo> plus the FFT
magnitude spectrum and its bin width (freq_res) — the same spectrum
data the spectrogram visualizer reuses instead of re-running its own
FFT, since it’s already sitting in the published AudioFrame resource.
Five selectable algorithms, chosen via the PitchAlgorithm enum and
switchable live from the Options page:
| Algorithm | Character |
|---|---|
| FFT (default) | Peak-picking with harmonic suppression on the FFT spectrum. Can report multiple simultaneous pitches — the only algorithm here that can, which matters for chord-tone detection. |
| YIN | Cumulative mean-difference function. Monophonic (one pitch), classically robust for a single clean voice/instrument. |
| pYIN | Probabilistic YIN — aggregates YIN’s own estimate over a Beta-weighted range of candidate lags for a more stable estimate at the cost of more computation. |
| MPM | McLeod Pitch Method — normalized square-difference autocorrelation; often the most reliable choice for a single harmonica note (see docs/lessons_plan.md/CLAUDE.md’s recording-workflow notes, which specifically recommend it for Song Editor Record mode). |
| NMF | Non-negative matrix factorization against a dictionary of harmonica note spectra, rebuilt whenever the detection range changes. The one algorithm that’s chart-aware by construction, since its dictionary is built from exactly the notes the current harp/chart can produce. |
PitchRange is chart-driven, not a fixed global constant. Detecting
across the full audible spectrum wastes computation and increases false
positives from other pitches a harmonica simply cannot produce (a
different instrument bleeding through the mic, room noise at an
implausible pitch). PitchRange (default 200–2500 Hz) is narrowed to
Harmonica::frequency_range() — the specific harp’s actual playable
range — the moment a song starts, and to the selected key’s range in the
Bending Trainer; both reset it to the default on state exit. This is
also why NMF’s dictionary staleness check has to include the range as an
input, not just the algorithm choice: a dictionary built for one harp’s
notes would silently misclassify pitches from a different one.
Publishing: PitchEvent and AudioFrame
Two different things get published from the same analyze call,
because they have two different kinds of consumer:
PitchEvent(aMessage) — the detected pitches, for anything that reacts to “what changed this frame”: scoring (collect_pitchesingameplay), the Song Editor’s live recording, Jam Session’s hole- map tinting.AudioFrame(aResource) — the raw FFT magnitude spectrum and the just-analyzed sample buffer, for anything that wants continuous access to “what does the signal currently look like” rather than a discrete event — the spectrogram visualizer being the only consumer today.
Downstream: synthesis and file decode
audio_system also owns two pieces that have nothing to do with
capturing audio but share its “audio infrastructure, not a feature”
status:
synth.rs— an additive harmonica-voice synthesizer (render_pcm, operating on aVec<PhraseNote>tick/frequency list) used everywhere Harmonicon needs to produce a harmonica-like sound rather than detect one: the Song Editor’s Play/Practice/Record preview and note-audition blip,gameplay::call_response’s one-shot “call” demo audio, and — the newest consumer — per-track MIDI backing stems for Jam Session (see Jam Session). Vibrato/FM modulation here integrates frequency over time (a phase accumulator), nevermodulated_freq × tdirectly — the latter drifts pitch upward over the duration of a held note, a subtle bug that’s easy to reintroduce if this synth is ever touched without knowing why the phase-accumulator form was chosen.waveform.rs/wav.rs— OGG/WAV peak-amplitude pre-analysis (viarodio, decode-only) and WAV encode, used at asset-load time to give the song-progress bar a waveform to draw immediately rather than decoding audio on the main thread mid-setup, and to turn synthesized PCM back into a playableAudioSource(the generated Jam Session bass line, MIDI-track backing stems, MIDI-import backing tracks).
Neither of these touches cpal or the real-time callback at all — they
run as ordinary (if sometimes CPU-heavy) synchronous code, either inside
a custom AssetLoader (see Chart Format and Asset Loading) or inside an ordinary system, and carry none of
the real-time constraints the capture side does.
The Gameplay Clock
Every timing-sensitive system in scored gameplay — where a falling note
currently is, whether it’s inside its hit window, what the HUD displays,
where the phrase/song-progress overlays are drawn — reads its notion of
“now” from exactly one place: GameplayClock (gameplay/clock.rs).
This chapter explains what that clock actually is, why it isn’t simply
“the real-time elapsed since the song started,” and the API design that
makes its one hard invariant difficult to violate by accident.
Why not just use real elapsed time?
The naive approach — track Instant::now() - song_start and treat that
as the authoritative song position — breaks the moment the audio
itself doesn’t advance in perfect lock-step with wall-clock time. In
practice it never does: audio decoders have startup latency, backends
occasionally hitch, and a bevy_audio/rodio sink’s own reported
playback position is the one source of truth for “where is the actual
sound the player is hearing right now.” A note judged against
wall-clock time while the audio is a few milliseconds ahead or behind
reads as mistimed even when the player’s timing was perfect — the
judgment is comparing the player against the wrong reference.
So GameplayClock doesn’t track wall-clock time directly; once music
starts, it tracks the audio sink’s own position, with wall-clock
delta as a fallback for the periods where there’s no sink to anchor to
at all (the pre-song countdown, Jam Session, before the sink has
produced its first position report).
The anchoring algorithm
Two constants make this concrete: MAX_RATE_ADJUST (0.5%) caps how much
the clock’s rate — not a one-off jump — can deviate from real time
while gently correcting toward the sink, expressed as a rate rather than
a fixed per-frame step specifically so the correction doesn’t bias every
judged hit offset by a constant amount for as long as it’s active, and
so it doesn’t over- or under-correct depending on the actual frame rate.
SNAP_THRESHOLD_SECS (0.5s) is the line between “ordinary jitter, worth
correcting gently” and “a real discontinuity” (a decoder stall, a
backend seek) that should be corrected immediately rather than converged
toward over several seconds of visibly-desynced notes.
Jam Session deliberately never anchors. There’s no long, fixed-length
track to drift against in free play — Jam Session’s music is either a
generated backing loop or a picked song played on repeat, and nothing
about that experience benefits from the sink-anchoring machinery.
should_anchor_to_sink excludes GameplayMode::JamSession explicitly.
The encapsulation: why the inner value is private
GameplayClock’s inner f64 is a private tuple field. The type exposes
exactly three ways to change it, and each one documents (or, for the
anchored case, actively enforces) the invariant that matters:
The invariant all three exist to protect: anything that jumps the
clock must also seek the music sink, or suspend anchoring — because if
it doesn’t, the very next tick_clock pass sees the sink still sitting
at its old position, computes a large “drift,” and drags the clock right
back toward where it just jumped from. This is a genuinely easy bug to
write by hand (assign the new time, forget the sink is now stale) and a
confusing one to debug (the symptom is “my rewind gets silently undone
one frame later,” with no error or panic pointing at why) — which is
exactly the shape of bug a private field plus a small, invariant-carrying
API is good at ruling out at the type level rather than relying on every
future caller remembering a rule from a comment.
rewind_to is the one both existing callers that jump the clock already
use — handle_loop_boundary (an A–B loop or a chart’s own loop
section reaching its end point) is the only one in the codebase today,
but the doc comment above is explicit that this is also the contract any
future practice-speed-change or manual seek feature must follow, not an
incidental detail of the current feature set.
Reading the clock: the ordering invariant
The other half of the contract, enforced by convention rather than the
type system: every system that reads the clock must be ordered
.after(GameplayLogic) (the SystemSet that ticks it — see
The Plugin Architecture). Bevy’s scheduler is
free to run unordered systems in any relative sequence, including
parallelized; a clock-reading system that isn’t explicitly ordered after
the tick can read last frame’s value on some frames and this frame’s on
others, which manifests as visible note-movement stutter rather than a
crash — the kind of bug that’s easy to introduce and easy to miss in
testing if it only shows up as an occasional single-frame jitter.
Practice speed and pausing
Two more free-running cases fall out of the same should_anchor_to_sink
predicate rather than needing special-case code: practice speed below
100% (real time-stretched audio isn’t implemented, so the sink is
simply paused and the clock free-runs on Time::delta scaled by the
practice-speed factor instead — returning to 100% re-seeks the sink to
the clock’s current position via rewind_to before resuming it, since
the sink sat still the whole time the clock kept moving), and the
wait-for-note freeze (wait_freeze_overlay — the chart holds at the
next unjudged note until the player plays it; tick_clock pauses the
sink and skips advancing the clock at all while a note is “due” under
that mode, and Jam Session never populates SongNotes, so the freeze
condition is always trivially false there regardless).
The Scoring System
Scoring is split cleanly into two layers: pure functions with no ECS
dependency at all (src/scoring.rs, top-level, shared by real
gameplay and the Song Editor’s practice mode) that decide “given this
timing offset and this configuration, what quality is this hit,” and a
driving ECS system (gameplay::judge::score_notes, plus its
supporting resources in gameplay::state) that feeds real per-frame
pitch/clock data into those functions and applies the result to game
state. This chapter covers both layers and the data-model decision that
sits underneath them: why scored notes live in a plain resource instead
of as ECS components.
Why note state is a resource, not components
It would be reasonably idiomatic Bevy to give every chart note its own
entity with a Note component carrying its score state. Harmonicon
doesn’t do this. Instead, SongNotes (gameplay/notes.rs) is a
Resource holding a plain Vec<ScheduledNote> (the entire chart,
loaded once) plus a cursor (the index of the first not-yet-resolved
note). ScheduledNote is plain data — no Component derive, nothing
ECS-specific about it at all.
This decoupling is what makes several other things in the codebase simple that would otherwise be awkward:
- Only a rolling window of notes needs a visual at all.
notes_needing_spawn(a pure function, shared by the 2D and 3D renderers) answers “which notes should have a visual right now but don’t” by binary-searchingSongNotes::notes(kept sorted bytime) for theLOOKAHEAD-second window around the playhead — a whole song is typically hundreds of notes, but only a handful are ever visible (and thus need an entity) at once. If score state lived on the entity, a note leaving the lookahead window would either need to destroy and later reconstruct its own progress, or the despawn logic would need special-casing to preserve it — with the split, an entity can freely despawn and later respawn fresh, because its score state was never at risk of being lost with it. - Looping resets state with a slice mutation, not an ECS query.
clock::handle_loop_boundaryresets every note inside the loop range back to “unresolved” with a binary-search-bounded slice mutation over plain data, rather than aQueryover however many of those notes happen to currently have a live entity (most of them won’t, being outside the lookahead window). - Recolor-on-hit systems need no
Changed<T>filter.ScheduledNoteisn’t a component, so there’s nothing to filter aQueryon for “did this note’s score state change since last frame.” Instead,update_note_visuals*just re-syncs every currently-spawned visual’s color fromSongNotesevery frame — cheap, because (per the point above) only the lookahead window’s worth of notes ever have a visual to update in the first place.
Pitch identity: a u8, not a string
Every place scoring needs to compare “what pitch is the player playing”
against “what pitch does the chart expect,” both sides are a MIDI note
number (u8) — PitchInfo::midi, ScheduledNote::expected_pitch: Option<u8> (None for a hole/technique combination the harp simply
can’t produce, which can therefore never be hit), PitchGate’s
consumed: HashSet<u8>. This is what lets the hot per-frame comparison
be an integer equality check with zero allocation, and — a correctness
detail, not just a performance one — it rules out enharmonic mismatches
("A#4" vs "Bb4") entirely, since they’re the same integer. Display
strings (note/octave on PitchInfo, the harmonica’s own
wind_direction_label) still exist purely for what the player sees;
they are never compared against each other for scoring purposes.
score_notes: candidate selection
judge::score_notes runs every frame (as part of GameplayLogic, see
The Gameplay Clock) and has to answer “which of
however many notes are currently near the playhead did the player’s
detected pitches just satisfy?” It does this in two passes over
SongNotes, both leaning on the notes being sorted by time:
Sorting candidates by |offset| before judging — rather than judging in
whatever order they happen to appear in the chart — is what makes two
overlapping same-pitch notes resolve deterministically: the closer one
claims the played pitch first, so a played note can never be credited to
the wrong one of two nearby candidates just because of iteration order.
The early break (not continue) on the first out-of-range note is
possible, and correct, specifically because the notes are sorted and
the scan starts from cursor — nothing beyond that point can be in
range either, so there’s no reason to keep scanning a long chart’s
entirely future notes every single frame.
Fresh-attack gating: PitchGate
A note only counts as “being played” on a genuine new attack, not merely
“this pitch happens to still be sounding from a moment ago” — otherwise
a single long blow could be re-credited to every note at that pitch in
a row. PitchGate (gameplay/state.rs) wraps scoring::AttackGate<u8>,
a small pure state machine keyed by MIDI note number that tracks which
pitches are “fresh” (just started sounding) versus “already consumed”
by an earlier note’s hit — the same primitive Jam Session’s own
ImprovStats accumulator reuses for its own fresh-attack gating,
despite scoring nothing.
Chord and octave-split notes
A chart TrackItem with more than one simultaneous events entry
(PlayMode::Chord/Split) still produces one ScheduledNote per event
— but every sibling note carries the same chord_pitches: Vec<u8>,
the full target set for that item. score_notes ANDs a
chord_is_sounding check (every pitch in the set present at once) into
each sibling’s own per-pitch freshness check, so a chord only scores
when its members are struck together — playing the same holes one at
a time doesn’t satisfy it. This needed no chart-schema change at all:
multi-event TrackItems already existed (for the visual chord/split
badge); nothing previously required their events to actually sound
together, and chord_pitches is empty (a no-op AND) for the
overwhelmingly common single-event case.
Where the pure/ECS boundary actually sits
src/scoring.rs — outside gameplay/ entirely, at the crate’s
top level — contains every function that decides quality from a
timing offset and a ScoringConfig: the perfect/good/miss timing-window
classification, combo/multiplier math, style-bonus point values, and
AttackGate<K> itself. None of it touches a World, a Query, or any
Bevy type. This is what lets the Song Editor’s practice mode
(song_editor::practice) share the exact same judging math real
gameplay uses — scoring a player’s mic input against the chart being
edited — without depending on any part of gameplay’s ECS machinery at
all, and it’s what makes this layer straightforward to unit test
directly against plain inputs and expected outputs rather than needing a
World/Schedule harness (see Testing Strategy
for the project-wide convention this exemplifies: pure functions and
their tests first, the ECS system that drives them second).
Chart Format and Asset Loading
A “song” in Harmonicon is more than a chart file — it’s a chart plus a
loose bundle of optional sibling assets (music, art, per-song note
themes), all pulled together into one loaded SongManifest by a custom
Bevy AssetLoader. This chapter covers the on-disk chart format, the
loader that turns a folder into a SongManifest, and the specific
design pattern — checking a sibling’s existence before making it a hard
dependency — that lets a song ship with almost nothing beyond its chart
and still load correctly.
The chart format: .harpchart
A chart is a JSON file (song::chart::HarpChart), validated at load
time against assets/song_schema.dtd.json via the jsonschema crate.
Its top-level shape:
metadata— includingformat_version, checked (not just descriptive) againstsong::chart::CURRENT_FORMAT_VERSION; a chart declaring a newer version than the running build understands is rejected with a clear “this chart needs a newer Harmonicon” message rather than failing confusingly downstream against a schema it wasn’t written for. An older chart goes throughchart::migrate_chart_jsonfirst (see below) rather than being checked directly — a chart old enough to still carry a since-removed field would otherwise fail schema validation before this check is even reached.song— title, artist, tempo, key, time signature.harmonica— diatonic or chromatic, hole layout, tuning/bending profile, and (see the Song Editor and Jam Session chapters) the referencescaleused for out-of-scale coloring.timing—resolution(ticks per quarter note) and atempo_map(a sorted list of(tick, bpm)points) — see The Song Editor for why this is a real, chart-declared value rather than a hardcoded constant, and how that choice paid off when the Song Editor’s own tick resolution later changed.track— the timed list of notes: each item has atime(ortick, resolved against the tempo map) and one or more noteevents(hole + blow/draw + expected pitch), optionally carrying techniquemodifiers(bend, overblow, overdraw, slide, vibrato, wah-wah).
Schema strictness is deliberate, and has a real consequence for
schema evolution. Every level of the schema sets
additionalProperties: false, so removing a field from the schema
would break previously-authored charts still carrying it at validation
time (a plain serde struct would just silently ignore an unknown
field; the schema validator won’t). A field removal has three legitimate
ways to handle this — keep the old key present-but-ignored in the schema,
accept the break explicitly and bump format_version, or add a migration
step (below) that fixes the content up before validation ever sees it.
The original fx_mapping removal did none of these, which is exactly why
old charts carrying it failed to load; migrate_chart_json (below) fixes
that specific case retroactively, and is the template for handling any
future removal the same way instead of repeating that gap.
Migrating old charts on load
song::chart::migrate_chart_json runs on the raw serde_json::Value
before schema validation — migrating after would be too late, since a
chart that fails validation never reaches typed deserialization at all.
It walks an ordered list of Migration { target_version, apply } steps
(currently one: strip_legacy_fx_mapping, folded into 1.1.0), skipping
any step whose target_version is already covered by the chart’s own
declared metadata.format_version — a missing or unparsable version is
treated as older than everything, since almost every chart old enough to
need migrating predates the field itself.
Each step’s apply returns whether it actually changed anything, kept
deliberately separate from whether it was merely attempted: most
charts below a step’s version threshold don’t actually have the specific
problem it fixes (a chart declaring 1.0.0 that never used fx_mapping
to begin with, say). song::loader only logs when real content changed;
metadata.format_version itself is unconditionally stamped to
CURRENT_FORMAT_VERSION whenever any step’s threshold applied, whether
or not that step found something to fix, so a chart that’s passed through
here once never gets re-evaluated against the same migrations again.
This is purely an in-memory fix-up for that one load — nothing writes the
migrated JSON back to the .harpchart file on disk. A re-save through the
Song Editor naturally picks up the fix, since it serializes whatever’s
currently in memory, but an external chart a player never re-saves stays
migrated-on-load, indefinitely, every time it’s opened.
SongManifest: the loaded, in-memory result
SongManifest is itself a Bevy Asset, registered with
app.init_asset::<SongManifest>().register_asset_loader(SongChartLoader)
(song::SongPlugin). Picking a song sets SelectedSong(Handle< SongManifest>) and moves AppState to SongLoading;
menu::routing::check_loading polls AssetServer:: is_loaded_with_dependencies every frame and only advances to Playing
once the whole manifest — chart plus every asset it depends on — has
resolved (see Application States and Modes).
SongChartLoader: the custom AssetLoader
song::loader::SongChartLoader (type Asset = SongManifest) is an
async AssetLoader — it runs as a future on the AssetServer’s IO task
pool, off the main thread, alongside every other asset load in the
game. Its job is considerably more involved than “parse the JSON,”
because every sibling asset a song folder can ship is optional except
the chart itself:
The load-order subtlety that makes all of this work: every sibling
is checked for existence with read_asset_bytes before it’s ever
handed to load_context.load(). Calling load() registers that path as
a hard dependency of the SongManifest asset — and a dependency
pointing at a file that doesn’t exist never resolves, so
AssetServer::is_loaded_with_dependencies (the very check
check_loading polls) would wait on it forever. Without the
existence check first, a song shipping only a chart wouldn’t fail
loudly — it would just hang on the loading screen indefinitely, with no
error message pointing at why. Example Song 3 in the bundled assets
exists specifically to exercise this path: it ships only a chart, on
purpose, so this fallback behavior stays covered by
tests/asset_layout.rs rather than silently regressing.
2D/3D note assets are noted, not loaded, here. assets_2d/
assets_3d are stored as an AssetPath, not a Handle — loading them
here would make them a manifest dependency kept resident for the
entire song regardless of which render mode (if either) actually gets
entered. gameplay_2d::setup/gameplay_3d::setup load the matching one
on demand, and free it again on exit.
Why the asset source matters: bundled vs. external
Every sibling read above goes through load_context, which resolves
relative to whichever AssetSource the manifest itself was loaded
from — the default (bundled assets/) or external:// (the
~/Harmonicon drop-in folder registered in main.rs, see
The Plugin Architecture). The loader captures
this explicitly (load_context.path().source().clone_owned()) and
threads it through every sibling path it builds, rather than ever using
a bare PathBuf/&str — a bare path always resolves against the
default source, so without this, a song loaded from external://...
would have its own chart parsed correctly but its music/images silently
looked up in the bundled tree instead. The one deliberate exception:
the fallback note-theme JSON (notes/2d/circular.json) is loaded with a
bare, source-less path on purpose — a shared default asset lives in the
bundled tree regardless of where the song itself came from.
MIDI as a chart source: two very different roles
midly-based MIDI parsing appears in two places in the codebase with
deliberately different responsibilities, both built on the same
low-level, pure parsing module (song::midi — track names, tempo maps,
note on/off pairing, no pitch-to-harp resolution or chart-building logic
at all):
- Authoring:
song_editor::midi_importreads a MIDI file the player picks in the Song Editor, resolves its pitches onto the currently selected harp, and drops the result onto the note grid as ordinary, editable chart notes — a one-time, offline conversion. See The Song Editor. - Runtime backing:
song::loader’ssong/music.midfallback (this chapter) keeps a MIDI file’s tracks separate rather than converting them to chart notes at all, rendering each to its own playable audio stem via the shared additive synth. See Jam Session for how those stems become independently mutable playback.
Both share song::midi’s parsing primitives, but neither depends on the
other — one produces chart notes, the other produces chart audio,
and song::midi itself stays agnostic of which a given caller is doing.
The Song Editor
The Song Editor (src/song_editor/, ~15,000 lines across roughly thirty
files) is Harmonicon’s largest single feature: a full in-game chart
authoring tool built around one central document resource, a piano-roll
grid, and enough surrounding tooling (live recording, MIDI import,
undo/redo, a real tempo map, lesson authoring) that it functions as a
small application in its own right, running inside AppState:: SongEditor2. This chapter describes how that document is modeled, how
the ~30 files are split by responsibility, and the design of a few of
its more intricate features.
EditorState: one resource, the whole document
Everything the editor is currently working on — every placed note, the
tempo map, the meta-form fields (key, tempo, harmonica type, position,
scale, lesson metadata), the current selection, drag state, which mode
is active — lives on one Resource, EditorState (song_editor/ state.rs). This mirrors the same “plain data resource, not scattered
across components” choice The Scoring System
describes for SongNotes, for a very similar reason: the editor’s
“document” needs to be trivially snapshot-able (for undo — see below),
diffable, and serializable to a .harpchart, none of which is
convenient if a note’s data is spread across ECS component storage.
GridNote (the editor’s own per-note type — distinct from gameplay’s
ScheduledNote, since the editor’s notes carry authoring-time fields
gameplay’s scored notes don’t, like a stable id used for
selection/undo) is plain data too: { id, hole, tick, len, dir, pitch, expr }.
How the module is split
The screen is a row, not a column
The editor root is a row: a vertical tool sidebar down the left, and
everything else in a column beside it. Within that column, the note grid
and the metadata form are tabs (ChartTabPanel/DetailsTabPanel,
toggled with Display::None so the hidden one reserves no space).
Both choices are about height, and both were driven by running the game
on a phone. A landscape screen is wide and short — 2400x1080, roughly 400
logical px tall at Android DPI — while the grid alone needs about 424. The
tool palette used to be a horizontal strip below the grid, which put it
off the bottom of the screen with nothing able to scroll to it, because it
sits in the fixed chrome outside the form’s ScrollArea. Turning it
vertical spends width, which that screen has in abundance, instead of
height, which it does not.
view_scroll.rs owns everything that moves the view rather than the
content: the grid’s horizontal pan (keys, wheel, scrollbar), the
two-finger touch pan, and the sidebar’s own drag/wheel scrolling. It was
split out of interaction.rs for the file-size budget, but the group was
always coherent — none of it touches a note.
This split follows a consistent pattern also visible elsewhere in the
codebase (see Module Boundaries and Dependency Rules): most files aren’t “a layer,” they’re “one
feature, factored out once it grew large enough to justify its own
file” — snap.rs, audition.rs, save_feedback.rs, metronome.rs,
and undo.rs were all split out of state.rs/mod.rs specifically to
stay under the project’s enforced per-file line budget (see
Testing Strategy), not because of some deeper
layering principle. The underlying rule that is structural, not
incidental: pitch-to-harp resolution (pitch_map.rs) is shared, not
duplicated, between MIDI import and live recording, even though the two
want opposite fallback behavior when a pitch doesn’t map cleanly onto
the harp (import always finds something playable, so an imported
track never has gaps; recording discards a detection it can’t map
cleanly, so raw pitch-detector noise never disguises itself as a
plausible note).
Undo/redo: snapshot diffing, not command objects
The editor’s undo system (undo.rs) is snapshot-based, not
command-based. A command-based undo system (each mutation pushes an
explicit “undo this specific action” closure or enum onto a stack) is
the more common pattern, but it requires instrumenting every single
mutation call site — every note placement, drag, resize, delete, paste,
Erase/Remove, MIDI import — to know it needs to record undo
information, which is a lot of individually-easy-to-forget call sites in
an editor this large.
Harmonicon’s undo::track_changes instead runs once every frame
EditorState changes at all, diffing a lightweight Snapshot (just
notes and tempo_changes — deliberately not the whole
EditorState, excluding transient fields like selected/scroll_beat/
dragging that shouldn’t count as “an edit” for undo purposes) against
the last-seen snapshot, and pushes the previous one onto the undo
stack only when they actually differ:
This means adding a new note-mutating feature needs zero
integration work with undo — whatever it does to EditorState.notes/
tempo_changes is caught generically on the next diff pass, which is
exactly why undo support didn’t need to be re-derived when MIDI import,
paste, and the timeline tools were each added afterward. The one
deliberate exception is live recording: a note’s length grows every
single frame while a take is running, so diffing continuously would
flood the history with one entry per frame of growth — track_changes
simply skips while RecordState::active, so an entire take (onset
through Stop/Finish, pauses included) becomes one undo step, not
hundreds. undo()/redo() themselves also keep the cached “last”
snapshot in sync, so the very next track_changes pass after either one
correctly reads as a no-op rather than a spurious new edit that would
otherwise clear the redo stack.
The grid-snap feature: a case study in scoped extension
The grid’s snap-to-beat-subdivision feature (SnapMode — Straight
16ths, Shuffle, Triplet) is a useful worked example of incremental,
carefully-bounded architecture change, because it was built in two
passes that make a good illustration of “ship the minimum, verify, then
extend deliberately”:
- First pass:
TICKS_PER_BEAT(shared withaudio_system::synth— see The Audio Input Pipeline) went from 4 to 12, the lowest resolution divisible by both 4 (straight 16ths) and 3 (triplets) — a true triplet position simply doesn’t exist as an integer tick on a 4-ticks-per-beat grid, so this had to be a resolution change, not a smarter snapping function on the old grid.snap_tick_in_beat(a pure function taking a fractional position within one beat cell — a click’s normalized offset) was wired into the note-placement click handler only. - Verification surfaced a scope gap: manual testing found dragging
and resizing an existing note showed no visible difference between
snap modes — because
move_target/apply_resize(the pure functions computing a drag’s resulting tick) had noSnapModeinput at all, by design at the time. Whether that was the right final scope was a real, open question, not a bug — dragging free-form and only snapping fresh placements is a defensible design on its own. - Second pass, after confirming the broader scope was wanted:
snap_absolute_tick(a second pure function, taking an already-absolute tick rather than a within-one-beat fraction — drag deltas can cross beat boundaries, which the placement-only function was never built to handle) is applied as a post-processing step at each drag observer’s call site.move_target/apply_resizethemselves stay completely snap-agnostic — snapping is layered on after calling them, not threaded through their own signatures — which is why their existing unit tests needed zero changes when this landed.
The broader lesson this illustrates: a feature’s first correct, tested, shipped version does not have to cover every place a related concept applies — placing a note and dragging one are related but distinct interactions, and it was legitimate to ship the first as its own complete unit, confirm with the person who’d actually use it whether the second needed the same treatment, and only then extend — rather than either guessing at the full scope up front or leaving the gap undiscovered.
Meta-form text fields: real EditableText, not hand-rolled input
The nine free-text meta-form fields (Tempo, Music, Name, Author, and five
lesson fields) are built on bevy_text::EditableText via a small shared
widget, dialogs::text_input::spawn_text_input — real click-to-focus,
cursor rendering, and keyboard editing supplied by bevy_ui_widgets’
EditableTextInputPlugin (already registered app-wide), rather than a
hand-rolled per-character KeyboardInput capture. The five click-to-cycle
fields (Key, Position, and three lesson-enum pickers) are unrelated —
plain WidgetButtons that step their value on Activate, never text
input at all.
Two consequences of building on real focus/editing primitives instead of
a custom EditorState.focus: Option<Field> flag (which this used to be):
- Keyboard-shortcut gating reads real
InputFocus.interaction:: grid_keys/handle_copy_paste/handle_undo_redo/pan_keyseach checkRes<InputFocus>againstQuery<(), With<EditableText>>(a small shared helper,a_text_field_has_focus) instead of a bespoke flag, so Delete/Ctrl+C/V/Z/Y/arrow-panning correctly stay disabled while typing. This also means Tab-focusing a field and typing immediately now works — under the old flag-based system it silently didn’t, since only a click (never Tab) ever set the flag. - Programmatic writes need their own sync path. A field’s
EditableTextbuffer is its own live source of truth while focused, soEditorStatecan’t just be blindly redrawn into it every frame the way the oldMetaFieldTextdisplay was —EditorStatechanges on essentially every frame during ordinary editing (note placement, selection, …), and a naive resync would erase whatever the player is mid-typing.panel:: sync_meta_field_texthandles this: it reconciles every field’s buffer againstEditorState::field_textevery frame, but skips whichever field entity currently equalsInputFocus::get(). This is what lets Load, MIDI import, and Browse-picking a music file all update a field’s displayed text with zero code of their own — they just writeEditorState’s plainStringfields as before, and the sync system picks it up on the next frame (unless the player happens to be typing in that exact field at that exact moment, in which case it’s skipped until they move on).
Save/load and the schema/version contract
Saving serializes EditorState into the same .harpchart JSON shape
Chart Format and Asset Loading describes,
through harpchart::serialize_harpchart, and validates the result
against the same schema before writing — an editor that could produce a
chart its own game engine then refuses to load would be a uniquely
bad experience. Save/load outcomes surface in the status bar (not just
the log) via save_feedback::SaveFeedback, a small resource with a
message and a countdown timer, displayed as the status bar’s
highest-priority tier for a few seconds before falling back to whatever
it would otherwise show (a count-in countdown, a drag-validity message,
recording/practice status).
Where MIDI enters: import, not runtime backing
The Song Editor’s midi_import.rs is the authoring-side use of MIDI
files, distinct from — but built on the same underlying song::midi
parsing primitives as — the runtime MIDI-backing feature described in
Jam Session. Picking a MIDI file lists
its tracks in a combobox; picking a track quantizes its notes onto the
editor’s tick grid and resolves each pitch onto the currently selected
harp key via pitch_map::map_pitch (an exact match, else a bend or
slide, else the nearest playable note — reusing the exact same
compatibility check the editor’s own UI enforces, so an import can never
produce a note the grid wouldn’t otherwise let you place by hand).
Saving while a track is selected also writes a synthesized WAV mixdown
of every other track as song/music.wav, via the same additive synth
The Audio Input Pipeline describes — this predates,
and is a different design point from, the newer song/music.mid
per-track-stem backing Jam Session can use instead.
Jam Session
Jam Session (src/jam/) is Harmonicon’s free-play mode: a rolling
12-bar backing, a live hole-map guide, and — deliberately — nothing
scored. This chapter covers how it shares gameplay’s core
infrastructure without being a gameplay submodule itself, the two ways
it can produce backing audio (a procedurally generated bass line, or a
picked song), and, in the most depth, the MIDI multi-track backing and
per-track mute feature — the newest and architecturally most interesting
piece of this subsystem, since it’s the one place in the codebase that
plays more than one background-music sink at once.
Why jam is a sibling of gameplay, not part of it
GameplayMode::JamSession is one of three values a Res<GameplayMode>
can hold while AppState::Playing is active (see
Application States and Modes) — so in one sense, Jam
Session is a gameplay mode. But its own code lives in a separate
top-level module, jam/, not inside gameplay/. This is a deliberate
split, and it reads as a genuine two-way dependency at first glance —
worth being honest about rather than glossing over, since it’s the kind
of thing Module Boundaries and Dependency Rules exists to explain clearly:
gameplay::plugin (the composition root — the one place that
assembles the entire AppState::Playing schedule for all three
GameplayModes) imports from jam to register Jam-Session-specific
systems (jam::session::update_hole_map, jam::midi_tracks:: apply_midi_track_mute, and so on) into that shared schedule, alongside
gameplay’s own 2D/3D-specific systems. jam’s actual feature code, in
turn, depends on gameplay’s core primitives — GameplayClock,
MusicPlayer, MidiTrackPlayer — as shared low-level vocabulary, the
same way song_editor or any other feature would. What makes this
not a real circular dependency in the problematic sense: a composition
root is expected to depend on everything it wires together (that’s its
whole job), while jam’s own feature logic never reaches back into
gameplay’s feature logic (2D/3D rendering, scoring) — only into the
primitives gameplay::state exists specifically to share. jam is a
sibling feature that happens to plug into gameplay’s shared schedule,
not a sub-feature of it.
Two ways to get a backing track
“Pick a Song” routes through the ordinary song-loading pipeline
described in Chart Format and Asset Loading —
any bundled or ~/Harmonicon song can be jammed over.
“Generate a Jam” is the no-existing-song alternative:
jam::backing::build_generated_manifest synthesizes a 12-bar bass line
at runtime (generate_bass_pcm — a simple 3-harmonic sine “blues box”
pattern, deliberately a different, plainer instrument voice from the
harmonica-voice synth song_editor::playback/MIDI backing use, since a
backing bass shouldn’t compete with or resemble the thing the player is
playing), encodes it to WAV, and registers it as a real AudioSource
asset directly via Assets::add — never going through the
AssetServer’s load path or AppState::SongLoading at all, because
there’s no file on disk to load in the first place. A
GeneratedJamSession marker resource is what lets the surrounding menu
routing (see Application States and Modes) recognize
this case and skip the loading screen, and lets “Quit Song” route back
to the generation page instead of a song list a generated jam never
went through.
MIDI multi-track backing: independent, synchronized sinks
This is the newest piece of jam, and the one with the most interesting
architecture, because it’s the first (and so far only) place in
Harmonicon that plays more than one background-music sink
simultaneously.
The constraint that shaped the design
Every other audio-producing path in the codebase — the generated bass
line above, the Song Editor’s MIDI-import backing mixdown, every
preview/practice/record playback, gameplay::call_response’s one-shot
call demo — follows the same shape: build a note list, render it once
to a flat PCM buffer via the shared additive synth, and hand the result
to a single AudioSink. There is no live/streaming audio-mixing
capability anywhere in the engine. Naively, “let the player mute
individual MIDI tracks live” sounds like it needs one — re-mixing the
active subset of tracks into a fresh buffer every time the player clicks
a mute button, mid-playback.
It doesn’t, and this is the key design insight: mute doesn’t require
re-mixing if every track is already an independent, complete render,
played as its own simultaneous sink. Muting one track is then just
setting that sink’s volume to zero — an operation bevy_audio
supports natively per-sink, with no re-rendering, no re-encoding, and no
new streaming infrastructure at all.
Why each sink is tagged MusicPlayer and MidiTrackPlayer
MusicPlayer is the ordinary, pre-existing marker component that tags
“the currently-playing background-music entity” for pause/resume and
global-volume-slider application (gameplay::lifecycle:: apply_music_volume, gameplay::pause_menu) — code written for the
single-sink case, years before multi-track backing existed. Tagging
every per-track sink with MusicPlayer too (alongside the new,
per-track MidiTrackPlayer(usize)) means that existing pause and
global-volume code needs zero changes to correctly apply to N sinks
instead of one — it already iterates every matching entity, never
assumed there’d be exactly one. The two places in the codebase that
do assume a single sink (Query::single(), in gameplay::clock’s
wait-for-note pause/play check and A–B loop boundary handling) simply
see “zero or multiple” as the same “nothing to anchor to” case they
already handle gracefully for a music-less song — and neither code path
is reachable in Jam Session anyway (no SongNotes, no A–B loop UI
there), so this was verified safe rather than assumed safe.
MidiTrackPlayer(usize) itself lives in gameplay::state, not in
jam::midi_tracks where it’s read — the same “define shared
low-level vocabulary where the lower layer needs it, not where the
higher one reads it” placement MusicPlayer itself already follows
(countdown_overlay, which spawns the component, can’t depend on
jam without inverting the dependency direction the section above
described).
Why mute always wins over a global volume change
apply_midi_track_mute is ordered .after(gameplay::lifecycle:: apply_music_volume) (see The Plugin Architecture for what this ordering primitive means). Both
systems can write the same sink’s volume in the same frame — dragging
the Options volume slider fires apply_music_volume, which sets every
MusicPlayer sink (per-track ones included) to the new global level,
which would silently un-mute a muted track if nothing ran afterward to
re-assert it. The explicit ordering constraint is what guarantees mute
always has the last word, deterministically, rather than depending on
whichever order Bevy’s scheduler happened to pick that frame.
The UI: one shared observer, not N closures
The per-track mute row (jam::midi_tracks::spawn_midi_track_row) spawns
one button per track, each tagged TrackMuteCell(index), all sharing
one toggle_track_mute observer function (cloned onto every
button’s entity) rather than a distinct closure capturing a different
index per button:
This is the same mechanical pattern gameplay::harmonica_overlay:: DiagramCellTarget already established for the Bending Trainer’s
selectable diagram cells — a reusable idiom in this codebase for “N
dynamically spawned, independently clickable things sharing one piece of
click-handling logic,” worth recognizing as a pattern rather than
reinventing per feature.
Looping preserves mute state for free
restart_finished_jam_music (which re-spawns every track’s sink
together once the previous set has fully finished, when Loop is on)
doesn’t touch JamMidiMute at all — it’s a resource independent of any
particular sink’s lifetime, so a track muted before the loop boundary
stays muted after new sinks spawn, with no explicit hand-off code
needed.
Improv and call-and-response
Two smaller jam submodules round out free-play practice, both built
on ImprovStats/ActivePitches (the same live-pitch data scoring
consumes — see The Scoring System) without scoring
anything themselves:
jam::improv— accumulates scale/chord-tone/phrase-discipline adherence continuously during any jam (an “always-on diagnostic,” the same conventionSongStats::clean_attackfollows in real gameplay), which several Lessons pass criteria read from without Jam Session needing to know Lessons exists at all — see The Lessons Engine.jam::call_response— freeform, unscored call-and-response: the game plays a short generated lick (rolled from harp-producible chord tones of the current bar) and gives the player a couple of bars to echo it by ear, with purely visual turn-taking feedback (a banner, the lick’s holes ghost-highlighted on the hole map) — deliberately noPitchGate/ImprovStatsinvolvement, since there’s no authored target to judge against, just a suggestion.
The Lessons Engine
The Lessons module (src/lessons/) is Harmonicon’s guided curriculum: a
tree of prerequisite-gated lessons, each judged by one of a small,
closed set of pass criteria, with progress persisted per player. This
chapter covers the manifest format, how a lesson actually runs
(mostly by reusing the ordinary gameplay pipeline, with one deliberate
exception), and how discovery works for both bundled and player-dropped
lesson content.
LessonManifest: the authored content
A lesson is a lesson.json file (assets/lessons/<unit>/<lesson>/,
schema-validated against assets/lesson_schema.dtd.json), with a stable
id (referenced by other lessons’ prerequisites and by
PlayerProfile’s progress records — ids are never meant to change once
published) and:
title_key/body_key— Fluent message keys, never raw display text. This is a hard rule, not a style preference: it’s what lets a lesson’s text be properly localized, and it means the Song Editor’s own lesson-authoring UI (see The Song Editor) can never accidentally write real display text into the manifest —serialize_lessonderives the keys from the lesson id and prints the key/text pairs to add by hand to the locale files, the same manual step every bundled lesson’s authoring already requires.chart— optional. Present for a lesson backed by an ordinary.harpchart(played through the unmodified gameplay pipeline — see below); absent for the handful of open-ended, jam-based lessons.prerequisites— a list of other lesson ids that must be passed first.pass_criteria— a small closed enum (not open-ended scripting):Accuracy { threshold },Technique { technique, threshold }, or one of three jam-based criteria (ScaleAdherence,ChordToneAdherence,PhraseDiscipline) for open improvisation lessons with no fixed notes to score.
Prerequisite gating is a pure function
is_unlocked takes the manifest and an already-resolved list of passed
lesson ids — it doesn’t reach into PlayerProfile itself. This keeps it
trivially unit-testable against plain data (see
Testing Strategy) and, more importantly, keeps
lessons::manifest ignorant of how progress is actually stored — the
same “low-level module doesn’t depend on the higher-level thing that
uses it” direction this codebase applies consistently (see
Module Boundaries and Dependency Rules).
Running a lesson: mostly the ordinary pipeline, with one exception
A chart-backed lesson plays through the exact same pipeline as an
ordinary song — Play2D/Play3D, the same SongChartLoader, the
same score_notes. There is no lesson-specific scoring path. What
changes is layered on top, via a LessonContext resource kept in flight
for the run’s duration: the results screen judges pass_criteria
against it instead of (or alongside) recording an ordinary song-best,
adaptive difficulty is forced off (a lesson’s own pacing shouldn’t be
further modulated by a second, unrelated pacing system — see
Application States and Modes for the general
routing-flag pattern LessonContext follows for “where do I land when
this run ends”), and the menu routes back to the lesson list rather than
the song list on exit.
The one exception: PassCriteria::ScaleAdherence/
ChordToneAdherence/PhraseDiscipline have no chart notes to score at
all — they’re an open GameplayMode::JamSession run judged on live
adherence data jam::improv::ImprovStats was already accumulating
continuously (see Jam Session), with a
dedicated “Finish Lesson” pause-menu button (visible only when a
LessonContext is in flight during a jam) that judges the accumulated
stats on demand, since there’s no natural chart end to trigger judging
automatically the way a scored song has.
Discovery: bundled plus external, kept live
lessons::catalog::scan_all_lessons scans assets/lessons and then, if
present, ~/Harmonicon/lessons — bundled entries first, so a
player-dropped lesson can never silently reorder or shadow shipped
curriculum. This mirrors assets_management’s own bundled-plus-external
pattern for songs and themes exactly (see Persistence
for the shared live-filesystem-watcher infrastructure both ride on) —
deliberately: lessons depends on assets_management for the low-level
watch machinery, never the other way around, since assets_management
is generic shared vocabulary that has no business knowing what a
“lesson” is. A live drop-in under ~/Harmonicon/lessons fires a
LessonsRescanned message the Lessons list page consumes to rebuild
itself if it happens to already be open — no restart, no manual refresh
button.
Progress
lessons::progress judges a finished run (Accuracy/Technique
thresholds against results::accuracy/SongStats; the jam-based
criteria against ImprovStats) and the result is written into
PlayerProfile (see Persistence) as a passed/not-yet-
passed record keyed by lesson id — the same profile file per-song best
scores and Bending Trainer drill records already live in, not a separate
lessons-specific save file.
Localization and Theming
Localization and theming are two separate subsystems (localization.rs
and theme.rs), but they’re covered in one chapter because they share
both a common shape — data loaded from files, resolved against a
player preference, applied reactively when that preference changes —
and a common history: both were reworked during the same push to get
Harmonicon running under WebAssembly, for the same underlying reason
(a wasm AssetReader fetches over HTTP and simply cannot list a
directory the way a native filesystem can), which makes them a useful
paired case study in why an asset-loading design choice that works
fine natively can quietly fail to generalize to a second target platform.
Localization: enforcement, then loading
Every user-visible string must come from loc.msg("key") (or
loc.msg_args for one with interpolated values), never a raw string
literal. This isn’t a style guideline enforced by review discipline —
it’s enforced by the build itself. build.rs statically scans every
source file for a handful of known “sink” shapes a raw string could
reach the screen through (Text::new("..."), a bsn! Text({"..."})
binding, a handful of shared label-spawning helpers) and fails the build
if it finds a literal that looks like natural-language text (a simple
two-feature heuristic: contains an ASCII letter and whitespace — this
deliberately doesn’t flag a single word like "Retry", which is a much
larger, separate content-migration effort). A LocalizedStr newtype
wraps every already-localized string so a value that’s passed through
several layers before display still carries the “this came from
loc.msg” guarantee with it.
Why loading is a fixed list, not a directory scan. The natural way
to load “every locale we ship” would be AssetServer::load_folder ("locales") — but that needs the asset reader to list the directory’s
contents, which bevy_asset::io::wasm::HttpWasmAssetReader cannot do
over plain HTTP. This used to hard-panic the game on startup under wasm
(bevy_fluent’s bundle builder indexing an empty map, since
load_folder silently found nothing). The fix: localization::LOCALES
is a fixed, three-element array of language tags, each loaded by an
explicit path (locales/<lang>/main.ftl.ron) — no directory listing
involved at all, so it works identically on native and wasm. A unit
test, locales_const_matches_the_assets_directory, keeps the constant
honest against what’s actually on disk (using a real
std::fs::read_dir — safe there specifically because tests always
run on the native host, never inside the wasm build itself).
This “fixed list instead of a directory scan” fix generalizes cleanly here because the set of shipped locales is genuinely small and rarely-changing — which is exactly the assumption that stops working for the next section.
Theming: names via a build-time manifest, content via a real Asset
Themes have the same directory-listing problem localization did, but a
critically different shape: while there are only three fixed locales, a
player can drop an arbitrary number of new songs, themes, and harmonica
models into ~/Harmonicon on native without a rebuild — so
assets_management’s song/theme/note-theme/harmonica-model discovery
cannot become a fixed compile-time list the way LOCALES did, without
breaking that entirely. This is the one place the localization fix’s own
pattern had to be rejected, deliberately, rather than reused:
assets_management’s scan functions (scan_all_songs,
scan_note_themes, scan_harmonica_models, scan_ui_themes) are each
two #[cfg]-gated implementations under the same name: the original
std::fs::read_dir-based body, completely unchanged, behind
#[cfg(not(target_arch = "wasm32"))]; and a #[cfg(target_arch = "wasm32")] sibling that reads a manifest build.rs generated at
build time instead (generate_wasm_asset_manifest, included via
include!(concat!(env!("OUT_DIR"), "/asset_manifest.rs"))). The insight
this rests on: a build script always compiles for, and runs on, the
native host, no matter what --target the crate itself is being
built for — so build.rs can do a completely ordinary
std::fs::read_dir walk of assets/ even while producing a wasm32
binary, mirroring each scan function’s own discovery rule exactly (the
first *.harpchart under a song’s song/ subfolder, and so on) so the
two implementations can’t silently drift apart. Native behavior is
exactly unchanged — a player can still drop a new song into
~/Harmonicon/songs/ and see it without a rebuild — because the
#[cfg(not(wasm32))] body never went away; only wasm, which has no
concept of “drop a file into a folder on the machine running the
browser” in the first place, gets the build-time-baked alternative.
Theme content, not just theme names, has a second, distinct
loading problem. Even once AvailableThemes correctly lists theme
names under wasm, actually applying a theme still needs to read that
theme’s theme.json — and the original theme::load_theme did that
with a raw std::fs::read_to_string, which fails identically under
wasm (a different mechanism than a directory listing: an actual file
read, not an enumeration). The fix here mirrors the chart loading
pipeline described in Chart Format and Asset Loading rather than the manifest trick above: ThemeJson
is now a real Bevy Asset, loaded through a small custom AssetLoader
(ThemeJsonLoader, matching song::loader::SongChartLoader’s shape —
registered by the compound extension "theme.json", not the bare
"json", so it can never collide with some other JSON asset gaining its
own loader later) — which works identically on native and wasm because
AssetServer itself already abstracts over “read this file,” the same
way it already did for a theme’s sibling images and sounds.
This changed load_theme from one synchronous function into two
systems — request_theme_load (kicks off the load, clears the previous
theme’s data immediately) and apply_theme_when_loaded (polls the
handle every frame, a no-op whenever nothing is pending, and populates
LoadedTheme once the load resolves) — because an AssetServer load is
inherently asynchronous; there’s no synchronous “just get me the bytes”
escape hatch that would also work under wasm.
The shared lesson
Both fixes are instances of the same underlying principle, applied
differently based on one question: does this data need to change at
runtime without a rebuild? Where the answer is no (three fixed
locales), bake it in at build time and load by explicit path. Where the
answer is yes but only on native (arbitrary player-dropped content),
keep the real runtime scan on native and bake in a build-time-computed
equivalent for wasm specifically, leaving native completely untouched.
Where the thing being loaded is genuine file content, not just a
listing of what exists, route it through AssetServer like any other
asset rather than reaching for std::fs directly — AssetServer was
already built to abstract over exactly this platform difference, for
every asset type that goes through it correctly. See
Native vs. WebAssembly for the fuller picture
of what’s ported to wasm today and what remains (mic capture, settings/
profile persistence, the external-folder watcher).
Persistence
Harmonicon persists three distinct kinds of state to disk, each with a
deliberately different save strategy, plus a live filesystem watcher
that makes one particular directory (~/Harmonicon) behave like a
content source the game keeps in sync with, not just a place it reads
from once at startup. This chapter covers all three save paths and the
watcher.
Settings vs. profile: two save strategies for two access patterns
settings.rs (AudioSettings and friends — volume levels, the chosen
pitch algorithm, input device, latency calibration, UI theme, adaptive
difficulty on/off) and profile.rs (PlayerProfile — per-song best
scores, per-technique best accuracy, Bending Trainer drill records,
total play time) both persist to JSON under the platform config
directory — harmonicon_platform::paths::config_dir, the single answer to
“where do we write”, #[cfg]-split because Android has no such thing as an
XDG config directory (dirs::config_dir() returns None there, so every
save silently no-opped and all progress was lost on exit; the sandbox path
comes from AndroidApp::internal_data_path() instead). Loading goes through
figment, layered so a fresh install gets sensible
defaults without a config file needing to exist yet, but they save on
opposite schedules, matched to how often each actually changes:
Settings are debounced because a slider drag can fire many change
events per second — writing a file on every single one would be wasteful
disk I/O for no benefit, since only the final value after the player
stops dragging actually matters. PendingSave restarts a 0.5-second
countdown on every change; tick_debounce writes once it elapses with
no further changes in the meantime, and AppExit flushes unconditionally
so a change made right before quitting is never silently lost to an
in-flight debounce that never got to fire.
The profile deliberately has no debounce machinery at all. A new
best score or drill record is inherently a rare, discrete event (once
per song completion at most, not many times a second), so there’s
nothing to batch — writing immediately, at the exact point the record
changes, is both simpler and loses no more data on an unexpected exit
than debouncing would. The one thing that does accumulate
continuously — total play time — is the one field flushed on AppExit
rather than written continuously, for the ordinary reason: nobody wants
a disk write every frame for a number nobody’s watching in real time.
~/Harmonicon: a second asset root, watched live
Beyond the bundled assets/ tree, Harmonicon registers a second,
optional AssetSource (external://, mapped to ~/Harmonicon — see
The Plugin Architecture for why this has to be
registered before DefaultPlugins) so a player can drop in their own
songs, themes, and lessons without touching the install directory at
all — and, going a step further, without even restarting the game.
A few design choices here are worth calling out:
- The watcher module itself is agnostic of what any subfolder
means.
assets_management::watchfires one genericExternalFolderChanged{top_level_dirs}message naming which immediate subfolders changed, without knowing or caring thatsongsmeans something toassets_managementandlessonsmeans something to a completely different module.lessons::catalogis its own, independent consumer of the same message — alessons-depends-on-assets_managementedge, never the reverse, sinceassets_managementis meant to be generic, low-level shared vocabulary (see Module Boundaries and Dependency Rules). - A dedicated
*Rescannedmessage, not just “the resource changed.” A menu page reacting toresource_changed::<AvailableSongs>would also see it as “changed” on the ordinary one-time Startup scan, and again every time the page re-enters and its own change-detection tick happens to fall after some unrelated write —SongsRescanned/ThemesRescanned/LessonsRescannedfire only when a live watcher event actually triggered a re-scan, which is the one distinction a menu page genuinely needs (“did something new just appear while I was sitting here” vs. “this resource simply exists”). - Every scan function fully replaces its resource’s contents, rather than appending — making every one of them safe to call a second time at runtime, not just once at Startup. This wasn’t always true (some scan functions used to only ever run once and assumed that), and fixing it was a prerequisite for live rescanning to be correct at all: an appending scan run twice would duplicate every previously-found entry.
- Deliberately not built on Bevy’s own asset-hot-reload path. Bevy
can watch and hot-reload already-loaded assets, but that’s useless
here specifically because the content this watcher cares about was
often never loaded in the first place — a brand-new song the player
just dropped in has no existing
Handlefor Bevy to reload. Separately, whether Bevy’s own watching is on at all is one global flag applied uniformly to every registeredAssetSource— enabling it forexternal://would also silently enable asset hot-reloading for the bundledassets/tree in shipped builds, which is explicitly a--features dev-only behavior everywhere else in the project.
None of this watcher infrastructure exists under wasm — there’s no
concept of a home directory, let alone a filesystem to watch, inside a
browser sandbox. See Native vs. WebAssembly
for what that means concretely (nothing wasm-specific was needed; the
native code already handles dirs::home_dir() returning None
gracefully) and for the persistence gap that is still open there
(settings/profile storage has no browser-compatible replacement yet).
Native vs. WebAssembly
Harmonicon’s primary target is a native desktop build, but the crate
also compiles for wasm32-unknown-unknown and boots and runs in a real
browser, verified with headless Chromium. This chapter is the
consolidated picture of what that involved: the build pipeline itself,
the dependency conflicts that had to be resolved to compile at all, the
asset-loading rework Localization and Theming and Chart Format and Asset Loading cover in depth from their own angles, a genuine
GPU-shader compatibility bug the wasm push surfaced, and — importantly —
an honest list of what still doesn’t work in a browser.
The build pipeline
Trunk (a Rust/Bevy-ecosystem-standard wasm bundler) drives the wasm
build from index.html and Trunk.toml at the repository root — it
compiles the crate for wasm32-unknown-unknown, runs wasm-bindgen to
generate the JS glue, and copies assets/ alongside the output. The
<canvas id="bevy-canvas"> element index.html declares is wired up in
lib.rs’s WindowPlugin (canvas: Some("#bevy-canvas".into()),
fit_canvas_to_parent: true, prevent_default_event_handling: true) —
all three fields are documented no-ops on native, so no #[cfg] is
needed around setting them unconditionally.
Two trunk serve traps, both fixed in Trunk.toml
Both produced a blank page with a working build, so neither points at
itself. Both are configuration, not code — trunk build --release plus any
static file server was unaffected the whole time, which is what makes them
so slow to find.
[serve] no_spa = true. Trunk’s SPA fallback answers every missing path withindex.htmland a200. Bevy’sAssetServerprobes for an optional<asset>.metasidecar next to each asset; none exist here, so instead of the404it expects (meaning “no meta, use defaults”) it gets HTML with a success status, fails to deserialize it as meta, and marks the asset failed. Localization is the fatal one —localization_readynever flips, so the app sits inAppState::Startup, which draws nothing but the clear colour. The tell isFailed to deserialize meta for asset ...in the browser console.[watch] watch = [...]. Trunk otherwise recursively watches the whole repo root.target-flatpak/var/runis a symlink to the real/run, so the walk descends into root-only directories like/run/udisks2andtrunk serveaborts before building anything withfailed to watch ... Permission denied. Listing the source paths explicitly keeps the watcher out of every build-output tree.
Getting it to compile at all: two dependency conflicts
Two unrelated dependency issues blocked a wasm32 build before any
actual feature work could begin, both worth knowing about since they’ll
resurface if a dependency bump reintroduces either:
- Two incompatible major versions of
getrandomin the tree at once — 0.4.x (pulled in viarand/Bevy) and 0.3.x (viaahash) — and both refuse to targetwasm32without an explicit opt-in.Cargo.toml’s[target.'cfg(target_arch = "wasm32")'.dependencies]section enables both explicitly (the 0.3.x line under a renamed package alias,getrandom03, since both can’t share the plaingetrandomname in oneCargo.toml), and the 0.3.x line additionally needs a compile-time--cfgthe Cargo feature alone doesn’t cover — hence theRUSTFLAGS='--cfg getrandom_backend="wasm_js"'in every wasm build/check command. This isn’t set globally in.cargo/config.tomlbecause that file is this repository’s per-machine, gitignored local build config (linker overrides,sccache), not somewhere to route a project-wide setting through. jsonschema’s default features pull inreqwest, which explicitly refuses to compile forwasm32at all (resolve-http/resolve-file, used for resolving a schema’s remote$refs — a capability nothing in this codebase’s schema validation actually uses, confirmed before disabling).jsonschema = { version = "0.28", default-features = false }fixes this with no behavior change on native either, verified by the full native test suite still passing unchanged.
The asset-loading rework
Covered in depth in their own chapters — this is the index:
- Localization and Theming: the
bevy_fluentstartup panic (load_folderneeding directory listing), fixed with a fixedLOCALESlist loaded by explicit path; andtheme::load_theme’s rawstd::fs::read_to_string, fixed by turningThemeJsoninto a realAssetServer-loadedAsset. - Chart Format and Asset Loading /
Persistence:
assets_management’s song/theme/ harmonica-model discovery, fixed with a#[cfg]-gated pair — native keeps its realstd::fs::read_dirscan unchanged, wasm reads abuild.rs-generated manifest instead (built at build time, on the native host, regardless of the crate’s own--target).
A genuine bug the wasm push surfaced: WebGL2 uniform alignment
Not every issue wasm exposed was a directory-listing problem. Once theme loading actually started working under wasm (rather than failing silently before that fix landed), the game reached, for the first time, a code path that actually used a custom WGSL shader material — the themed buttons’ animated smoke-shader background — and immediately hit a real GPU pipeline-creation error:
Desktop/native rendering backends tolerate a smaller-than-16-byte
uniform buffer binding just fine; WebGL2’s wgpu “downlevel” backend
(used when a browser doesn’t expose WebGPU, still the common case) does
not, and this shader’s time: f32 uniform was 4 bytes. This wasn’t a
new bug introduced by the wasm work — it was always broken for any
browser without WebGPU, just never discovered, because the earlier
theme-loading failure had been silently preventing this code path from
ever running under wasm at all. This is worth internalizing as a general
lesson about cross-platform testing: fixing one bug can be exactly what
it takes to reveal the next one behind it, and “it worked when I tested
wasm” can mean “the feature I was testing never actually ran,” not
“the feature works.” The project’s own resolution was to remove the
smoke-shader button effect entirely rather than pad the uniform to
16 bytes and keep it — a legitimate call given the effect’s actual
value versus the ongoing WebGL2-compatibility maintenance burden of
every future shader touching this material.
What still doesn’t work in a browser
Verified via headless Chromium (checked for zero panics across a full
run): WGPU initializes, localization loads, mic capture fails
gracefully exactly like a real permission-less browser would
(MicStatus::Failed, no panic — cpal simply has nothing to talk to
under wasm), and bundled songs/themes/note-themes/harmonica-models all
load correctly. What’s explicitly still missing, none of which have a
drop-in browser equivalent to reach for:
- Actual microphone input. The whole point of the game — cpal has
no wasm backend; a real implementation needs a Web Audio API bridge
(
AudioContext/MediaStreamAudioSourceNode, called throughwasm-bindgen/web-sys), feeding the samepitch_detect::analyzepipeline The Audio Input Pipeline describes — the pipeline’s analysis side needs no change at all, only the capture side needs a second implementation. - Settings and profile persistence.
figment/dirs-based JSON files have no meaning in a browser sandbox; a wasm build would need something likelocalStorageor IndexedDB instead — see Persistence. - The
~/Harmoniconexternal-folder watcher. No home directory concept in a browser at all — the native code already handlesdirs::home_dir()returningNonegracefully (no external songs/ themes/lessons, no watcher started), so nothing wasm-specific was needed to avoid a crash here; a real wasm equivalent (letting a player add their own content some other way — a file picker, drag-and-drop into the page) is unexplored.
Each of these three is a real, standalone piece of engineering — not a loading-order fix like the ones this chapter otherwise covers — and each would benefit from its own design discussion before being started, per this project’s own working practice of not guessing at scope for a large, undesigned subsystem.
Android
The Android port builds a real, installable APK and runs. This chapter is the architectural half — the shape the port forced on the codebase, and the traps that cost the most time. For build commands, emulator setup and the current verified/unverified split, see Building and Running on Android.
Android inverted the entry point
Android never calls a main. The platform loads a shared object and calls
android_main, handing over an AndroidApp that owns the event loop and
the JNI handles. That single fact reshaped the workspace root.
The composition root moved out of src/main.rs into src/lib.rs’s
run(), so both entry points are thin wrappers around one shared
assembly:
The cdylib is its own crate rather than a second crate-type on the
root package, because default-members includes every workspace member: a
cdylib on the root would relink the entire Bevy app on every desktop
cargo build. Instead harmonicon-android’s dependency on the game is
target-gated and its lib.rs is entirely #[cfg(target_os = "android")],
so off Android it compiles to an empty cdylib with no dependencies —
measured at 4.2 MB with zero Bevy symbols, against a 517 MB desktop
binary.
harmonicon-android is therefore the one crate that sits above the root
package in the layering, and the only cdylib.
Assets live inside the APK
An APK’s assets are inside the archive, reachable only through the JNI
AssetManager. std::fs::read_dir("assets/songs") returns Err, so the
runtime scans find nothing at all.
This is the same constraint wasm already had, so Android reuses the same
solution — the #[cfg]-split scan functions backed by a
build.rs-generated manifest described in Chart Format and Asset
Loading and Native vs. WebAssembly. The predicate widened from wasm32 to
any(target_arch = "wasm32", target_os = "android").
The framing that matters: the condition is “this target’s assets/ is
not a readable local directory”, not “this target is not desktop”. iOS
is deliberately excluded — an app bundle’s Resources directory reads
like any other, so iOS keeps the runtime scan and the ~/Harmonicon
drop-folder dynamism that comes with it.
Doing this surfaced that lessons were broken on wasm too:
lessons::catalog had no manifest path at all, because it reads each
lesson.json’s bytes directly rather than through AssetServer. It needed
its own build script (OUT_DIR is per-package) embedding the JSON text
with include_str!, not just directory names. Fixing Android fixed the
web build’s silently-empty Lessons menu.
Two failures that only appear at runtime
Both compiled cleanly, packaged cleanly, and passed every static check on the APK. This is the argument for keeping an emulator in the loop rather than trusting a green build.
ClassNotFoundException for GameActivity — while the class was in
classes.dex. The real cause hid in a suppressed exception:
NoClassDefFoundError: AppCompatActivity. GameActivity extends it, but
games-activity’s POM declares no dependencies at all, so appcompat
was never pulled in transitively.
NoSuchMethodError on Application.requestPermissions —
ndk_context::android_context() is the obvious way to reach the app
context from Rust and is wrong for this: android-activity registers the
Application there, not the Activity. Application is a Context,
so checkSelfPermission resolves and appears to work, while
requestPermissions — declared on Activity — throws. The Activity comes
from AndroidApp::activity_as_ptr.
That one also cascaded: a throwing JNI call leaves the exception pending
on the thread, so every later call fails with the same opaque “Java
exception was thrown”, once per frame, never showing the cause.
permission.rs’s with_activity now calls
exception_describe/exception_clear, which is what surfaced the real
error in logcat.
Version and API constraints that fail late
- The GameActivity AAR version is pinned to the C++ vendored in the Rust
crate.
android-activity’sGameActivity.hdeclares version 4.4.0, so Gradle pinsandroidx.games:games-activity:4.4.0. A mismatch aborts inRegisterNativesat runtime. - API 28 is a hard floor. cpal links
libaaudio, which only exists in the NDK sysroot from API 26 up; below it the link fails with a bareunable to find library -laaudio. - The Android-only Bevy feature selection lives in
harmonicon-android’s ownCargo.toml, not the root package, socargo ndk -p harmonicon-androidkeeps it. On the root package,-psilently drops it and you get a build with no activity backend.
Persistence had to move
dirs::config_dir() returns None on Android — an app has no XDG config
directory, only a sandbox — so every save silently no-opped and all
progress vanished on exit. See Persistence for
harmonicon_platform::paths::config_dir, the #[cfg]-split single answer
to “where do we write”.
What the port has not established
It runs on an emulator. Nobody has played a harmonica into a phone, and
that is the whole product: an emulator opening a capture stream says the
plumbing is connected, not that pitch detection survives a phone mic’s AGC
and noise suppression. Touch gestures are likewise emulator-only — and
can’t be scripted there, since adb shell input has no multi-touch.
Building and Running on Android
The operational half of the Android port — how to build it, how to run it on an emulator, and exactly how far it has actually been taken. For the shape the port forced on the codebase, see Android.
Status: runs on an emulator; never run on real hardware
Verified, by actually running it on an Android 15 (API 35) x86_64 emulator:
- It launches, and the main menu renders correctly — background art, fonts, all four buttons.
- Assets load out of the APK. That background is a theme asset, so the
build-time manifest path works in a real
AssetManagerenvironment. - The permission flow works end to end: the system
GrantPermissionsActivitydialog appears, and once the permission is granted the polling retry picks it up and opens a capture stream —Input device : Default Device / Sample rate : 44100 Hz | channels: 2 | format: F32. - The APK’s contents were also inspected statically: cdylib exporting
android_mainandGameActivity_onCreate,GameActivityinclasses.dex,RECORD_AUDIOdeclared, 186 asset entries,debug_songsexcluded.
CI’s android_check job type-checks the target on every push.
Not verified. No real device, so: nobody has played a harmonica into it. An emulator opening a capture stream says the plumbing is connected; it says nothing about latency, gain, or whether pitch detection works against a phone mic’s AGC and noise suppression — which for this game is the whole product. Also unknown: touch target sizes, whether landscape-only is right, real frame rates, and whether the Song Editor is usable on a phone at all.
Two bugs were found only by running it, both since fixed — see “Two runtime-only failures” below. Neither was visible at build time.
Persistence
dirs::config_dir() returns None on Android — an app has no XDG config
directory, only a sandbox — so every save used to silently no-op and all
progress was lost on exit.
harmonicon_platform::paths::config_dir is now the single answer to “where
do we write”, #[cfg]-split: dirs on desktop,
AndroidApp::internal_data_path() (via bevy’s ANDROID_APP) on Android.
Both settings.json and profile.json go through it.
Verified on the emulator: settings.json appears in
/data/data/io.github.tcanabrava.harmonicon/files/, and corrupting it makes
the app log “Could not read settings” on the next launch — so both
directions are wired, not just the write.
Building
export ANDROID_HOME="$HOME/Android/Sdk"
cd packaging/android
./gradlew assembleRelease # -> app/build/outputs/apk/release/app-release.apk
./gradlew installRelease # with a device connected via adb
Requires the SDK (platform 35, build-tools 35.0.1), NDK 28.2.13676358, a
JDK 17+ (Android Studio’s bundled jbr works), and cargo-ndk. Gradle comes
from the committed wrapper.
If cargoNdkBuild fails with “found crate … compiled by an incompatible
version of rustc”, it is almost certainly a stale Gradle daemon. The daemon
captures its environment (including PATH) when it starts and reuses it for
every later build, so a PATH change — or picking up a second cargo from
~/.cargo/bin that differs from the system one — keeps biting long after you
fixed it in your shell. ./gradlew --stop and build again. If your SDK isn’t at $ANDROID_HOME, put
sdk.dir=/path/to/sdk in packaging/android/local.properties (gitignored).
The Gradle build invokes cargo ndk itself — there is no separate Rust step
to remember. Expect ~6 minutes for a cold Rust release build.
Other ABIs, and the emulator
Only arm64-v8a is built by default: every phone worth targeting is arm64,
and each extra ABI costs another full Rust build plus ~108 MB of APK. A
desktop emulator is x86_64, so it needs an override:
./gradlew installRelease -Pharmonicon.abis=x86_64
Comma-separate for a fat APK (-Pharmonicon.abis=arm64-v8a,x86_64).
Running it on the emulator
One-time setup, if the AVD doesn’t exist yet:
sdkmanager "emulator" "system-images;android-35;google_apis;x86_64"
avdmanager create avd -n harmonicon-test \
-k "system-images;android-35;google_apis;x86_64" -d pixel_6
Then, each time:
export ANDROID_HOME="$HOME/Android/Sdk"
export JAVA_HOME=/opt/android-studio/jbr # or any JDK 17+
export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH"
emulator -avd harmonicon-test & # add -gpu host if it's slow
adb wait-for-device
cd packaging/android
./gradlew installRelease -Pharmonicon.abis=x86_64
adb shell am start -n \
io.github.tcanabrava.harmonicon/com.google.androidgamesdk.GameActivity
Tap Allow on the microphone prompt (or adb shell pm grant io.github.tcanabrava.harmonicon android.permission.RECORD_AUDIO — the
polling retry picks either up within a frame or two).
Do not pass -no-audio if you want to test the mic: the emulator
forwards the host’s input device, which is how the capture stream gets a
real signal. Headless (-no-window) is fine for checking it boots.
Watch the game’s own output with:
adb logcat | grep -E "RustStdoutStderr|harmonicon"
Bevy’s LogPlugin filter is warn by default, so info! lines don’t
appear; println! does, via RustStdoutStderr.
Two runtime-only failures
Both compiled cleanly, packaged cleanly, and passed every static check on the APK. Only launching it found them. This is the argument for keeping an emulator in the loop.
ClassNotFoundException: com.google.androidgamesdk.GameActivity
The class was in classes.dex — the real cause was hidden in a
suppressed exception: NoClassDefFoundError: androidx/appcompat/app/AppCompatActivity.
GameActivity extends AppCompatActivity, but the
games-activity-4.4.0.pom declares no dependencies at all, so appcompat
was never pulled in transitively. It has to be an explicit
implementation("androidx.appcompat:appcompat:...").
That also forces the theme: AppCompatActivity refuses to start under a
plain platform theme, so @android:style/Theme.NoTitleBar.Fullscreen had to
become a Theme.AppCompat.NoActionBar descendant
(res/values/themes.xml).
NoSuchMethodError: Landroid/app/Application;.requestPermissions
ndk_context::android_context() is the obvious way to reach the app’s
context from Rust, and it is wrong for this: android-activity registers
the Application there, not the Activity (its init.rs,
initialize_android_context(vm, app_global)).
Application is a Context, so checkSelfPermission resolves on it and
appears to work — but requestPermissions is declared on Activity, so it
threw. The Activity has to come from AndroidApp::activity_as_ptr, which
Bevy keeps in ANDROID_APP.
The failure also cascaded: a throwing JNI call leaves the exception pending
on that thread, so every later call failed with the same opaque “Java
exception was thrown” — one bad call per frame, and the real cause never
shown. with_activity now calls exception_describe/exception_clear,
which is what surfaced the actual NoSuchMethodError in logcat.
Decisions worth knowing
GameActivity, and why its version is not free to choose
android-activity requires exactly one backend and Bevy 0.19 selects
neither by default (its defaults are just 2d/3d/ui/audio), so this
had to be chosen explicitly. GameActivity was picked over NativeActivity for
its far better soft-keyboard/IME handling, which the Song Editor’s text
fields need.
The Java AAR version must match the C++ vendored in the Rust crate.
android-activity’s GameActivity.h declares
GAMEACTIVITY_MAJOR/MINOR/BUGFIX_VERSION as 4/4/0, so the Gradle
dependency is pinned to androidx.games:games-activity:4.4.0. A mismatch
fails at runtime — RegisterNatives aborts the process — not at build
time. Re-read those defines before bumping android-activity.
API 28 is a hard floor, not a preference
cpal’s Android backend links libaaudio, which only exists in the NDK
sysroot from API 26 up. Below that the link fails with a bare unable to find library -laaudio that says nothing about why. minSdk in
app/build.gradle.kts, the -P passed to cargo-ndk, and CI’s check all
have to agree; 28 also comfortably clears the API 23 floor for the runtime
permission API permission.rs calls.
The Android-only Cargo config lives in harmonicon-android
It used to sit on the root package, which meant cargo ndk -p harmonicon-android silently dropped it and produced a build with no
activity backend. It now lives in the crate that is the Android target, so
-p works. Keep it there.
Note cargo-ndk spells platform -P; -p is cargo’s package flag. Passing
-p 28 gets you unknown package: 28.
No [package.metadata.android]
That block belongs to cargo-apk, which is deprecated and cannot emit a Play
Store AAB. Gradle can (./gradlew bundleRelease), which is why the packaging
went this way.
Asset discovery goes through the build-time manifest
An APK’s assets live inside the archive, reachable only through the JNI
AssetManager — std::fs::read_dir("assets/songs") returns Err, and the
runtime scans would find nothing at all.
This is the same constraint wasm already had, so Android reuses the same
solution: #[cfg]-split scan functions backed by a build.rs-generated
manifest, now keyed on
any(target_arch = "wasm32", target_os = "android").
- Lessons were broken on wasm before this, not just on Android.
lessons::cataloghad no manifest path at all, because it reads eachlesson.json’s bytes directly rather than throughAssetServer. It now has one (crates/harmonicon-song/build.rs), embedding the JSON text withinclude_str!. Fixing Android fixed wasm. - iOS is deliberately excluded. An app bundle’s Resources directory
reads like any other, so iOS keeps the runtime scan and the
~/Harmonicondrop-folder dynamism.
The microphone permission
RECORD_AUDIO is “dangerous”: declaring it in the manifest only makes it
requestable, and until the user grants it, opening a cpal input stream fails
indistinguishably from a broken device.
harmonicon-audio’s permission module calls
Activity.checkSelfPermission/requestPermissions over JNI.
audio_input::start_capture asks first and parks in
MicStatus::AwaitingPermission — a state that already existed as
groundwork, which the Options page already renders a banner for — and
retry_capture_when_permission_granted polls until the dialog is answered.
It polls because the result is delivered to an onRequestPermissionsResult
callback on a Java activity this codebase doesn’t own, and a once-per-install
dialog doesn’t justify routing that back across JNI.
Off Android, microphone_granted() returns true and
request_microphone() does nothing, so call sites need no #[cfg].
android_main, and why the root package has a library
Android never calls main: the platform loads a shared library and calls
android_main. That forced the composition root out of src/main.rs into
src/lib.rs’s run(), which both entry points now call.
The cdylib is its own crate rather than a second crate-type on the root
package because default-members includes every member — a cdylib on the
root would relink the whole Bevy app on every desktop cargo build. Instead
harmonicon-android’s dependency on the game is target-gated and its
src/lib.rs is entirely #[cfg(target_os = "android")], so off Android it
is an empty cdylib with no dependencies (measured: 4.2 MB with zero Bevy
symbols, against a 517 MB desktop binary).
Size
147 MB APK: a 108 MB stripped cdylib (stored uncompressed so it maps
straight out of the APK rather than unpacking a second copy into /data)
plus ~38 MB of assets. Only arm64-v8a is built; adding armeabi-v7a means
another full Rust build and roughly doubles the download. If this needs to
come down, the first target is assets/themes (21 MB of the 38).
What has not been done
- Never installed or launched. See the top of this file.
- No touch-input pass. Keyboard-only actions already have on-screen equivalents, but nothing has been sized for a thumb.
- No app icon — the APK currently has none (
icon=''). - Only arm64-v8a.
- Release builds are debug-signed, so
assembleReleaseproduces something installable without a keystore. Replace before distributing. - Opening the user guide does nothing.
help_about::open_in_default_appreturnsUnsupported; doing it properly means handing anIntentto the system over JNI. - iOS remains untouched and needs Xcode.
Module Boundaries and Dependency Rules
Harmonicon has no Cargo-workspace boundaries between its subsystems (see System Overview for why it’s one library crate) — which means nothing at the compiler level stops any module from importing any other. The structure this chapter describes is enforced by a mix of one automated test, and — for everything the test can’t check — reviewer discipline against a written-down rule. This chapter states the rules, the one place they’re mechanically checked, and a documented exception worth understanding rather than working around.
Rule 1: unrelated things do not share a file
A file is one concern; its name says what that concern is; landing on a
file via a grep hit should mean everything in it is relevant to what you
were looking for. This is checked mechanically:
tests/physical_design.rs::no_file_exceeds_the_line_budget_unless_ allowlisted enforces a ~1000-line budget on non-test code per file (test
modules — #[cfg(test)] mod tests { ... } or a sibling tests.rs — are
excluded from the count, and files literally named tests.rs are
skipped as pure test content with no budget of their own).
The allowlist isn’t an escape hatch that quietly accumulates forever —
a second test, allowlist_has_no_stale_entries, fails the build if an
allowlisted file has already dropped back under budget, which is what
makes the list function as an honest burndown chart of known,
intentional debt rather than a ratchet that only ever grows. New code
isn’t allowed to add itself to the list preemptively — the rule this
enforces is “split before adding to an already-large file,” not “budget
permission in advance.”
This rule has real teeth: a 2026-07 pass (docs/physical_design_plan.md)
measured gameplay/mod.rs at 2,921 lines mixing plugin wiring, ~30
resource/component/message types, the score-state model, a 250-line
scoring system, HUD updates, and 1,250 lines of inline tests (43% of the
file) — and split it into the gameplay/ module structure described in
The Scoring System and The Gameplay Clock today. The Song Editor’s own snap.rs (see
The Song Editor) was split out of
state.rs for exactly this reason, as recently as the same session that
built the feature living in it — this isn’t a one-time historical
cleanup, it’s an ongoing discipline applied as code is written.
Rule 2: folders match modules, and dependencies point downward
A module’s physical location should reflect its level: low-level shared vocabulary at the bottom, features in the middle, app-wiring at the top — and nothing should import upward. System Overview’s package diagram shows the intended shape; this section covers what “pointing the wrong way” actually looked like before it was fixed, as a concrete illustration of the rule rather than an abstract statement of it.
AppState used to live inside menu. Conceptually, an app-wide
state machine is vocabulary every feature shares, not a menu concern —
but historically it lived in menu/mod.rs, so gameplay (seven
files), song_editor, spectrogram, and profile all had to
use crate::menu::... to reach it, even though ten of the eleven things
they were actually importing from there (AppState, GameplayMode,
SelectedSong, ReturnToSongList) had nothing to do with menus at all.
Anyone asking “what depends on the menu?” got a misleading answer, and
any review of menu code pulled in readers who only ever wanted the
state enum. The fix was mechanical once diagnosed: this vocabulary now
lives in app.rs at the crate’s top level (see
Application States and Modes), which every feature —
menu included — depends on downward, and nothing depends on upward.
gameplay::call_response used to import song_editor::playback
directly for its synth — two peer features welded sideways, when the
synth (audio_system::synth, see
The Audio Input Pipeline) is shared audio
infrastructure with no real business living inside an editor tool.
Moving the synth down to audio_system — vocabulary both gameplay
and song_editor can depend on independently — removed the sideways
edge entirely, rather than leaving one feature depending on the other’s
internals.
The documented exception: composition roots
One place in the codebase looks, at first glance, like it violates “dependencies point downward” — and is worth naming explicitly as a deliberate, understood exception rather than either hiding it or mistaking it for a bug to fix:
gameplay::plugin — the one file responsible for assembling the entire
AppState::Playing system schedule, across all three GameplayMode
values — imports from jam to register Jam-Session-specific systems
into that shared schedule. Read naively, that’s gameplay depending on
jam, while Jam Session also describes
jam’s own feature code depending on gameplay’s core primitives
(GameplayClock, MusicPlayer) — which would be a real circular
dependency, and a real problem, if both directions were the same kind
of dependency. They aren’t: gameplay::plugin is acting as a
composition root — the one place in the codebase whose entire job
is wiring separately-developed pieces together — and a composition root
being coupled to everything it composes is not the same failure mode as
two peer features being coupled to each other’s internals. The rule
this exception doesn’t violate: jam’s own feature logic never
reaches into gameplay’s feature logic (2D/3D rendering, scoring) —
only into the shared low-level vocabulary gameplay::state exists
specifically to expose, the same primitives any other feature is free
to depend on too.
The practical test for “is this a legitimate composition-root edge, or
an actual layering inversion sneaking in”: does the dependency go from
assembly/wiring code down into a feature’s own systems/resources (fine — that’s what a composition root does), or does it go from one
feature’s own business logic sideways into another feature’s own
business logic (the call_response/song_editor::playback case above
— not fine, and the kind of thing worth flagging in review the same way
the historical AppState-in-menu case would be today).
What isn’t enforced mechanically
The file-size budget is the one rule with a real, running test behind
it. Dependency direction itself has no equivalent automated check
today — a Cargo workspace with real crate boundaries would get one for
free (an illegal use simply wouldn’t compile), which is the main
thing a future workspace split, if the project ever grows to warrant
one, would buy back over the current single-crate structure. Until
then, this chapter — and a reviewer who’s read it — are the mechanism.
Testing Strategy
Harmonicon’s test suite (several hundred #[test] functions across
src/, plus a handful of top-level integration tests in tests/)
follows a consistent, project-wide convention: pure functions and unit
tests first, the ECS system that drives them second — and, alongside
the ordinary test suite, a set of build-time static checks that catch
a specific class of bug no amount of unit testing would reach, because
the bug isn’t in any function’s logic at all. This chapter covers both.
Pure functions and unit tests first
Wherever a new mechanic has any logic worth getting right — timing-
window classification, tick-to-seconds conversion, snap-point selection,
prerequisite gating, drift-correction math — that logic is written as a
plain function taking plain data and returning plain data, with no
World, Query, Res, or other ECS type anywhere in its signature,
before the ECS system that calls it with real per-frame values exists.
The Scoring System, The Gameplay Clock, The Song Editor’s
snap functions, and The Lessons Engine’s
is_unlocked are all examples this book has already covered in depth;
the pattern repeats throughout the rest of the codebase at the same
density.
The reason this ordering matters, not just “having both kinds of test”
in the abstract: pure-function tests are fast, don’t need any Bevy
scaffolding, and pin down the actual logic precisely — as the Song
Editor chapter’s grid-snap case study
shows, this is what makes it cheap to verify a change like “does
snap_absolute_tick correctly wrap across a beat boundary” in isolation,
without also standing up a World and simulating a drag gesture through
it. ECS-level tests are reserved for what can only be verified with real
Bevy machinery involved: system ordering, state-transition behavior,
whether a resource actually gets initialized — using either a minimal
World + Schedule (for a handful of systems in isolation) or a full
App + relevant plugins (for state-transition-dependent behavior,
menu/mod.rs’s and gameplay/tests.rs’s style).
Static checks that run at build time, not test time
A small number of correctness properties in this codebase are checked
by build.rs itself — meaning a violation fails cargo build
(and thus cargo test, cargo run, everything) before a single test
even runs. Both exist because the failure mode they catch is a runtime
panic, not a compile error, and one that’s easy to introduce without
noticing and easy to miss until the exact code path happens to execute:
- Localization enforcement —
build.rsscans every source file for a handful of known sink shapes (Text::new("..."), absn!literal binding, a fixed list of shared label-spawning helpers) and fails the build on a literal that looks like natural-language text. See Localization and Theming. - Message registration — every
#[derive(Message)]type must appear in some.add_message::<T>()call somewhere in the codebase, or Bevy panics at runtime the first time a system’sMessageReader/MessageWriterfor it actually runs — which can be well after the type was first added, since the type itself compiles fine unregistered.build.rscross-references every declared message type against every registration call, statically. See The Plugin Architecture.
Both scans are intentionally simple, line-oriented text matching rather
than a real parse of the Rust source (documented explicitly in
build.rs’s own module comment, including the specific patterns each
one can and can’t see through) — a deliberate scope trade-off: a real
AST-based check would be more precise, but a purpose-built text scan for
these two specific, narrow shapes is far less code, has no parser
dependency, and in practice catches what it’s meant to.
Integration tests: schema validation and physical structure
Three top-level suites under tests/ check properties that span many
files at once, which don’t naturally belong inside any single module’s
own unit tests:
-
tests/asset_layout.rs— schema-validates every bundled song chart, theme, and lesson against their respective JSON schemas, and checks completeness (a lesson’s referenced chart file actually exists, a lesson’s prerequisite ids actually resolve to other real lessons, a lesson’s Fluent keys actually exist in every locale). This is what keeps bundled content — not code — from silently rotting as the schemas or the content itself evolve independently. -
tests/physical_design.rs— the file-size budget enforcement described in Module Boundaries and Dependency Rules. -
tests/glyph_coverage.rs— every character the game draws must exist in one of the bundled fonts. A character missing from all of them renders as a tofu box: it compiles, passes every other test, and is visible only in a rendered frame, in the right language. Five shipped that way in all three locales before this existed.It deliberately reads the font binaries’
cmaptables rather thandialogs::font_fallback’s hand-maintained lists, because that list is a statement of intent — adding a codepoint to it without also re-subsetting the.ttfleaves the glyph exactly as missing. It covers both locale values and\u{...}escapes in source, since button icons live in source and a locale-only scan would miss every one of them.Three deliberate exclusions, each of which was a false positive while writing it:
Bravura.otfcounts as coverage (the notation staff draws SMuFL private-use codepoints with it), invisible formatting characters are skipped (bidi isolates have no glyph by design —localization:: strip_bidi_isolatesnames U+2068/U+2069 precisely to remove them), and nothing below U+2000 is checked.
Developer tools as their own kind of testing infrastructure
src/bin/ holds three small binaries, sharing the library crate (see
System Overview), each existing specifically to make some
kind of manual verification faster than it would be through the full
game:
hole-editor— positions the clickable hole overlays on a 3D harmonica model, writing the sameholes.jsonformatgameplay_3d/bending_trainerread at runtime — a visual tool for content that would otherwise mean hand-editing pixel coordinates in a text editor and reloading to check them.note-editor— a visual editor for the 2D/3D note-head tail layout configs (NoteThemeConfig/NoteCube3dConfig— see Chart Format and Asset Loading).note-bench— an offline pitch-detection benchmark: replays a “debug recording” (raw captured mic audio plus the chart and detection metadata, dumped by the Song Editor’s own--features dev“Debug Recording” checkbox) through each of the five selectable algorithms (see The Audio Input Pipeline) and reports a hit/ miss/phantom summary. Its comparison logic lives in the library (note_bench.rs), not the binary, specifically so it’s directly unit- testable against synthetic inputs without needing a real recorded WAV file — the same “pure logic first” split this whole chapter describes, applied to a benchmarking tool rather than a gameplay feature. This exists as the deliberate first step of a benchmark-first policy for ever touching the detection algorithms themselves: don’t change detection logic on a hunch, measure it against a reproducible dataset first.
Profiling with Tracy
cargo run --release --features trace_tracy, with the Tracy UI
(https://github.com/wolfpld/tracy) already open and “Connect” clicked.
How it hangs together
trace_tracy (Cargo.toml) just forwards to bevy/trace_tracy, which already wraps every ECS system
call in its own info_span!("system", name = ..) — most of what shows up
in Tracy needs no manual instrumentation at all. Two things this crate adds
on top:
main.rs’sLogPluginis feature-gated: the everyday filter ("warn,bevy_render::camera=error") sets the default level belowinfo, which silently drops every span (Bevy’s own and ours) before any backend — Tracy included — ever sees them (seeLogPlugin::build_filter_layer, which foldsfilter’s own bare directives overlevel). Atrace_tracybuild swaps in a filter with no bare-level directive belowinfo, so the configuredLevel::INFOdefault actually holds.- Manual spans cover the paths automatic per-system instrumentation can’t
reach — anything that isn’t itself a system call. Two categories so far:
- Off the ECS schedule entirely: the cpal capture callback
(
audio_input::push_chunks) runs on its own real-time thread; the only customAssetLoader(song::loader::SongChartLoader::load) runs as a future on the AssetServer’s IO task pool. Both get a manual span for the same reason — Bevy’s per-system spans only wrap systems the schedule itself calls, so anything running elsewhere (another thread, another executor) is otherwise invisible no matter how expensive it is. A span held across an.awaitneedstracing::Instrument(viabevy::log::tracing::Instrument) rather than a plain.entered()guard — anEnteredSpanisn’tSend, which the loader’s returned future must be;SongChartLoader::loadis a thin wrapper that instruments aload_innerfor exactly this reason. - A hot inner loop worth breaking out of its system’s own total time:
pipeline::process_audio’s per-chunk work;pitch_detect::analyze’s FFT transform and per-algorithm dispatch;build_nmf_dict(the priciest one-off, rebuilt only when the NMF dictionary goes stale);waveform::analyze_ogg_waveform/analyze_wav_waveform(a whole-file decode — also called from the off-schedule asset loader above, so it carries both reasons at once). Add spans the same way for any other code that runs off the main schedule (more asset loaders, decode threads, the asset watcher — thoughassets_management::watch’s debouncer thread runs onlynotify-debouncer-full’s own code, nothing of ours, so there’s nothing to instrument there) or burns real time inside a single system call.
- Off the ECS schedule entirely: the cpal capture callback
(
Driving a Running Game from Outside
--features dev starts a Bevy Remote Protocol server — JSON-RPC over
HTTP on 127.0.0.1:15702 — so a running game can be inspected, mutated,
screenshotted and recorded from a shell, with no rebuild and no code added
at each interesting moment. Wiring is src/dev_capture.rs.
Never shipped. BRP is unauthenticated and can read and mutate arbitrary
world state. dev is a compile-time feature, so a release build doesn’t
merely disable it, it doesn’t contain it.
Running
cargo run --features dev
Launching the built binary directly needs one extra thing, or every asset
fails to load and you get a blank window: Bevy resolves assets/ relative to
the executable (target/debug/assets), not the working directory, unless
CARGO_MANIFEST_DIR is set — which cargo run does and a bare ./harmonicon
does not.
BEVY_ASSET_ROOT="$PWD" ./target/debug/harmonicon
(configured_asset_plugin in src/lib.rs special-cases this for macOS debug
builds only; everywhere else, use one of the two forms above.)
A helper for the examples below:
brp() { curl -s -X POST http://127.0.0.1:15702 \
-H 'Content-Type: application/json' -d "$1"; }
Screenshots → target/screenshots/
Bevy’s own Screenshot component is Reflect and registered, so BRP can
spawn one. dev_capture’s global observer writes whatever gets captured:
brp '{"jsonrpc":"2.0","id":1,"method":"world.spawn_entity","params":
{"components":{"bevy_render::view::window::screenshot::Screenshot":
{"Window":"Primary"}}}}'
Files are named shot_<unix_millis>.png, so repeated captures accumulate
rather than overwriting. Deliberately not bevy’s save_to_disk, which takes
one fixed path.
Video → target/video/
Set frames_left; one frame is captured per rendered frame until it reaches
zero. Each recording gets its own numbered directory.
brp '{"jsonrpc":"2.0","id":2,"method":"world.mutate_resources","params":
{"resource":"harmonicon::dev_capture::VideoCapture",
"path":".frames_left","value":300}}'
The game writes numbered PNGs; encoding is left outside, since pulling a video encoder into the dependency tree for a dev tool isn’t worth it:
ffmpeg -y -framerate 30 -i target/video/0001/frame_%06d.png \
-c:v libx264 -pix_fmt yuv420p target/video/0001.mp4
Capturing every frame stalls the render loop — each frame is a GPU readback. Expect the recorded clip to run slower than real time, and don’t use it to judge performance or timing. It shows what happened, not how fast.
Inspecting and mutating state
# What the menu actually says right now
brp '{"jsonrpc":"2.0","id":3,"method":"world.query","params":
{"data":{"components":["bevy_ui::widget::text::Text"]},"filter":{}}}'
# Everything reachable
brp '{"jsonrpc":"2.0","id":4,"method":"world.list_components"}'
brp '{"jsonrpc":"2.0","id":5,"method":"world.list_resources"}'
brp '{"jsonrpc":"2.0","id":6,"method":"rpc.discover"}'
Type paths are the full Rust paths and they matter: UI text is
bevy_ui::widget::text::Text, not bevy_text::text::Text (which exists,
is registered, and matches nothing on a UI node).
world.mutate_components/world.mutate_resources change state live, and
world.write_message/world.trigger_event fire messages and events without
synthesising input — useful precisely where synthetic input is unreliable
(see Building and Running on Android on sub-frame taps
being dropped).
The catch: only reflected, registered types are visible
BRP reaches everything through AppTypeRegistry. Bevy’s own components are
registered, which covers the whole UI tree, text, transforms and windows.
Most of this codebase’s own types are not — EditorToolbar,
MicStatus, Scroll and friends are plain #[derive(Component)]/
Resource, so world.list_resources shows only the handful that derive
Reflect and call register_type precisely so they can be driven from
outside: VideoCapture, and NextState<AppState>/NextState<MenuPage>.
Add #[derive(Reflect)] + app.register_type::<T>() per type as the need
arises, rather than blanket-deriving it.
Navigating: two ways, and when each one works
Screens that need no prior selection are one NextState write away:
brp '{"jsonrpc":"2.0","id":7,"method":"world.mutate_resources","params":
{"resource":"bevy_state::state::resources::NextState<harmonicon_menu::menu::routing::MenuPage>",
"path":"","value":{"Pending":"Options"}}}'
Note the exact path: bevy_state::state::resources::NextState, not
…::states::NextState. Swap MenuPage for
harmonicon_app::app::AppState ("Calibration", "SongEditor2",
"BendingTrainer", …) for the screens outside the menu hierarchy.
That is as far as state alone gets you. Play 2D/3D, Jam Session and
Results all need a SelectedSong first, which holds a Handle<SongManifest>
— not something a JSON value can express. So the other way is to click the
button, which is why dev_capture registers bevy_ui_widgets::Activate:
brp '{"jsonrpc":"2.0","id":8,"method":"world.trigger_event","params":
{"event":"bevy_ui_widgets::Activate","value":{"entity":4294966678}}}'
Activate carries its own target, so the entity goes in the payload rather
than in a separate parameter. Since every click handler in this codebase is
an On<Activate> on a real bevy_ui_widgets::Button (see CLAUDE.md), one
trigger reaches any of them.
Finding the entity is the fiddly part. A label lives on a Text node that
may sit several levels below the entity carrying Button —
dialogs/button.rs wraps its content in a shell — so resolve it by walking
bevy_ecs::hierarchy::ChildOf upward until you hit an entity in the
Button set, rather than assuming the text’s immediate parent is the
button.
One trap worth knowing: query the text nodes and the hierarchy in as few round trips as you can. Menu pages despawn and respawn their whole subtree on navigation, so an entity id read in one request can be gone by the next, which looks exactly like “this button doesn’t exist”.
A stale entity id can kill the game, not just fail. world.trigger_event
on a despawned entity is harmless, but world.insert_components and
world.mutate_components panic inside bevy_remote itself — the panic
propagates through process_remote_requests and takes the process down:
thread 'main' panicked at bevy_remote-0.19.1/src/builtin_methods.rs:1194
Note that interacting with a despawned entity is the most common cause
Gameplay is worse than menus for this, because a scene can vanish on its own
schedule: a song that reaches its end tears down every GameplayRoot entity
and moves to Results with no input from you. Re-query immediately before
mutating, and don’t assume an id you captured before a sleep is still
alive.
What it’s already used for
Every image in docs/book/src/images/ is a real capture taken this way —
nine of the fifteen by setting NextState alone, the rest (gameplay, the
jam grid, the results screen, the tour overlay, the microphone dropdown) by
clicking through to them. If a screen changes, re-take that one PNG under
the same filename; the  references don’t move.
What this does not give you
- Rendering correctness. BRP reports the string
"♬ Import MIDI"whether or not the font has the glyph. Five tofu boxes shipped in every locale for months and only a screenshot caught them. Use both. - Timing. See the readback stall above.
- Audio.
Android
The same server runs in the Android build (--features dev), reachable by
forwarding the port:
adb forward tcp:15702 tcp:15702
The capture directories are then inside the app’s sandbox rather than
target/, so pull them with adb.
Glossary
Short definitions for terms this book uses repeatedly, each pointing at the chapter that covers it in depth. Not exhaustive — see each chapter’s own prose for anything not listed here.
AppState — the top-level screen state machine (Startup, Menu,
SongLoading, Playing, Results, Calibration, Credits,
SongEditor2, BendingTrainer). See
Application States and Modes.
AssetLoader — Bevy’s mechanism for turning raw file bytes into a
typed Asset, run asynchronously off the main thread. Harmonicon has
two custom ones: SongChartLoader (a chart folder → SongManifest)
and ThemeJsonLoader (theme.json → ThemeJson). See
Chart Format and Asset Loading and
Localization and Theming.
EditorState — the Song Editor’s single resource holding its
entire in-memory document: every placed note, the tempo map, meta-form
fields, selection, and mode. See The Song Editor.
GameplayClock — the single authoritative “now” every scored-
gameplay system reads, anchored to the music sink’s own playback
position once music starts rather than tracking wall-clock time
directly. See The Gameplay Clock.
GameplayMode — which of three experiences AppState::Playing
currently means: Play2D, Play3D, or JamSession. See
Application States and Modes.
GameplayLogic — the Bevy SystemSet grouping the clock tick,
scoring, and loop-boundary systems; every clock-reading system must be
ordered .after it. See The Gameplay Clock and
The Plugin Architecture.
GridNote — the Song Editor’s own per-note type (distinct from
gameplay’s ScheduledNote): { id, hole, tick, len, dir, pitch, expr }.
See The Song Editor.
HarpChart — the parsed, typed representation of a .harpchart
JSON file. See Chart Format and Asset Loading.
LessonManifest — a lesson’s authored content (lesson.json):
identity, prerequisites, pass criteria, and Fluent key references (never
raw display text). See The Lessons Engine.
LessonContext — the resource kept in flight for the duration of a
lesson run, read by the results screen (or, for a jam-based lesson, the
pause menu’s “Finish Lesson” button) to judge the run’s pass_criteria.
See The Lessons Engine.
LoadedTheme — the resource holding the currently active UI
theme’s resolved colors and asset handles, populated asynchronously once
its ThemeJson asset load resolves. See
Localization and Theming.
MenuPage — the Bevy SubStates enum scoped to AppState::Menu,
one variant per menu screen. See
Application States and Modes.
MidiTrackAudio / MidiTrackPlayer — MidiTrackAudio (on
SongManifest) is one MIDI track’s own pre-rendered AudioSource stem;
MidiTrackPlayer(usize) (a component, in gameplay::state) tags the
live AudioSink entity playing that stem so per-track mute can find it.
See Jam Session.
MusicPlayer — the component tagging whichever entity is currently
playing a song’s background music, used by pause/resume and global-
volume-slider systems to find it (or, for MIDI multi-track backing, all
of them at once). See Jam Session.
PitchEvent — the Message published once per analyzed audio
chunk, carrying every pitch detected in it. See
The Audio Input Pipeline.
PitchGate — the fresh-attack gate (wrapping scoring:: AttackGate<u8>) that keeps a single sustained note from being
re-credited to multiple chart notes at the same pitch. See
The Scoring System.
PitchRange — the current min/max frequency pitch detection
searches, narrowed to the active harmonica’s real playable range at
song start (rather than a fixed global range) to reduce false positives
and, for the NMF algorithm, to know which dictionary to build. See
The Audio Input Pipeline.
Plugin (composition root) — a plugin, like gameplay::plugin,
whose entire job is assembling other features’ systems into one shared
schedule — legitimately depends on everything it wires together, unlike
an ordinary feature module. See
Module Boundaries and Dependency Rules.
ScheduledNote — one chart note’s live score state during real
gameplay, held in SongNotes as plain data, independent of whatever
(if any) render entity currently represents it on screen. See
The Scoring System.
SnapMode — the Song Editor’s Straight/Shuffle/Triplet grid
subdivision setting, constraining where a note placement or drag lands.
See The Song Editor.
SongManifest — the fully-loaded representation of a song: its
HarpChart plus every resolved (or gracefully defaulted) sibling
asset — background art, backing music or MIDI track stems, note-theme
configs. See Chart Format and Asset Loading.
SongNotes — the resource holding the entire loaded chart’s worth
of ScheduledNotes plus a scan-avoidance cursor; scoring’s actual data
model. See The Scoring System.
AttackGate<K> — the pure, generic fresh-attack state machine
PitchGate and Jam Session’s ImprovStats both wrap, keyed by whatever
identity type K the caller needs (a MIDI note number for scoring). See
The Scoring System.