Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6ee5581
build the matching tests' decoder once, and test MIDI import by default
bates64 Oct 3, 2026
6874941
find where tracks need their voices, and trim short overlaps
bates64 Oct 1, 2026
6833ca8
scroll the timelines to a time through TimeProvider
bates64 Oct 2, 2026
f75dabc
warn on regions short of voices or on voices sound effects take
bates64 Oct 1, 2026
7efdad4
keep region names in view as their regions scroll off the left
bates64 Oct 3, 2026
5cee0c6
put drum tracks last when importing MIDI
bates64 Oct 3, 2026
fa86ad9
take voices from drums first when a phrase needs more than the game has
bates64 Oct 3, 2026
f139668
read instrument envelopes and the drum kit from the sound bank in pm64
bates64 Oct 3, 2026
cf9e0a4
give spare music-only voices to release tails
bates64 Oct 3, 2026
f1286fc
midi import fixes
bates64 Oct 4, 2026
4b1754f
read MIDI files as General MIDI if chosen when importing
bates64 Oct 3, 2026
6fe8e13
normalize the loudness of songs imported from MIDI files
bates64 Oct 4, 2026
3576744
play General MIDI taiko and melodic toms with Jade Jungle's drums
bates64 Oct 4, 2026
37c3d39
draw the piano roll at the device pixel ratio when the browser is zoo…
bates64 Oct 4, 2026
9a82293
keep a track's instrument when a sample override follows it, and drop…
bates64 Oct 4, 2026
552e0ee
start each imported track with only its instrument, which holds the f…
bates64 Oct 4, 2026
923c537
warn when a song is too big for the game to load, and don't write it …
bates64 Oct 4, 2026
96c9943
import volume ramps as fades
bates64 Oct 4, 2026
ee81708
loop a passage that a MIDI file writes out again at its end
bates64 Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions mamar-audio/src/song.c
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ u8* mamar_audio_bgm_buffer(void) {
return MamarBGM;
}

/// The most bytes the host can write into the buffer `mamar_audio_bgm_buffer` gives.
__attribute__((export_name("mamar_audio_bgm_max_size")))
s32 mamar_audio_bgm_max_size(void) {
return MAMAR_BGM_MAX_SIZE;
}

/// How many aux banks a song can load its own instruments from.
__attribute__((export_name("mamar_audio_aux_bank_count")))
s32 mamar_audio_aux_bank_count(void) {
Expand Down
86 changes: 72 additions & 14 deletions mamar-wasm-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,15 @@ pub fn bgm_decode(data: &[u8]) -> JsValue {
}
}

