Add one-line score visualizer with 3D orb and MP4 export
- Verovio single-system engraving, MuseScore audio/MIDI from the same source - React + Remotion player with live camera, colour and orb controls - Express pipeline, render queue and local H.264/AAC export - MIT license plus third-party notices for the reference skill
This commit is contained in:
commit
a237a7c604
38 files changed
+13958
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import type {NoteEvent} from '../shared/types';
|
||||
|
||||
export const BALL_ENTRY_SECONDS = 0.18;
|
||||
/** Resting height above the paper, in score units: just enough to clear the ink. */
|
||||
export const REST_DEPTH = 1.6;
|
||||
|
||||
/**
|
||||
* Amplifies only the part of the height that comes from a hop, so the ball
|
||||
* still rests just above the ink and 0 flattens every jump onto the sheet.
|
||||
*/
|
||||
export function liftDepth(depth: number, lift: number): number {
|
||||
return REST_DEPTH + (depth - REST_DEPTH) * Math.max(0, lift);
|
||||
}
|
||||
|
||||
export type Motion = {
|
||||
position: [number, number];
|
||||
/** Height above the paper along its normal, in score units. */
|
||||
depth: number;
|
||||
pulse: number;
|
||||
landed: boolean;
|
||||
opacity: number;
|
||||
from: NoteEvent;
|
||||
to: NoteEvent;
|
||||
u: number;
|
||||
trailOpacity: number;
|
||||
departing: boolean;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export function groupLandings(notes: NoteEvent[], staff: number): NoteEvent[] {
|
||||
const groups: NoteEvent[][] = [];
|
||||
for (const note of notes
|
||||
.filter((n) => n.staff === staff)
|
||||
.sort((a, b) => a.time - b.time)) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && Math.abs(last[0]!.time - note.time) < 1e-6) last.push(note);
|
||||
else groups.push([note]);
|
||||
}
|
||||
const preferHigh = staff % 2 === 1;
|
||||
return groups.map(
|
||||
(group) =>
|
||||
[...group].sort(
|
||||
(a, b) =>
|
||||
(a.kind === 'attack' ? 0 : 1) - (b.kind === 'attack' ? 0 : 1) ||
|
||||
(preferHigh ? b.pitch - a.pitch : a.pitch - b.pitch),
|
||||
)[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hop from note `a` to note `b`. X/Y travel across the sheet in a straight line
|
||||
* while Z arcs above it, so the ball lands back on the paper at every note.
|
||||
*/
|
||||
export function arcPoint(
|
||||
a: NoteEvent,
|
||||
b: NoteEvent,
|
||||
u: number,
|
||||
): [number, number, number] {
|
||||
const phase = clamp(u, 0, 1);
|
||||
const distance = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const interval = Math.max(0, b.time - a.time);
|
||||
// Big enough that a hop still reads on a nearly face-on sheet, where a
|
||||
// movement along the paper normal mostly points at the camera.
|
||||
const hop =
|
||||
(22 + Math.min(22, distance * 0.15)) * Math.min(1, Math.max(0.25, interval / 0.24));
|
||||
return [
|
||||
a.x + (b.x - a.x) * phase,
|
||||
a.y + (b.y - a.y) * phase,
|
||||
REST_DEPTH + 4 * phase * (1 - phase) * hop,
|
||||
];
|
||||
}
|
||||
|
||||
export function performanceAt(targets: NoteEvent[], t: number): Motion | null {
|
||||
if (!targets.length) return null;
|
||||
let i = 0;
|
||||
while (i + 1 < targets.length && targets[i + 1]!.time <= t) i++;
|
||||
const current = targets[i]!;
|
||||
const next = targets[i + 1];
|
||||
const age = t - current.time;
|
||||
const landed = age >= 0;
|
||||
const resting = Boolean(next && next.time - current.time - current.duration > 0.65);
|
||||
const enteringNext = Boolean(resting && next && t >= next.time - BALL_ENTRY_SECONDS);
|
||||
const entryTarget = enteringNext ? next! : current;
|
||||
const entryPhase = clamp((t - entryTarget.time + BALL_ENTRY_SECONDS) / BALL_ENTRY_SECONDS, 0, 1);
|
||||
const departing = Boolean(next && landed && !resting);
|
||||
const u = departing ? clamp(age / Math.max(1e-6, next!.time - current.time), 0, 1) : 1;
|
||||
const [ballX, ballY, ballZ] = departing
|
||||
? arcPoint(current, next!, u)
|
||||
: arcPoint(entryTarget, entryTarget, 1);
|
||||
const position: [number, number] = [ballX, ballY];
|
||||
|
||||
let opacity = 1;
|
||||
if (!landed || enteringNext) {
|
||||
opacity = Math.min(1, entryPhase * 2.5);
|
||||
} else if (resting || !next) {
|
||||
opacity = clamp((current.time + current.duration + 0.18 - t) / 0.18, 0, 1);
|
||||
}
|
||||
|
||||
const pulse = landed && !enteringNext ? Math.exp(-Math.max(0, age) / 0.1) : 0;
|
||||
const from = departing ? current : targets[Math.max(0, i - 1)] ?? current;
|
||||
const to = departing ? next! : current;
|
||||
const trailOpacity = departing
|
||||
? 0.68
|
||||
: from.scoreId !== to.scoreId && i > 0
|
||||
? 0.48 * Math.max(0, 1 - age / 0.3)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
position,
|
||||
depth: ballZ,
|
||||
pulse,
|
||||
landed,
|
||||
opacity,
|
||||
from,
|
||||
to,
|
||||
u,
|
||||
trailOpacity,
|
||||
departing,
|
||||
};
|
||||
}
|
||||
|
||||
function lowerBound(notes: NoteEvent[], time: number): number {
|
||||
let low = 0;
|
||||
let high = notes.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (notes[mid]!.time < time) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** Latest ring per scoreId within the 3.6s window, matching the original behavior. */
|
||||
export function activeRingsAt(notes: NoteEvent[], t: number): NoteEvent[] {
|
||||
const start = lowerBound(notes, t - 3.6);
|
||||
const latest = new Map<string, NoteEvent>();
|
||||
for (let i = start; i < notes.length; i++) {
|
||||
const note = notes[i]!;
|
||||
if (note.time > t) break;
|
||||
latest.set(note.scoreId, note);
|
||||
}
|
||||
return [...latest.values()];
|
||||
}
|
||||
|
||||
export function staffColorIndex(staff: number): number {
|
||||
return Math.max(0, staff - 1);
|
||||
}
|
||||
Reference in new issue
Block a user