Spaces:
Sleeping
Sleeping
| 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; | |
| } | |