mroctopus / app /src /utils /midiHelpers.js
Ewan
Add Full Song mode with Demucs source separation
56c8033
import { MIDI_SPLIT_POINT } from './colorScheme';
// Piano range: C2 (36) to C7 (96)
export const LOWEST_NOTE = 36;
export const HIGHEST_NOTE = 96;
const BLACK_KEY_OFFSETS = new Set([1, 3, 6, 8, 10]);
export function isBlackKey(midiNumber) {
return BLACK_KEY_OFFSETS.has(midiNumber % 12);
}
/**
* Build an array of key objects with pixel positions for a given canvas width.
* Returns [{ midiNumber, x, width, isBlack }]
*/
export function buildKeyboardLayout(canvasWidth) {
// Count white keys in range
const keys = [];
const whiteKeys = [];
for (let midi = LOWEST_NOTE; midi <= HIGHEST_NOTE; midi++) {
if (!isBlackKey(midi)) {
whiteKeys.push(midi);
}
}
const whiteKeyWidth = canvasWidth / whiteKeys.length;
const blackKeyWidth = whiteKeyWidth * 0.6;
// Position white keys
const keyMap = new Map();
whiteKeys.forEach((midi, i) => {
keyMap.set(midi, {
midiNumber: midi,
x: i * whiteKeyWidth,
width: whiteKeyWidth,
isBlack: false,
});
});
// Position black keys between their adjacent white keys
for (let midi = LOWEST_NOTE; midi <= HIGHEST_NOTE; midi++) {
if (isBlackKey(midi)) {
// Find the white key just below this black key
const prevWhite = keyMap.get(midi - 1);
if (prevWhite) {
keyMap.set(midi, {
midiNumber: midi,
x: prevWhite.x + prevWhite.width - blackKeyWidth / 2,
width: blackKeyWidth,
isBlack: true,
});
}
}
}
// Return sorted by MIDI number
for (let midi = LOWEST_NOTE; midi <= HIGHEST_NOTE; midi++) {
if (keyMap.has(midi)) {
keys.push(keyMap.get(midi));
}
}
return keys;
}
/**
* Get the x position and width for a falling note block.
*/
export function noteXPosition(midiNumber, keyboardLayout) {
const key = keyboardLayout.find((k) => k.midiNumber === midiNumber);
if (key) return { x: key.x, width: key.width };
// Clamp to range
if (midiNumber < LOWEST_NOTE) {
const first = keyboardLayout[0];
return { x: first.x, width: first.width };
}
const last = keyboardLayout[keyboardLayout.length - 1];
return { x: last.x, width: last.width };
}
// Build a fast lookup map for noteXPosition (avoids .find() per note per frame)
export function buildNotePositionMap(keyboardLayout) {
const map = new Map();
for (const key of keyboardLayout) {
map.set(key.midiNumber, { x: key.x, width: key.width });
}
return map;
}
export function noteXPositionFast(midiNumber, positionMap) {
const pos = positionMap.get(midiNumber);
if (pos) return pos;
// Clamp
if (midiNumber < LOWEST_NOTE) return positionMap.get(LOWEST_NOTE);
return positionMap.get(HIGHEST_NOTE);
}
/**
* Parse a Midi object (from @tonejs/midi) into our note format.
*/
export function parseMidiFile(midiObject) {
const notes = [];
midiObject.tracks.forEach((track) => {
const program = track.instrument?.number ?? 0;
const instrument = (program >= 32 && program <= 39) ? 'bass' : 'piano';
track.notes.forEach((note) => {
notes.push({
midi: note.midi,
name: note.name,
time: note.time,
duration: note.duration,
velocity: note.velocity,
hand: note.midi < MIDI_SPLIT_POINT ? 'left' : 'right',
instrument,
});
});
});
// Sort by start time
notes.sort((a, b) => a.time - b.time);
const totalDuration =
notes.length > 0
? Math.max(...notes.map((n) => n.time + n.duration))
: 0;
return { notes, totalDuration };
}
/**
* Get notes visible in the current time window using binary search.
* Notes array must be sorted by `time` (start time).
*/
export function getVisibleNotes(
notes,
currentTime,
lookAheadSeconds,
maxPastSeconds = 1
) {
const endTime = currentTime + lookAheadSeconds;
// Find the longest note duration so we can search far enough back
// to catch long-held notes that started early but are still visible.
// Precompute once on first call via a cached property.
if (notes._maxDur == null) {
let mx = 0;
for (let i = 0; i < notes.length; i++) {
if (notes[i].duration > mx) mx = notes[i].duration;
}
notes._maxDur = mx;
}
const searchBack = maxPastSeconds + notes._maxDur;
// Binary search on `time` (which IS sorted) to find the earliest
// note that could possibly still be visible.
const earliest = currentTime - searchBack;
let lo = 0;
let hi = notes.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (notes[mid].time < earliest) {
lo = mid + 1;
} else {
hi = mid;
}
}
const cutoff = currentTime - maxPastSeconds;
const result = [];
for (let i = lo; i < notes.length && notes[i].time < endTime; i++) {
// Only include if the note hasn't fully ended before the visible window
if (notes[i].time + notes[i].duration >= cutoff) {
result.push(notes[i]);
}
}
return result;
}