Crash-tan Crash-tan

CrashCore 1.23.0 is out!

Crash BASIC 1.23.0 Release Notes

written by Crash-tan

THE "SAMPLED INSTRUMENTS" UPDATE

CrashAudio learned to play sampled instruments. Loaded WAV files can now be bound to any .cbi instrument slot, and every one of the instrument's modulation layers — ADSR envelope, filter + filter envelope, vibrato, tremolo, FM — flows over the sampled waveform instead of a synthesized one. On top of that, a new engine-wide "retro" mode applies SNES-style voicing constraints for that mid-90s sampled soundtrack character.

SETINSTRUMENTWAV

One new statement. Binds a loaded WAV sample to an instrument's layer(s):

LOADWAV "samples/piano.wav", "piano"
SETINSTRUMENTWAV 1, "piano"                          ' all layers
SETINSTRUMENTWAV 1, 0, "piano"                       ' layer 0 only
SETINSTRUMENTWAV 2, "strings" BASEPITCH "A4" LOOP 4800, 24000
SETINSTRUMENTWAV 1, ""                               ' clear binding
  • All-layers vs specific-layer. Without a layer number, the binding replaces the oscillator source of every layer on that instrument. Pass an explicit layer index to bind only one — the other layers keep their original synthesized waveforms. This lets you layer a sampled guitar over a synthesized sub-oscillator, which is musically powerful.
  • BASEPITCH sets the MIDI note the sample represents at its native rate. Accepts note names ("A4", "C#5", "Bb3") or raw MIDI numbers 0–127. Default is A4 (69). When you play a different note, the sample resamples by 2^((note − base_note)/12).
  • LOOP start, end sets forward loop points in sample frames. Omit the LOOP clause for one-shot playback — the sample plays once and the instrument's envelope takes over. Perfect for drum hits.
  • Empty handle clears the binding. Reverts the layer to the original .cbi waveform.
  • Bindings survive .cbm reloads. Most games reuse the same patch set across songs; loading a new music bank doesn't wipe your sample bindings. Only an explicit clear or engine shutdown removes them.

INSTRUMENTMODE

INSTRUMENTMODE "retro"
' ... everything sampled now plays SNES-style ...
INSTRUMENTMODE "modern"  ' back to clean DSP

Modern (default) uses linear sample interpolation, the full 32-voice polyphony pool, and no output filtering — clean, modern DSP.

Retro is a deliberate downgrade targeting the specific sonic character of SNES soundtracks:

  • 4-tap Gaussian interpolation on sampled playback. The SNES S-DSP's built-in filter — that mellow, slightly-smeared high end you hear in every SquareSoft RPG.
  • 8-voice global polyphony cap. Matches the SNES's 8 hardware voices exactly. When all 8 are busy, voice-stealing picks the oldest releasing voice first, falling back to the oldest playing one. Voices beyond the cap are silently kept out of the mix, even during mode transitions mid-song.
  • Output low-pass at 15 kHz. One-pole per channel, applied after the master mix. Rolls off the very top of the spectrum the way the SNES's 32 kHz DAC output does, without the complexity of a full resample path.

The cap and the filter together produce that unmistakable compressed, warm character. Synthesized waveforms are mostly unaffected (other than the output low-pass); the full character shift lands on sampled instruments.

The full flow

SCREEN 320, 240

LOADWAV "samples/kick.wav", "kick"
LOADWAV "samples/snare.wav", "snare"
LOADWAV "samples/lead.wav", "lead"

SETINSTRUMENTWAV 10, "kick"  BASEPITCH 36
SETINSTRUMENTWAV 11, "snare" BASEPITCH 38
SETINSTRUMENTWAV 20, "lead"  BASEPITCH "C5" LOOP 4000, 32000

INSTRUMENTMODE "retro"

PLAY BACKGROUND "I10 o3 l8 c r I11 c r I10 c c I11 c r"
PLAY BACKGROUND "I20 o4 l4 c d e g l8 a g f e l2 d c"

That's a full retro-feel sampled drum-and-lead arrangement with ADSR envelopes, vibrato, and filter all still applying to every note.

Demo

New demos/sampled_instruments/ includes four freely-usable WAV samples (kick, snare, hi-hat, Casio CTK-611 Calliope C5 lead — 249 KB total, sourced from freewavesamples.com under their royalty-free terms) and a five-key interactive scene that A/B's drum loops and melodic lines between Modern and Retro mode. You can literally hear the character switch in real time.

