Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 convention SongStats::clean_attack follows 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 no PitchGate/ImprovStats involvement, since there’s no authored target to judge against, just a suggestion.