Initial commit

This commit is contained in:
Fabricio Giglio
2026-05-06 17:56:38 +02:00
commit 8f4df1451e
78 changed files with 12130 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@bach-to-basics/shared",
"version": "0.1.0",
"private": true,
"main": "./types/index.ts",
"types": "./types/index.ts"
}
+201
View File
@@ -0,0 +1,201 @@
// ─── 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);
}
+89
View File
@@ -0,0 +1,89 @@
// ─── EventBus typed event map ─────────────────────────────────────────────────
// All inter-component communication flows through these events.
// Views subscribe; only SyncEngine emits (except input:note from MIDI/WebMidi).
import type { NoteEvent, PlaybackStatus } from "./MusicData";
export interface SyncEventMap {
// ── Playback events ──────────────────────────────────────────────────────────
/**
* Emitted LOOKAHEAD_SECONDS before a note plays.
* FallingNotes pre-renders the bar so it's ready when it enters the viewport.
*/
"note:upcoming": NoteEvent;
/**
* Emitted at the exact moment a note begins playing.
* PianoKeyboard highlights the key; Tone.js triggers the sample.
*/
"note:on": NoteEvent;
/**
* Emitted at the exact moment a note stops playing.
* PianoKeyboard un-highlights the key.
*/
"note:off": NoteEvent;
/**
* Emitted every animation frame (~16ms) with the current playback position.
* SheetMusicView drives the AlphaTab cursor; FallingNotesView moves bars.
*/
"transport:tick": { seconds: number; tick: number };
/**
* Emitted when transport status changes (play/pause/stop/loop/waiting).
*/
"transport:stateChange": { status: PlaybackStatus; seconds: number };
// ── User input events ────────────────────────────────────────────────────────
/**
* Emitted when the user plays a note on the physical piano or virtual keyboard.
* SyncEngine uses this for wait-mode comparison.
* PianoKeyboard echoes the key highlight with a different color.
*/
"input:note:on": { midi: number; velocity: number };
/**
* Emitted when the user releases a note.
*/
"input:note:off": { midi: number };
// ── Document events ──────────────────────────────────────────────────────────
/**
* Emitted when a new MusicDocument is loaded and all views should reset.
*/
"document:loaded": { id: string; title: string; totalDuration: number };
/**
* Emitted when settings change that affect all views (e.g., toggle labels,
* change hand colors, toggle fingering).
*/
"settings:changed": { key: string; value: unknown };
// ── Transport ────────────────────────────────────────────────────────────────
/**
* Emitted at the start of every seek/skip so UI can release pressed keys
* before Tone cancels pending note:off callbacks.
*/
"transport:seek": { seconds: number };
// ── Practice features ────────────────────────────────────────────────────────
/**
* Emitted by SyncEngine when the speed trainer advances one step.
* The store updates settings.speedTrainer.currentPct to keep UI in sync.
*/
"speedTrainer:stepped": { currentPct: number };
/**
* Emitted by AccuracyTracker after each note event with running totals.
*/
"accuracy:update": { correct: number; wrong: number; missed: number; score: number };
}
export type SyncEventName = keyof SyncEventMap;
export type SyncEventPayload<K extends SyncEventName> = SyncEventMap[K];
+2
View File
@@ -0,0 +1,2 @@
export * from "./MusicData";
export * from "./SyncEvent";