Architecture (for the curious)

  • SampleLibrary — decoded-PCM store keyed by LOADWAV handle. Uses hound to decode WAV bytes into mono f32 PCM (stereo gets downmixed by averaging channels; integer formats normalize to [-1, 1]; float formats pass through). Shared via Arc<RwLock<_>> between the AudioManager (writes on LOADWAV) and the Synthesizer (reads at note-on).
  • SampleOscillator — phase accumulator + forward loop handling + linear/Gaussian interpolation. Integrates with the existing OscillatorLayer so everything downstream (ADSR, filter, LFOs, FM) still applies. FM modulator layers even still wiggle sampled carriers — the modulator's output folds into the sample's playback ratio.
  • Binding store on InstrumentBank, keyed by (instrument_id, BindingLayer) where BindingLayer is All or Specific(layer). Specific-layer bindings win at resolution time over all-layer bindings. No changes to the .cbi file format.
  • Protocol — two new AudioCommand variants (SetInstrumentWav, SetInstrumentMode) with proto messages + both-direction conversions.

Tests

42 new tests across phases: data-model invariants (binding persistence, specific-vs-all precedence, mode toggle), protocol roundtrips for every SETINSTRUMENTWAV shape, sample library decode (synthetic 16-bit mono, stereo downmix, roundtrip), sample oscillator (pitch math, octave doubling, one-shot finish, loop wrap, Gaussian interp), voice-level integration (sample override produces flat-positive PCM vs bipolar sine), and retro-mode specifics (8-voice cap enforcement, oldest-releasing-first voice stealing, 15 kHz low-pass Nyquist attenuation). 1251 tests pass across the workspace (1167 crashcore + 58 crashaudio + 26 crashaudio-protocol).

Version bump

Minor: 1.22.1 → 1.23.0. No breaking changes — existing MML, PLAY, PLAYMUSIC, LOADWAV, and .cbi instrument banks work unchanged.


Addendum: WAV Layers as a .cbi Citizen, Music Engine Fixes, and the IDE Surface

A second wave of work on the same release. Expansion: the SETINSTRUMENTWAV runtime feature got promoted to a first-class field on the .cbi schema, the music renderer learned to honor sample bindings, and a handful of latent music bugs that had been hiding in plain sight finally surfaced.

Sample bindings in .cbi

InstrumentLayer now carries an optional sample field:

{
  "wave": "square",
  "volume": 0.8,
  "detune": 0,
  "octave": 0,
  "sample": {
    "wavPath": "drums/snare.wav",
    "baseNote": 60,
    "loopPoints": [4800, 24000]
  }
}

When a bank is loaded (via LOADINSTRUMENTBANK or any other path), CrashAudio's InstrumentBank reconciles Specific(layer) sample bindings from these fields automatically — same end state as if the program had issued SETINSTRUMENTWAV n, layer, "drums/snare.wav" BASEPITCH 60 LOOP 4800, 24000 for each. Layers without sample clear any prior specific-layer binding for that slot, so the .cbi is authoritative over its own layers; runtime SETINSTRUMENTWAV still works and wins.

Loading flow remains strictly byte-only inside CrashAudio — the engine never reads files. The IoBus side (audio_bus) does the dance:

  1. Walk the bank JSON for unique sample.wavPath values.
  2. For each, load_wav(path, path) → IoBus reads bytes → LoadAudioData over the protocol (= what LOADWAV does at runtime).
  3. For each (instrument, layer, sample), set_instrument_wav(...) → SetInstrumentWavCmd (= what SETINSTRUMENTWAV does at runtime).
  4. Then LoadInstrumentBank{json} for the instrument shapes themselves.

The native CrashPlayer auto-load on startup (instruments.cbi in the game folder) was rewired to lazily fire from audio_bus.try_load_default_instruments on the interpreter thread's first audio command — driving it from the main thread before run_main_loop started would deadlock on send_audio_blocking with no one draining the audio host.

Two latent music-renderer bugs the WAV work surfaced

Sample bindings ignored by render_to_wav. Native music playback pre-renders a song to WAV bytes for SDL2 backend playback (AudioCommand::PlayMusic → MusicManager::render_to_wav → OfflineRenderer). OfflineRenderer::note_on called voice.configure(&inst_def) but never voice.set_sample_overrides(&overrides) — so any layer with a sample binding silently fell back to its oscillator. Live grammar playback (Synthesizer::process_command) had been doing both since day one; the offline path predated the override system and never got the matching call. Fixed by giving OfflineRenderer access to the synth's SampleLibrary and mirroring Synthesizer::resolve_overrides exactly.

Arrangement steps stacking instead of sequencing. MusicManager::load_song flattened a song's arrangement by pushing every step's tracks into a Vec<(channel, mml)> — and every consumer treated those as parallel voices starting at t=0. So an arrangement of [PatternA, PatternB] produced four overlapping tracks instead of A-then-B. WASM's expand_song_to_notes had always advanced current_time_ms per step; native never did. Fixed by changing the flattened shape to Vec<(start_offset_ms, channel, mml)>, computing each step's max-track duration during load, and threading the offset through both render_to_wav (initial current_time_ms) and play_tracks_parallel (initial time_until_next per TrackState). with-layered patterns share the step's offset (correct parallel-within-step). Total duration is now the cumulative offset at end-of-arrangement.

