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; }