feat: implemented global key signature track
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user