This bug had been live since multi-track music shipped. It survived because most test songs were single-step arrangements, or used disjoint channels per pattern, or just sounded "fine — busy" until a single distinctive sampled instrument made the overlap unmistakable. The sample work was the perfect tripwire.

Sample-library ingest reordering

load_file and load_from_base64 (the WAV → AudioManager paths) ran backend.load_sound before ingest_sample_from_wav. A backend decode error returned early and skipped the ingest — so a WAV that the SDL2 backend hiccuped on (codec mismatch, audio device not yet ready) couldn't be used as a sampled-instrument source even though the internal Hound decoder accepted it perfectly fine. Reordered so ingest happens first; backend register stays the gating step for self.sounds (used by PLAYWAV), but sample_library always gets populated.

IDE-facing WASM exports for CrashBasic

A growing surface of #[wasm_bindgen] command encoders so the CrashBasic IDE bridge can drive CrashAudio over the same protobuf path BASIC programs use, without hand-rolling protobuf in JS. Added across this cycle:

  • Per-instrument: encode_play_note_cmd, encode_stop_note_cmd, encode_all_notes_off_cmd, encode_synth_panic_cmd, encode_set_synth_volume_cmd
  • Instruments + sounds: encode_load_instrument_cmd, encode_load_instrument_bank_cmd, encode_load_audio_data_cmd, encode_unload_sound_cmd
  • MML: encode_play_mml_cmd, encode_play_mml_background_cmd, encode_stop_mml_cmd, encode_query_mml_playing_cmd
  • Multi-track music: encode_load_music_bank_cmd, encode_play_music_cmd (with start_ticks), encode_stop_music_cmd, encode_pause_music_cmd, encode_resume_music_cmd, encode_fade_music_cmd, encode_set_music_track_state_cmd
  • Queries: encode_query_music_playing_cmd, encode_query_music_position_cmd, encode_query_music_length_cmd
  • Master: encode_set_master_volume_cmd, encode_stop_all_cmd

The CrashBasic-side bridge calls these to build protobuf bytes locally and pushes them through shim.processCommand. The shim now also forwards LoadAudioData and UnloadSound to WASM after its own Web Audio bookkeeping, so sample_library and the shim's loadedSounds stay in lockstep regardless of which side originated the command.

Protocol additions for the tracker

Three new commands/events to make CrashBasic's music tracker (browser-only) feel like a first-class consumer:

  • PlayMusicCmd.start_ticks — optional seek into a song, in 16th-note ticks. WASM trims the rendered stereo buffer by tick count before handing it to the JS shim. Native ignores the field for now (the tracker is browser-only); same protocol, divergence is local.
  • SetMusicTrackStateCmd { muted_tracks, soloed_tracks } — runtime mute/solo per track index. Stored on the WASM WasmAudioManager and applied at the next PlayMusic render. The tracker re-issues PlayMusic on toggle; that's a clean "state lives in WASM, transport drives it" model.
  • MusicCompleteEvent { song_name } — fires when a non-looping song's pre-rendered buffer completes naturally. Explicit StopMusic clears the engine's current-song-name first so the event isn't pushed for deliberate stops.

Also: QueryMusicPosition returns a real position now (was hardcoded to 0). Computed from sample_clock - playback_start_sample against the song's tempo.

Bank-load reconcile helpers

InstrumentBank got two helpers used by the new orchestration:

  • reconcile_layer_sample_bindings(instrument, &definition) — walks the definition's layers and writes/clears a Specific(layer) binding for each. Called from set_instrument (single-instrument load) and load_from_bank_file (bank load).
  • required_sample_paths() — returns the set of unique wavPath values referenced by any custom instrument's layers. Used by audio_bus.preload_bank_sample_wavs to decide which WAVs to LOADWAV before sending the bank.

Plus debug_dump_sample_bindings() for diagnostic logging during the chase.

Net

The .cbi ↔ engine ↔ tracker triangle is closed: instruments authored in the IDE editor (with sample layers) save to the bank, load via the same protocol grammar uses, and play correctly in both runtimes. Combined with the existing oscillator/envelope/filter/FM/LFO pipeline and the new Modern/Retro modes, the engine can now plausibly cover SNES/Amiga (sampled-rom feel), NES/Game Boy (oscillator + duty), Sega Genesis (FM stacks via fmTarget/fmDepth), C64 SID (filter + pitch envelope), and arcade boards (sampled hits + filter sweeps) — sometimes inside a single instrument with one layer per role.