/// Encodes `bgm`, giving tracks voices for their notes to ring on with as `releases` says, if it isn't null. See
/// [Bgm::encode_with_releases].
#[wasm_bindgen]
pub fn bgm_encode(bgm: &JsValue) -> JsValue {
pub fn bgm_encode(bgm: &JsValue, releases: &JsValue) -> JsValue {
let bgm: Bgm = from_js(bgm);
let releases: Option<Releases> = from_js(releases);

let mut f = Cursor::new(Vec::new());
match bgm.encode(&mut f) {
match bgm.encode_with_releases(&mut f, releases.as_ref()) {
Ok(_) => {
let data: Vec<u8> = f.into_inner();
let arr = js_sys::Uint8Array::new_with_length(data.len() as u32);
Expand Down Expand Up @@ -132,12 +135,23 @@ pub fn commands_place(commands: &JsValue, id: u32, time: usize, command: &JsValu
to_js(&commands)
}

/// Returns the voices each track of `track_list` needs and gets. See [TrackList::voices].
/// Returns the voices each track of `track_list` needs and gets, given `releases` if it isn't null, and where each
/// needs them as proximity mix `mix` plays it. See [TrackList::voice_report].
#[wasm_bindgen]
pub fn track_list_voices(track_list: &JsValue, branches: &JsValue) -> JsValue {
pub fn track_list_voice_report(track_list: &JsValue, branches: &JsValue, mix: usize, releases: &JsValue) -> JsValue {
let track_list: TrackList = from_js(track_list);
let branches: std::collections::BTreeMap<BranchId, Branch> = from_js(branches);
to_js(&track_list.voices(&branches))
let branches: BTreeMap<BranchId, Branch> = from_js(branches);
let releases: Option<Releases> = from_js(releases);
to_js(&track_list.voice_report(&branches, mix, releases.as_ref()))
}

/// Returns `commands` with notes held a little into the next shortened. See [CommandSeq::trim_short_overlaps].
#[wasm_bindgen]
pub fn commands_trim_short_overlaps(commands: &JsValue, branches: &JsValue) -> JsValue {
let mut commands: CommandSeq = from_js(commands);
let branches: BTreeMap<BranchId, Branch> = from_js(branches);
commands.trim_short_overlaps(&branches);
to_js(&commands)
}

/// Returns what `commands` play in proximity mix `mix`. See [CommandSeq::for_mix].
Expand Down Expand Up @@ -236,11 +250,13 @@ fn bytes(data: &[u8]) -> JsValue {
js_sys::Uint8Array::from(data).into()
}

/// Makes a song from the MIDI file `data`, named `name`, linked to it so it can be reimported. Returns `{ bgm, base,
/// warnings }`, where `base` is the import to keep while the song is open, or an error message.
/// Makes a song from the MIDI file `data`, named `name`, reading its programs and drum notes as `mapping`, a
/// [reimport::MidiMapping], says, and choosing samples that reach its notes by `sample_reach`, the highest MIDI key
/// each sample plays, linked to it so it can be reimported. Returns `{ bgm, base, warnings }`, where `base` is the
/// import to keep while the song is open, or an error message.
#[wasm_bindgen]
pub fn midi_import(data: &[u8], name: &str) -> JsValue {
match reimport::import(data, name) {
pub fn midi_import(data: &[u8], name: &str, mapping: &JsValue, sample_reach: &[u8]) -> JsValue {
match reimport::import(data, name, from_js(mapping), sample_reach) {
Ok(imported) => object(&[
("bgm", to_js(&imported.bgm)),
("base", bytes(&reimport::encode_timeline(&imported.base))),
Expand Down Expand Up @@ -271,13 +287,13 @@ pub fn import_base_rebuild(bgm: &JsValue) -> JsValue {
}
}

/// Reimports the MIDI file `data`, named `name`, into `bgm`, given its last import `base` if it has one. Returns
/// `{ bgm, base, report }`, or an error message.
/// Reimports the MIDI file `data`, named `name`, into `bgm`, given its last import `base` if it has one, and
/// `sample_reach` as [midi_import] has it. Returns `{ bgm, base, report }`, or an error message.
#[wasm_bindgen]
pub fn bgm_reimport(bgm: &JsValue, base: Option<Vec<u8>>, data: &[u8], name: &str) -> JsValue {
pub fn bgm_reimport(bgm: &JsValue, base: Option<Vec<u8>>, data: &[u8], name: &str, sample_reach: &[u8]) -> JsValue {
let bgm: Bgm = from_js(bgm);
let base = base.as_deref().and_then(reimport::decode_timeline);
match reimport::reimport(&bgm, base.as_ref(), data, name) {
match reimport::reimport(&bgm, base.as_ref(), data, name, sample_reach) {
Ok((bgm, base, report)) => object(&[
("bgm", to_js(&bgm)),
("base", bytes(&reimport::encode_timeline(&base))),
Expand All @@ -286,3 +302,45 @@ pub fn bgm_reimport(bgm: &JsValue, base: Option<Vec<u8>>, data: &[u8], name: &st
Err(e) => e.to_string().into(),
}
}

/// A ROM's sound bank, kept in wasm memory so looking up its instruments doesn't copy it each time. See
/// [pm64::sbn::bank::SoundBank].
#[wasm_bindgen(js_name = SoundBank)]
pub struct WasmSoundBank(pm64::sbn::bank::SoundBank);

#[wasm_bindgen(js_class = SoundBank)]
impl WasmSoundBank {
#[wasm_bindgen(constructor)]
pub fn new(data: &[u8]) -> Self {
Self(pm64::sbn::bank::SoundBank::new(data.to_vec()))
}

pub fn file_name(&self, index: usize) -> Option<String> {
self.0.file_name(index)
}

pub fn file_index_of(&self, name: &str) -> Option<usize> {
self.0.file_index_of(name)
}

pub fn kit_drums(&self) -> JsValue {
to_js(&self.0.kit_drums())
}

pub fn instrument_offset(&self, patch: &JsValue, aux_banks: &JsValue) -> Option<usize> {
self.0
.instrument_offset(&from_js(patch), &from_js::<Vec<String>>(aux_banks))
}

pub fn envelopes(&self, patch: &JsValue, aux_banks: &JsValue) -> JsValue {
to_js(&self.0.envelopes(&from_js(patch), &from_js::<Vec<String>>(aux_banks)))
}
}

/// Returns how long the notes of `bgm` ring on after they end, with its instruments as `bank` has them. See
/// [Bgm::releases].
#[wasm_bindgen]
pub fn bgm_releases(bgm: &JsValue, bank: &WasmSoundBank) -> JsValue {
let bgm: Bgm = from_js(bgm);
to_js(&bgm.releases(&bank.0))
}
35 changes: 34 additions & 1 deletion mamar-wasm-bridge/src/piano_roll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct PianoRoll {
branch_times: Vec<usize>,
/// IDs of the selected notes.
selection: Vec<u32>,
/// IDs of the notes playing where the track plays the most notes at once, marked while its phrase needs more
/// voices than the game has.
busiest: Vec<u32>,
/// The highest pitch the track's instrument plays at its own pitch, above which the engine plays notes lower than
/// they should be, from each time the instrument or its tuning changes until the next, in time order. None is no
/// limit.
Expand Down Expand Up @@ -61,6 +64,7 @@ impl PianoRoll {
notes: Vec::new(),
branch_times: Vec::new(),
selection: Vec::new(),
busiest: Vec::new(),
pitch_limits: Vec::new(),
zoom: 2.0,
ticks_per_bar: TICKS_PER_BEAT * 4.0,
Expand Down Expand Up @@ -88,7 +92,8 @@ impl PianoRoll {
pub fn set_viewport(&mut self, width_css_px: f64, height_css_px: f64, dpr: f64) {
self.vw = width_css_px.max(0.0);
self.vh = height_css_px.max(0.0);
self.dpr = dpr.max(1.0);
// Below 1 when the browser is zoomed out, which the canvas is sized for
self.dpr = dpr;
// Resizing a canvas clears it
self.dirty = true;
}
Expand All @@ -98,6 +103,12 @@ impl PianoRoll {
self.dirty = true;
}

/// Marks the notes with `ids`, where the track plays the most notes at once. Empty marks none.
pub fn set_busiest_notes(&mut self, ids: Vec<u32>) {
self.busiest = ids;
self.dirty = true;
}

/// Sets the highest pitch the track's instrument plays at its own pitch from each of `times`, in time order, until
/// the next. A limit of 0 is no limit, as 0 isn't a pitch the engine plays.
pub fn set_pitch_limits(&mut self, times: Vec<u32>, limits: Vec<u8>) {
Expand Down Expand Up @@ -217,6 +228,14 @@ impl PianoRoll {
ctx.set_global_alpha(0.35 + 0.65 * (velocity.min(127) as f64 / 127.0));
self.draw_note(ctx, time, pitch, length);
ctx.set_global_alpha(1.0);
if self.busiest.contains(&id) {
// Outlined in the orange of the regions on voices sound effects share
ctx.save();
ctx.set_stroke_style_str("#e46f00");
ctx.set_line_width(2.0);
self.outline_note(ctx, time, pitch, length);
ctx.restore();
}
if selected {
ctx.set_stroke_style_str("#1d80f5");
ctx.set_fill_style_str("#066ce7");
Expand All @@ -227,6 +246,20 @@ impl PianoRoll {
Ok(())
}

/// Strokes around a note, just outside it, so the note's own colour still shows.
fn outline_note(&self, ctx: &web_sys::CanvasRenderingContext2d, time: usize, pitch: u8, length: u16) {
let x = self.time_to_x(time as f64);
let Some(y) = self.pitch_to_y(pitch) else { return };
let w = self.time_to_x(length as f64) - self.time_to_x(0.0);
let h = self.note_height();
if x + w < 0.0 || x > self.vw {
return;
}
ctx.begin_path();
let _ = ctx.round_rect_with_f64(x - 1.0, y - 1.0, w + 2.0, h + 2.0, 2.0);
ctx.stroke();
}

fn draw_note(&self, ctx: &web_sys::CanvasRenderingContext2d, time: usize, pitch: u8, length: u16) {
let x = self.time_to_x(time as f64);
let Some(y) = self.pitch_to_y(pitch) else { return };
Expand Down
2 changes: 1 addition & 1 deletion mamar-web/src/app/BgmEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const Editor = forwardRef<BgmEditorHandle, BgmEditorProps>(function Editor({ dat
if (!doc) {
throw new Error("No song is open")
}
const bgmBin: Uint8Array | string = Bridge.bgm_encode(doc.bgm)
const bgmBin: Uint8Array | string = Bridge.bgm_encode(doc.bgm, null)
if (typeof bgmBin === "string") {
throw new Error(bgmBin)
}
Expand Down
16 changes: 15 additions & 1 deletion mamar-web/src/app/doc/PianoRoll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CONTEXT as PLAYHEAD_CONTEXT } from "./Playhead"
import { usePickup, useTicksPerBar } from "./Ruler"
import { useMixCommands } from "./segmentTracks"
import { SNAP_NAMES, useSnap } from "./snap"
import { useVoiceReport, voiceProblem } from "./voices"

import Bridge from "../bridge"
import { useBgm, useDoc, useLocation } from "../store"
Expand Down Expand Up @@ -125,7 +126,7 @@ function Canvas({ trackListId, trackIndex, track, branches, mix, segmentStart, p
const rendererRef = useRef<Renderer | null>(null)
const rafRef = useRef<number>(0)
const [, docDispatch] = useDoc()
const [, dispatch] = useBgm()
const [bgm, dispatch] = useBgm()
const [snapSetting, snap, grid] = useSnap()
const ticksPerBar = useTicksPerBar()
const pickup = usePickup()
Expand Down Expand Up @@ -287,6 +288,19 @@ function Canvas({ trackListId, trackIndex, track, branches, mix, segmentStart, p
rendererRef.current?.set_bars(ticksPerBar, (((pickup - segmentStart) % ticksPerBar) + ticksPerBar) % ticksPerBar)
}, [ticksPerBar, pickup, segmentStart])

// While this track is short of voices or plays on voices sound effects share, mark the notes where it needs the most.
// Alternate parts play on the voices of the tracks they're for.
const report = useVoiceReport(trackListId)
const voiceIndex = track.alternate_for ?? trackIndex
const trackList = bgm?.track_lists[trackListId]
// A track that needs only one voice has nothing to mark: each note is its busiest
const busiestKey = report && trackList && voiceProblem(trackList, report.voices, voiceIndex) && report.voices.needed[voiceIndex] > 1
? report.tracks[trackIndex].busiest_notes.join()
: ""
useEffect(() => {
rendererRef.current?.set_busiest_notes(new Uint32Array(busiestKey ? busiestKey.split(",").map(Number) : []))
}, [busiestKey])

const selectedKey = selectedIds.join()
useEffect(() => {
rendererRef.current?.set_selection(new Uint32Array(selectedKey ? selectedKey.split(",").map(Number) : []))
Expand Down
19 changes: 11 additions & 8 deletions mamar-web/src/app/doc/SegmentMap.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ $versionHeight: 40px;
background: linear-gradient(var(--color-500) 0%, var(--color-400) 100%);
color: #ffffffee;
border-radius: 6px;
overflow: hidden;
// Clipped, as hidden would make it a scroll container, which its voice badge would stick to instead of the timeline
overflow: clip;

height: $trackHeight;

Expand All @@ -73,6 +74,13 @@ $versionHeight: 40px;
padding: 6px 10px;
}

// Stuck to the timeline's left edge while the region runs past it, as far as the region's end
.stickyName {
display: inline-block;
position: sticky;
left: 10px;
}

// A name from the region's instrument, as it has none of its own
.instrumentName {
opacity: 0.75;
Expand All @@ -88,12 +96,6 @@ $versionHeight: 40px;
}
}

// Some of its notes are cut off, as its segment needs more voices than the game has
&.shortOfVoices {
outline: 2px solid var(--spectrum-orange-600);
outline-offset: -2px;
}

&.drumRegion {
--color-500: var(--spectrum-seafoam-700);
--color-400: var(--spectrum-seafoam-600);
Expand All @@ -107,7 +109,8 @@ $versionHeight: 40px;
}
}

.pianoRollThumbnail svg {
// The notes, not the icons over them
.pianoRollThumbnail > svg {
width: calc(100% - 12px);
height: calc(100% - 12px);

Expand Down
13 changes: 6 additions & 7 deletions mamar-web/src/app/doc/SegmentMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { SegmentStart } from "./snap"
import SongLanes from "./SongLanes"
import TimeGrid from "./TimeGrid"
import TrackMeter from "./TrackMeter"
import { MAX_VOICES, total, useVoices } from "./voices"
import { VoiceBadge } from "./VoiceBudget"
import { useVoiceReport } from "./voices"

import Bridge from "../bridge"
import TrackControls from "../emu/TrackControls"
Expand Down Expand Up @@ -42,7 +43,7 @@ function PianoRollThumbnail({ trackIndex, trackListIndex, segmentIndex, isAltern
const [location] = useLocation()
const trackList = bgm?.track_lists[trackListIndex]
const shownIndex = isAlternatePart ? (trackList && alternatePartOf(trackList, trackIndex)) : trackIndex
const voices = useVoices(trackListIndex)
const voices = useVoiceReport(trackListIndex)?.voices
const track = shownIndex !== undefined ? trackList?.tracks[shownIndex] : undefined
const isSelected = doc?.panelContent.type === "tracker" && doc?.panelContent.trackList === trackListIndex &&
doc?.panelContent.track === trackIndex && location.alternateParts === isAlternatePart &&
Expand Down Expand Up @@ -90,10 +91,6 @@ function PianoRollThumbnail({ trackIndex, trackListIndex, segmentIndex, isAltern
[styles.drumRegion]: track.is_drum_track,
[styles.disabledRegion]: track.is_disabled,
[styles.showsVersion]: isVersion,
// Vanilla tracks often have fewer voices than notes at once, letting a note cut off the end of the one
// before, so this only warns when the segment needs more voices than the game has
[styles.shortOfVoices]: voices !== undefined && total(voices.needed) > MAX_VOICES &&
voices.given[shownIndex] < voices.needed[shownIndex],
[styles.selected]: isSelected,
})}
onClick={handlePress}
Expand All @@ -107,8 +104,10 @@ function PianoRollThumbnail({ trackIndex, trackListIndex, segmentIndex, isAltern
{/* A version's row is under the track's, which names the region */}
{!isVersion && <div id={nameId} className={classNames(styles.segmentName, { [styles.instrumentName]: !track.name })}>
{/* A region without a name of its own is called after its instrument */}
{track.name || instrumentName}
<span className={styles.stickyName}>{track.name || instrumentName}</span>
</div>}
{/* Alternate parts play on the voices of the tracks they're for */}
{voices && trackList && <VoiceBadge trackList={trackList} voices={voices} index={track.alternate_for ?? shownIndex} />}
</div>
}
}
Expand Down
2 changes: 2 additions & 0 deletions mamar-web/src/app/doc/SubsegDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import styles from "./SubsegDetails.module.scss"
import TimeGrid from "./TimeGrid"
import Tracker from "./Tracker"
import TrackLanes from "./TrackLanes"
import { VoiceNote } from "./VoiceBudget"

import Bridge from "../bridge"
import { useBgm, useDoc, useLocation } from "../store"
Expand Down Expand Up @@ -100,6 +101,7 @@ export default function SubsegDetails({ trackListId, trackIndex: mainIndex, segm
</Flex>
<StartingValues trackListId={trackListId} trackIndex={trackIndex} mainIndex={mainIndex} segmentIndex={segmentIndex} />
</Form>
<VoiceNote trackListId={trackListId} trackIndex={trackIndex} segmentIndex={segmentIndex} segmentStart={segmentStart} />
{showBlocks && <Inspector trackListId={trackListId} trackIndex={trackIndex} />}
</View>
{showBlocks ? <div style={{ gridColumn: "2 / -1", minHeight: 0 }}>
Expand Down
Loading
Loading