feat: added track scope mode for sheet music view; adjusted viewport behavior when switching between piano roll view and sheet music view

This commit is contained in:
Xiaohan-Tian
2026-05-10 13:11:10 -07:00
parent d69b3924e5
commit 578be7b726
11 changed files with 850 additions and 135 deletions
+93 -19
View File
@@ -24,7 +24,10 @@ const EPSILON = 1e-6;
export type SheetClef = 'treble' | 'bass' | 'percussion';
export interface BuildSheetNotationOptions {
scope?: 'region' | 'track';
region: KGMidiRegion;
regions?: KGMidiRegion[];
projectMaxBars?: number;
timeSignature: { numerator: number; denominator: number };
quantization: SheetQuantization;
}
@@ -36,6 +39,12 @@ interface WorkingEvent {
isRest: boolean;
}
interface NormalizedNoteInput {
pitch: number;
startBeat: number;
endBeat: number;
}
export function getSheetQuantizationOptions(): string[] {
return [...SHEET_QUANTIZATION_OPTIONS];
}
@@ -84,13 +93,14 @@ export function resolveSheetClef(
return averagePitch >= 60 ? 'treble' : 'bass';
}
export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number): SheetMeasureMetric[] {
export function buildSheetMeasureMetrics(measures: SheetMeasureModel[], widths: number[]): SheetMeasureMetric[] {
let leftPx = 0;
return widths.map((widthPx, index) => {
return measures.map((measure, index) => {
const widthPx = widths[index] ?? 0;
const metric: SheetMeasureMetric = {
barIndex: index,
startBeat: index * beatsPerBar,
endBeat: (index + 1) * beatsPerBar,
barIndex: measure.barIndex,
startBeat: measure.startBeat,
endBeat: measure.endBeat,
leftPx,
widthPx,
};
@@ -100,32 +110,59 @@ export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number):
}
export function getSheetPlayheadPixel(
regionRelativeBeat: number,
sheetBeat: number,
metrics: SheetMeasureMetric[]
): number {
if (metrics.length === 0) {
return 0;
}
if (regionRelativeBeat <= metrics[0].startBeat) {
if (sheetBeat <= metrics[0].startBeat) {
return metrics[0].leftPx;
}
const lastMetric = metrics[metrics.length - 1];
if (regionRelativeBeat >= lastMetric.endBeat) {
if (sheetBeat >= lastMetric.endBeat) {
return lastMetric.leftPx + lastMetric.widthPx;
}
const activeMetric = metrics.find(metric => regionRelativeBeat >= metric.startBeat && regionRelativeBeat < metric.endBeat);
const activeMetric = metrics.find(metric => sheetBeat >= metric.startBeat && sheetBeat < metric.endBeat);
if (!activeMetric) {
return 0;
}
const span = Math.max(activeMetric.endBeat - activeMetric.startBeat, EPSILON);
const progress = (regionRelativeBeat - activeMetric.startBeat) / span;
const progress = (sheetBeat - activeMetric.startBeat) / span;
return activeMetric.leftPx + activeMetric.widthPx * progress;
}
export function getSheetBeatAtPixel(
pixel: number,
metrics: SheetMeasureMetric[]
): number {
if (metrics.length === 0) {
return 0;
}
const firstMetric = metrics[0];
if (pixel <= firstMetric.leftPx) {
return firstMetric.startBeat;
}
const lastMetric = metrics[metrics.length - 1];
if (pixel >= lastMetric.leftPx + lastMetric.widthPx) {
return lastMetric.endBeat;
}
const activeMetric = metrics.find(metric => pixel >= metric.leftPx && pixel < metric.leftPx + metric.widthPx);
if (!activeMetric) {
return lastMetric.endBeat;
}
const progress = activeMetric.widthPx > 0 ? (pixel - activeMetric.leftPx) / activeMetric.widthPx : 0;
return activeMetric.startBeat + progress * (activeMetric.endBeat - activeMetric.startBeat);
}
export function resolveDurationSpec(durationBeats: number, isRest: boolean): { duration: string; dots: number } {
const withRest = (value: string) => (isRest ? `${value}r` : value);
const options = [
@@ -160,21 +197,37 @@ export function resolveDurationSpec(durationBeats: number, isRest: boolean): { d
}
export function buildSheetMeasureModels({
scope = 'region',
region,
regions = [region],
projectMaxBars,
timeSignature,
quantization,
}: BuildSheetNotationOptions): SheetMeasureModel[] {
const beatsPerBar = timeSignature.numerator;
const measureCount = Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
const measureEndBeat = measureCount * beatsPerBar;
const workingEvents = normalizeNotes(region.getNotes(), quantization.stepBeats, measureEndBeat);
const isTrackScope = scope === 'track';
const timelineStartBeat = isTrackScope ? 0 : 0;
const measureCount = isTrackScope
? Math.max(1, projectMaxBars ?? 1)
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
const measureEndBeat = isTrackScope
? measureCount * beatsPerBar
: measureCount * beatsPerBar;
const noteInputs = isTrackScope
? collectTrackScopeNotes(regions)
: region.getNotes().map(note => ({
pitch: note.getPitch(),
startBeat: note.getStartBeat(),
endBeat: note.getEndBeat(),
}));
const workingEvents = normalizeNotes(noteInputs, quantization.stepBeats, measureEndBeat);
const withRests = insertRests(workingEvents, measureEndBeat);
const splitEvents = splitAcrossBars(withRests, beatsPerBar);
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
barIndex,
startBeat: barIndex * beatsPerBar,
endBeat: (barIndex + 1) * beatsPerBar,
startBeat: timelineStartBeat + barIndex * beatsPerBar,
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
events: [],
}));
@@ -199,12 +252,12 @@ export function buildSheetMeasureModels({
return measures;
}
function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
function normalizeNotes(notes: NormalizedNoteInput[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
const clippedNotes = notes
.map(note => ({
keys: [midiPitchToVexKey(note.getPitch())],
startBeat: quantizeBeat(note.getStartBeat(), stepBeats),
endBeat: quantizeBeat(note.getEndBeat(), stepBeats),
keys: [midiPitchToVexKey(note.pitch)],
startBeat: quantizeBeat(note.startBeat, stepBeats),
endBeat: quantizeBeat(note.endBeat, stepBeats),
isRest: false,
}))
.map(note => ({
@@ -247,6 +300,27 @@ function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat:
return merged.filter(note => note.endBeat - note.startBeat > EPSILON);
}
function collectTrackScopeNotes(regions: KGMidiRegion[]): NormalizedNoteInput[] {
return [...regions]
.sort((left, right) => {
if (left.getStartFromBeat() !== right.getStartFromBeat()) {
return left.getStartFromBeat() - right.getStartFromBeat();
}
return left.getId().localeCompare(right.getId());
})
.flatMap(region => {
const regionStart = region.getStartFromBeat();
// Overlapping regions are flattened in deterministic start-beat/id order so
// the existing note normalization path can resolve collisions consistently.
return region.getNotes().map(note => ({
pitch: note.getPitch(),
startBeat: regionStart + note.getStartBeat(),
endBeat: regionStart + note.getEndBeat(),
}));
});
}
function insertRests(events: WorkingEvent[], measureEndBeat: number): WorkingEvent[] {
if (events.length === 0) {
return [{ keys: ['b/4'], startBeat: 0, endBeat: measureEndBeat, isRest: true }];