Files
ITOSMPiano-BachToBasics/shared/types/MusicData.ts
T
2026-05-06 17:56:38 +02:00

202 lines
7.0 KiB
TypeScript

// ─── Core music data model ────────────────────────────────────────────────────
// All views (SheetMusic, PianoKeyboard, FallingNotes) are driven by these types.
// NoteEvent arrays are computed ONCE at import time and never mutated during playback.
export type Hand = "left" | "right" | "unknown";
export type Finger = 1 | 2 | 3 | 4 | 5;
export type SourceType = "midi" | "musicxml" | "guitarpro" | "mp3" | "youtube";
export type FingeringVersion = "auto" | "manual" | "none";
export type PlaybackStatus = "stopped" | "playing" | "paused" | "waiting";
export interface NoteEvent {
id: string;
/** MIDI note number 0-127 */
midi: number;
/** Human-readable pitch e.g. "C4", "F#5" */
pitch: string;
startTick: number;
durationTick: number;
/** Wall-clock start time at tempo multiplier 1.0 (seconds) */
startSeconds: number;
/** Wall-clock end time at tempo multiplier 1.0 (seconds) */
endSeconds: number;
hand: Hand;
finger: Finger | null;
/** MIDI velocity 0-127 */
velocity: number;
channel: number;
}
export interface TempoEvent {
tick: number;
bpm: number;
}
export interface TimeSignatureEvent {
tick: number;
numerator: number;
denominator: number;
}
export interface KeySignature {
/** Root note name: "C" | "G" | "D" | "A" | "E" | "B" | "F#" | "C#" | "F" | "Bb" | "Eb" | "Ab" | "Db" | "Gb" | "Cb" */
key: string;
scale: "major" | "minor";
}
export interface SustainRange {
startSeconds: number;
endSeconds: number;
}
export interface MusicDocument {
id: string;
title: string;
sourceType: SourceType;
/** MusicXML string - drives AlphaTab sheet music view */
musicXml: string | null;
/** Raw .mxl bytes (ZIP-compressed MusicXML) - present only for direct .mxl imports */
mxlBuffer?: ArrayBuffer;
/** Raw MIDI binary - drives Tone.js playback */
midiBuffer: ArrayBuffer | null;
/** Flat sorted array of all note events, pre-computed at import time */
notes: NoteEvent[];
tempoMap: TempoEvent[];
timeSignatures: TimeSignatureEvent[];
/** Total duration in seconds at tempo multiplier 1.0 */
totalDuration: number;
/** Ticks per quarter note (PPQ) */
ppq: number;
/** Key signature - null if not present in the source file */
keySignature: KeySignature | null;
/** YouTube video ID if source is YouTube */
youtubeId: string | null;
/** User-adjustable sync offset for YouTube (seconds) */
youtubeSyncOffset: number;
fingeringVersion: FingeringVersion;
/** Sustain pedal (CC64) on/off ranges, pre-converted to seconds */
sustainRanges?: SustainRange[];
}
export interface PlaybackState {
status: PlaybackStatus;
/** Current position in seconds (scaled by tempo multiplier) */
currentSeconds: number;
/** Tempo multiplier: 0.25 = 25%, 1.0 = 100%, 2.0 = 200% */
tempoMultiplier: number;
/** Loop start in seconds (tempo-scaled), null = no loop */
loopStart: number | null;
/** Loop end in seconds (tempo-scaled), null = no loop */
loopEnd: number | null;
/** Which hands are active for playback and wait-mode evaluation */
activeHands: Set<Hand>;
/** When true, transport pauses until user plays the expected note */
waitMode: boolean;
}
// ─── Utility: key signature to display label ─────────────────────────────────
export function keySignatureToLabel(ks: KeySignature): string {
return `${ks.key} ${ks.scale}`;
}
// ─── Utility: MIDI ticks to seconds using a tempo map ────────────────────────
function tickToSeconds(tick: number, tempoMap: TempoEvent[], ppq: number): number {
let sec = 0;
let prevTick = 0;
for (let i = 0; i < tempoMap.length; i++) {
const ev = tempoMap[i];
if (ev.tick > tick) break;
const next = tempoMap[i + 1];
const endTick = (next && next.tick <= tick) ? next.tick : tick;
sec += (endTick - prevTick) * (60 / (ev.bpm * ppq));
prevTick = endTick;
if (!next || next.tick > tick) break;
}
return sec;
}
// ─── Utility: compute measure start times in seconds ─────────────────────────
export function computeMeasureSeconds(
timeSignatures: TimeSignatureEvent[],
tempoMap: TempoEvent[],
ppq: number,
totalDuration: number,
): number[] {
if (!timeSignatures.length || !tempoMap.length) return [];
const measures: number[] = [];
let measureTick = 0;
let tsIdx = 0;
while (true) {
// Advance to the right time signature segment
while (tsIdx + 1 < timeSignatures.length && timeSignatures[tsIdx + 1].tick <= measureTick) {
tsIdx++;
}
const ts = timeSignatures[tsIdx];
const ticksPerMeasure = Math.round(ppq * 4 * ts.numerator / ts.denominator);
const sec = tickToSeconds(measureTick, tempoMap, ppq);
if (sec > totalDuration + 1) break;
measures.push(sec);
measureTick += ticksPerMeasure;
if (measures.length > 2000) break; // safety cap
}
return measures;
}
// ─── Utility: compute every beat start time in seconds ───────────────────────
export function computeBeatSeconds(
timeSignatures: TimeSignatureEvent[],
tempoMap: TempoEvent[],
ppq: number,
totalDuration: number,
): number[] {
if (!timeSignatures.length || !tempoMap.length) return [];
const beats: number[] = [];
let beatTick = 0;
let tsIdx = 0;
while (true) {
while (tsIdx + 1 < timeSignatures.length && timeSignatures[tsIdx + 1].tick <= beatTick) {
tsIdx++;
}
const ts = timeSignatures[tsIdx];
// One beat = one denominator unit relative to the quarter note
const ticksPerBeat = Math.round(ppq * 4 / ts.denominator);
const sec = tickToSeconds(beatTick, tempoMap, ppq);
if (sec > totalDuration + 1) break;
beats.push(sec);
beatTick += ticksPerBeat;
if (beats.length > 20000) break; // safety cap
}
return beats;
}
// ─── Utility: scale stored seconds by tempo multiplier ───────────────────────
export function scaleTime(storedSeconds: number, tempoMultiplier: number): number {
return storedSeconds / tempoMultiplier;
}
// ─── Utility: MIDI note number to pitch string ────────────────────────────────
const SHARP_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const FLAT_NAMES = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"];
export function midiToPitch(midi: number, useFlats = false): string {
const names = useFlats ? FLAT_NAMES : SHARP_NAMES;
const octave = Math.floor(midi / 12) - 1;
return `${names[midi % 12]}${octave}`;
}
export function midiToNoteName(midi: number, useFlats = false): string {
const names = useFlats ? FLAT_NAMES : SHARP_NAMES;
return names[midi % 12];
}
export function isBlackKey(midi: number): boolean {
const mod = midi % 12;
return [1, 3, 6, 8, 10].includes(mod);
}