feat: implemented global key signature track

This commit is contained in:
Xiaohan-Tian
2026-05-23 14:44:48 -07:00
parent 1e816596e4
commit 8eb3f5d84d
30 changed files with 1689 additions and 57 deletions
@@ -5,20 +5,29 @@ import SheetMusicView from './SheetMusicView';
import { getSheetPlayheadPixel, parseSheetQuantization } from './sheetNotation';
import type { SheetMeasureMetric } from './sheetNotationTypes';
import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data';
import { createDefaultGlobalTracks } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
const setPlayheadPosition = vi.fn();
const requestMainContentScroll = vi.fn();
const vexflowMocks = vi.hoisted(() => ({
addKeySignatureMock: vi.fn(),
applyAccidentalsMock: vi.fn(),
}));
const storeState = {
playheadPosition: 0,
setPlayheadPosition,
requestMainContentScroll,
globalTracks: createDefaultGlobalTracks(),
};
vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (state: {
playheadPosition: number;
setPlayheadPosition: typeof setPlayheadPosition;
requestMainContentScroll: typeof requestMainContentScroll;
}) => unknown) => selector({
playheadPosition: 0,
setPlayheadPosition,
requestMainContentScroll,
}),
globalTracks: typeof storeState.globalTracks;
}) => unknown) => selector(storeState),
}));
vi.mock('../common', () => ({
@@ -66,6 +75,7 @@ vi.mock('vexflow', () => {
}
addKeySignature() {
vexflowMocks.addKeySignatureMock(...arguments);
return this;
}
@@ -143,7 +153,7 @@ vi.mock('vexflow', () => {
}
return {
Accidental: { applyAccidentals: vi.fn() },
Accidental: { applyAccidentals: vexflowMocks.applyAccidentalsMock },
BarlineType: { SINGLE: 1, NONE: 0 },
Beam: MockBeam,
Dot: { buildAndAttach: vi.fn() },
@@ -169,6 +179,9 @@ describe('SheetMusicView', () => {
setPlayheadPosition.mockClear();
requestMainContentScroll.mockClear();
onMetricsChange.mockClear();
vexflowMocks.addKeySignatureMock.mockClear();
vexflowMocks.applyAccidentalsMock.mockClear();
storeState.globalTracks = createDefaultGlobalTracks();
});
it('maps header clicks in region scope without adding scroll offset', () => {
@@ -299,4 +312,64 @@ describe('SheetMusicView', () => {
expect(screen.getByTestId('playhead')).toBeInTheDocument();
});
it('renders sheet measures using effective key signatures from the global signature track', () => {
const activeRegion = createMockMidiRegion({
startFromBeat: 0,
length: 12,
notes: [createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 60 })],
});
const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 1, 2, 4),
]);
render(
<SheetMusicView
activeRegion={activeRegion}
midiRegions={[activeRegion]}
maxBars={8}
sheetMusicTrackScopeEnabled={false}
timeSignature={{ numerator: 4, denominator: 4 }}
keySignature="C major"
instrument="acoustic_grand_piano"
quantization={quantization}
onMetricsChange={onMetricsChange}
/>
);
expect(vexflowMocks.addKeySignatureMock).toHaveBeenCalledWith('C');
expect(vexflowMocks.addKeySignatureMock).toHaveBeenCalledWith('G', 'C');
expect(vexflowMocks.applyAccidentalsMock).toHaveBeenCalledWith(expect.any(Array), 'C');
expect(vexflowMocks.applyAccidentalsMock).toHaveBeenCalledWith(expect.any(Array), 'G');
});
it('widens a measure when a key change header is inserted', () => {
const activeRegion = createMockMidiRegion({
startFromBeat: 0,
length: 12,
notes: [],
});
const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 1, 2, 4),
]);
render(
<SheetMusicView
activeRegion={activeRegion}
midiRegions={[activeRegion]}
maxBars={8}
sheetMusicTrackScopeEnabled={false}
timeSignature={{ numerator: 4, denominator: 4 }}
keySignature="C major"
instrument="acoustic_grand_piano"
quantization={quantization}
onMetricsChange={onMetricsChange}
/>
);
const metrics = getLatestMetrics();
expect(metrics[1].widthPx).toBeGreaterThan(metrics[2].widthPx);
});
});
+43 -5
View File
@@ -3,6 +3,8 @@ import { Accidental, BarlineType, Beam, Dot, Formatter, Renderer, Stave, StaveNo
import { Playhead } from '../common';
import { useProjectStore } from '../../stores/projectStore';
import type { KeySignature } from '../../core/KGProject';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import type { KGMidiRegion } from '../../core/region/KGMidiRegion';
import type { InstrumentType } from '../../core/track/KGMidiTrack';
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
@@ -11,6 +13,7 @@ import {
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
getSheetKeySignatureChangeModifierWidth,
projectKeySignatureToVexFlow,
resolveDurationSpec,
resolveSheetClef,
@@ -64,12 +67,28 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
}) => {
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll);
const globalTracks = useProjectStore(state => state.globalTracks);
const [metrics, setMetrics] = useState<SheetMeasureMetric[]>([]);
const [tiePaths, setTiePaths] = useState<SheetTiePath[]>([]);
const headerRef = useRef<HTMLDivElement | null>(null);
const measureHostRefs = useRef<Array<HTMLDivElement | null>>([]);
const lastDrawSignatureRef = useRef<string | null>(null);
const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]);
const signatureRegions = useMemo(() => {
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature);
if (!signatureTrack) {
return [] as KGKeySignatureRegion[];
}
return signatureTrack.getRegions()
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
.sort((left, right) => left.getStartBar() - right.getStartBar());
}, [globalTracks]);
const resolveEffectiveKeySignatureAtBar = useMemo(
() => (barIndex: number): KeySignature => (
signatureRegions.find(region => barIndex >= region.getStartBar() && barIndex < region.getEndBar())?.getKeySignature() ?? keySignature
),
[keySignature, signatureRegions]
);
const startingBarNumber = useMemo(() => (
sheetMusicTrackScopeEnabled
? 1
@@ -88,13 +107,24 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
projectMaxBars: maxBars,
timeSignature,
quantization,
defaultKeySignature: keySignature,
resolveKeySignatureAtBar: resolveEffectiveKeySignatureAtBar,
});
}, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]);
}, [activeRegion, keySignature, maxBars, midiRegions, quantization, resolveEffectiveKeySignatureAtBar, sheetMusicTrackScopeEnabled, timeSignature]);
const measureWidths = useMemo(
() => measureModels.map((measure, index) => (
Math.max(
MIN_MEASURE_WIDTH,
140 + measure.events.length * EVENT_WIDTH + (index === 0 ? FIRST_MEASURE_MODIFIER_WIDTH : 0)
140 +
measure.events.length * EVENT_WIDTH +
(
index === 0
? FIRST_MEASURE_MODIFIER_WIDTH
: getSheetKeySignatureChangeModifierWidth(
measure.keySignature,
measureModels[index - 1]?.keySignature ?? null
)
)
)
)),
[measureModels]
@@ -119,6 +149,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
clef,
instrument,
keySignature,
measureKeySignatures: measureModels.map((measure) => measure.keySignature),
quantization: quantization.raw,
numerator: timeSignature.numerator,
denominator: timeSignature.denominator,
@@ -157,6 +188,8 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
renderer.resize(width, STAFF_HEIGHT);
const context = renderer.getContext();
const showLeadingModifiers = index === 0;
const measureVexKeySignature = projectKeySignatureToVexFlow(measure.keySignature);
const previousMeasureKeySignature = index > 0 ? measureModels[index - 1]?.keySignature : null;
const staveX = showLeadingModifiers ? 8 : 0;
const staveWidth = Math.max(0, width - staveX);
const stave = new Stave(staveX, 10, staveWidth);
@@ -164,8 +197,13 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
stave.setEndBarType(BarlineType.SINGLE);
if (showLeadingModifiers) {
stave.addClef(clef);
stave.addKeySignature(vexKeySignature);
stave.addKeySignature(measureVexKeySignature);
stave.addTimeSignature(`${timeSignature.numerator}/${timeSignature.denominator}`);
} else if (measure.keySignature !== previousMeasureKeySignature) {
stave.addKeySignature(
measureVexKeySignature,
previousMeasureKeySignature ? projectKeySignatureToVexFlow(previousMeasureKeySignature) : undefined
);
}
stave.setContext(context).draw();
@@ -176,7 +214,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
});
voice.setStrict(false);
voice.addTickables(notes);
Accidental.applyAccidentals([voice], vexKeySignature);
Accidental.applyAccidentals([voice], measureVexKeySignature);
const beams = Beam.generateBeams(notes.filter(note => !note.isRest()));
new Formatter().joinVoices([voice]).formatToStave([voice], stave, { stave });
voice.draw(context, stave);
@@ -5,6 +5,7 @@ import {
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
getSheetKeySignatureChangeModifierWidth,
getSheetQuantizationOptions,
isDrumInstrument,
parseSheetQuantization,
@@ -52,8 +53,8 @@ describe('sheetNotation', () => {
it('maps playhead position through variable-width bars', () => {
const metrics = buildSheetMeasureMetrics([
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
{ barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
{ barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
], [120, 240]);
expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
@@ -74,6 +75,13 @@ describe('sheetNotation', () => {
expect(projectKeySignatureToVexFlow('F# major')).toBe('F#');
});
it('estimates extra width for cancelled naturals and new accidentals on key changes', () => {
expect(getSheetKeySignatureChangeModifierWidth('C major', 'G major')).toBeGreaterThan(0);
expect(getSheetKeySignatureChangeModifierWidth('G major', 'C major')).toBeGreaterThan(0);
expect(getSheetKeySignatureChangeModifierWidth('D major', 'G major')).toBeGreaterThan(0);
expect(getSheetKeySignatureChangeModifierWidth('C major', 'C major')).toBe(0);
});
it('keeps bar-aligned quarter notes in the correct measure model', () => {
const region = createMockMidiRegion({
length: 8,
@@ -132,11 +140,30 @@ describe('sheetNotation', () => {
it('maps absolute track beats through sheet metrics for full-track mode', () => {
const metrics = buildSheetMeasureMetrics([
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
{ barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
{ barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
], [120, 240]);
expect(getSheetPlayheadPixel(5, metrics)).toBe(180);
expect(getSheetBeatAtPixel(180, metrics)).toBe(5);
});
it('attaches effective key signatures to sheet measures', () => {
const region = createMockMidiRegion({
startFromBeat: 4,
length: 12,
notes: [createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 60 })],
});
const measures = buildSheetMeasureModels({
region,
timeSignature: { numerator: 4, denominator: 4 },
quantization: parseSheetQuantization('16,48'),
defaultKeySignature: 'C major',
resolveKeySignatureAtBar: (barIndex) => (barIndex >= 2 ? 'G major' : 'C major'),
});
expect(measures.map((measure) => measure.absoluteBarIndex)).toEqual([1, 2, 3]);
expect(measures.map((measure) => measure.keySignature)).toEqual(['C major', 'G major', 'G major']);
});
});
@@ -1,3 +1,4 @@
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import type { KeySignature } from '../../core/KGProject';
import type { KGMidiNote } from '../../core/midi/KGMidiNote';
@@ -30,6 +31,8 @@ export interface BuildSheetNotationOptions {
projectMaxBars?: number;
timeSignature: { numerator: number; denominator: number };
quantization: SheetQuantization;
defaultKeySignature?: KeySignature;
resolveKeySignatureAtBar?: (barIndex: number) => KeySignature;
}
interface WorkingEvent {
@@ -54,6 +57,32 @@ export function projectKeySignatureToVexFlow(keySignature: KeySignature): string
return quality === 'minor' ? `${tonic}m` : tonic;
}
export function getSheetKeySignatureChangeModifierWidth(
keySignature: KeySignature,
previousKeySignature: KeySignature | null
): number {
if (!previousKeySignature || previousKeySignature === keySignature) {
return 0;
}
const currentEntry = KEY_SIGNATURE_MAP[keySignature];
const previousEntry = KEY_SIGNATURE_MAP[previousKeySignature];
const currentCount = currentEntry.accidentals.length;
const previousCount = previousEntry.accidentals.length;
const differentTypes = (
(currentEntry.sharps > 0 && previousEntry.flats > 0) ||
(currentEntry.flats > 0 && previousEntry.sharps > 0)
);
const cancelledNaturals = differentTypes
? previousCount
: Math.max(0, previousCount - currentCount);
const glyphCount = cancelledNaturals + currentCount;
// Roughly matches the added horizontal space VexFlow needs for
// naturals followed by the new key signature accidentals.
return 24 + glyphCount * 12;
}
export function parseSheetQuantization(value: string): SheetQuantization {
const [primaryText, subdivisionText] = value.split(',');
const primary = Number.parseInt(primaryText, 10);
@@ -203,10 +232,13 @@ export function buildSheetMeasureModels({
projectMaxBars,
timeSignature,
quantization,
defaultKeySignature = 'C major',
resolveKeySignatureAtBar,
}: BuildSheetNotationOptions): SheetMeasureModel[] {
const beatsPerBar = timeSignature.numerator;
const isTrackScope = scope === 'track';
const timelineStartBeat = isTrackScope ? 0 : 0;
const regionStartBar = Math.floor(region.getStartFromBeat() / beatsPerBar);
const measureCount = isTrackScope
? Math.max(1, projectMaxBars ?? 1)
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
@@ -226,8 +258,10 @@ export function buildSheetMeasureModels({
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
barIndex,
absoluteBarIndex: isTrackScope ? barIndex : regionStartBar + barIndex,
startBeat: timelineStartBeat + barIndex * beatsPerBar,
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
keySignature: resolveKeySignatureAtBar?.(isTrackScope ? barIndex : regionStartBar + barIndex) ?? defaultKeySignature,
events: [],
}));
@@ -1,3 +1,5 @@
import type { KeySignature } from '../../core/KGProject';
export interface SheetMeasureMetric {
barIndex: number;
startBeat: number;
@@ -24,7 +26,9 @@ export interface SheetDisplayEvent {
export interface SheetMeasureModel {
barIndex: number;
absoluteBarIndex: number;
startBeat: number;
endBeat: number;
keySignature: KeySignature;
events: SheetDisplayEvent[];
}