@k-l-lambda/lilylet 0.1.60 → 0.1.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/lilylet/grammar.jison.js +273 -169
- package/lib/lilylet/lilypondDecoder.js +163 -39
- package/lib/lilylet/lilypondEncoder.js +117 -7
- package/lib/lilylet/musicXmlEncoder.js +1 -1
- package/lib/lilylet/parser.d.ts +12 -1
- package/lib/lilylet/parser.js +11 -1
- package/lib/lilylet/serializer.js +33 -4
- package/lib/lilylet/types.d.ts +8 -2
- package/package.json +12 -7
- package/source/abc/TODO.md +97 -0
- package/source/lilylet/grammar.jison.js +273 -169
- package/source/lilylet/lilylet.jison +114 -15
- package/source/lilylet/lilypondDecoder.ts +139 -42
- package/source/lilylet/lilypondEncoder.ts +114 -9
- package/source/lilylet/meiEncoder.ts +2 -1
- package/source/lilylet/musicXmlDecoder.ts +2 -2
- package/source/lilylet/musicXmlEncoder.ts +1 -1
- package/source/lilylet/parser.ts +20 -0
- package/source/lilylet/serializer.ts +31 -6
- package/source/lilylet/types.ts +10 -2
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
RestEvent,
|
|
16
16
|
ContextChange,
|
|
17
17
|
TupletEvent,
|
|
18
|
+
TimesEvent,
|
|
18
19
|
TremoloEvent,
|
|
19
20
|
BarlineEvent,
|
|
20
21
|
HarmonyEvent,
|
|
@@ -164,6 +165,53 @@ const getSpacerRest = (timeSig?: { numerator: number; denominator: number }): st
|
|
|
164
165
|
};
|
|
165
166
|
|
|
166
167
|
|
|
168
|
+
// === Partial Measure Helpers ===
|
|
169
|
+
|
|
170
|
+
const TPQN = 480;
|
|
171
|
+
|
|
172
|
+
const voiceDurationTicks = (voice: Voice): number => {
|
|
173
|
+
let ticks = 0;
|
|
174
|
+
const addEvent = (e: Event, factor = 1): void => {
|
|
175
|
+
if (e.type === 'note' || e.type === 'rest') {
|
|
176
|
+
const ev = e as NoteEvent | RestEvent;
|
|
177
|
+
if ((ev as NoteEvent).grace) return;
|
|
178
|
+
let t = (TPQN * 4) / ev.duration.division;
|
|
179
|
+
let dot = t / 2;
|
|
180
|
+
for (let i = 0; i < ev.duration.dots; i++) { t += dot; dot /= 2; }
|
|
181
|
+
ticks += Math.round(t * factor);
|
|
182
|
+
} else if (e.type === 'tuplet' || e.type === 'times') {
|
|
183
|
+
const te = e as TupletEvent;
|
|
184
|
+
const f = factor * te.ratio.numerator / te.ratio.denominator;
|
|
185
|
+
for (const inner of te.events) addEvent(inner as Event, f);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
for (const e of voice.events) addEvent(e);
|
|
189
|
+
return ticks;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const ticksToLyDuration = (ticks: number): string => {
|
|
193
|
+
const whole = TPQN * 4;
|
|
194
|
+
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
195
|
+
const t = whole / div;
|
|
196
|
+
if (Math.abs(ticks - t) < 1) return String(div);
|
|
197
|
+
if (Math.abs(ticks - Math.round(t * 1.5)) < 1) return `${div}.`;
|
|
198
|
+
if (Math.abs(ticks - Math.round(t * 1.75)) < 1) return `${div}..`;
|
|
199
|
+
}
|
|
200
|
+
return '4';
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Return a spacer-rest suffix to pad a voice to the full measure duration.
|
|
204
|
+
// Uses s16*N to avoid complexity with dotted/compound values.
|
|
205
|
+
const padVoiceToMeasure = (voiceTicks: number, measureTicks: number): string => {
|
|
206
|
+
const remaining = Math.round(measureTicks - voiceTicks);
|
|
207
|
+
if (remaining <= 0) return '';
|
|
208
|
+
const sixteenth = Math.round(TPQN / 4); // 120 ticks
|
|
209
|
+
const numSixteenths = Math.round(remaining / sixteenth);
|
|
210
|
+
if (numSixteenths <= 0) return '';
|
|
211
|
+
return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
|
|
167
215
|
// === Pitch Environment for Relative Mode ===
|
|
168
216
|
|
|
169
217
|
interface PitchEnv {
|
|
@@ -509,8 +557,12 @@ const encodeContextChange = (event: ContextChange): string => {
|
|
|
509
557
|
/**
|
|
510
558
|
* Encode a tuplet event
|
|
511
559
|
*/
|
|
512
|
-
const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
|
|
513
|
-
|
|
560
|
+
const encodeTupletEvent = (event: TupletEvent | TimesEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
|
|
561
|
+
// \times preserves type:"times"; \tuplet is denominator/numerator
|
|
562
|
+
const header = event.type === 'times'
|
|
563
|
+
? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
|
|
564
|
+
: `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
|
|
565
|
+
let result = `${header} { `;
|
|
514
566
|
let newEnv = env;
|
|
515
567
|
let newDuration = lastDuration;
|
|
516
568
|
|
|
@@ -650,8 +702,9 @@ const encodeVoice = (
|
|
|
650
702
|
result += encodeContextChange(event) + ' ';
|
|
651
703
|
break;
|
|
652
704
|
}
|
|
653
|
-
case 'tuplet':
|
|
654
|
-
|
|
705
|
+
case 'tuplet':
|
|
706
|
+
case 'times': {
|
|
707
|
+
const { str, newEnv, newDuration } = encodeTupletEvent(event as TupletEvent | TimesEvent, env, lastDuration);
|
|
655
708
|
result += str + ' ';
|
|
656
709
|
env = newEnv;
|
|
657
710
|
lastDuration = newDuration;
|
|
@@ -740,7 +793,7 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
740
793
|
for (const part of measure.parts) {
|
|
741
794
|
for (const voice of part.voices) {
|
|
742
795
|
for (const event of voice.events) {
|
|
743
|
-
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
|
|
796
|
+
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
744
797
|
return true;
|
|
745
798
|
}
|
|
746
799
|
}
|
|
@@ -769,6 +822,8 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
769
822
|
|
|
770
823
|
// Track time signature for each measure (for spacer rests)
|
|
771
824
|
const measureTimeSigs: Array<{ numerator: number; denominator: number } | undefined> = [];
|
|
825
|
+
// Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
|
|
826
|
+
const measurePartialDurs: Array<string | undefined> = [];
|
|
772
827
|
|
|
773
828
|
let currentKey: KeySignature | undefined;
|
|
774
829
|
let currentTimeSig: { numerator: number; denominator: number } | undefined;
|
|
@@ -783,6 +838,29 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
783
838
|
// Store time signature for this measure
|
|
784
839
|
measureTimeSigs[mi] = currentTimeSig;
|
|
785
840
|
|
|
841
|
+
// Detect partial (pickup) measures and compute their duration.
|
|
842
|
+
// Only the first measure (mi===0) can be an implicit partial;
|
|
843
|
+
// subsequent incomplete measures are NOT treated as partial.
|
|
844
|
+
const isExplicitPartial = measure.partial === true;
|
|
845
|
+
if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
|
|
846
|
+
let maxVoiceTicks = 0;
|
|
847
|
+
for (const part of measure.parts) {
|
|
848
|
+
for (const v of part.voices) {
|
|
849
|
+
maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const expectedTicks = currentTimeSig
|
|
853
|
+
? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
|
|
854
|
+
: TPQN * 4;
|
|
855
|
+
if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
|
|
856
|
+
measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
|
|
857
|
+
} else {
|
|
858
|
+
measurePartialDurs[mi] = undefined;
|
|
859
|
+
}
|
|
860
|
+
} else {
|
|
861
|
+
measurePartialDurs[mi] = undefined;
|
|
862
|
+
}
|
|
863
|
+
|
|
786
864
|
// Process each part
|
|
787
865
|
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
788
866
|
const part = measure.parts[pi];
|
|
@@ -803,12 +881,21 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
803
881
|
}
|
|
804
882
|
|
|
805
883
|
// Encode voice content
|
|
806
|
-
|
|
884
|
+
let voiceContent = encodeVoice(voice, {
|
|
807
885
|
key: currentKey,
|
|
808
886
|
timeSig: currentTimeSig,
|
|
809
887
|
isFirst: mi === 0
|
|
810
888
|
}, vi);
|
|
811
889
|
|
|
890
|
+
// For non-partial measures, pad incomplete voices to fill the full
|
|
891
|
+
// measure duration. lotus doesn't auto-advance on barlines, so
|
|
892
|
+
// under-full voices cause measure boundary miscounting.
|
|
893
|
+
if (!measurePartialDurs[mi] && currentTimeSig) {
|
|
894
|
+
const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
|
|
895
|
+
const voiceTicks = voiceDurationTicks(voice);
|
|
896
|
+
voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
|
|
897
|
+
}
|
|
898
|
+
|
|
812
899
|
staffMeasures[mi].push(voiceContent);
|
|
813
900
|
}
|
|
814
901
|
}
|
|
@@ -823,10 +910,28 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
823
910
|
// Build voice lines
|
|
824
911
|
const voiceLines: string[] = [];
|
|
825
912
|
for (let vi = 0; vi < maxVoices; vi++) {
|
|
913
|
+
let prevTimeSigStr: string | undefined;
|
|
826
914
|
const measureContents = measures.map((m, mi) => {
|
|
827
|
-
//
|
|
828
|
-
const
|
|
829
|
-
const
|
|
915
|
+
// For partial (pickup) measures, use a partial-duration spacer
|
|
916
|
+
const partialDur = measurePartialDurs[mi];
|
|
917
|
+
const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
|
|
918
|
+
let content = m[vi] || spacer;
|
|
919
|
+
// Inject \time if the content lacks it and the time sig changed.
|
|
920
|
+
// lotus processes each voice independently — without \time it
|
|
921
|
+
// defaults to 4/4, miscounting measure boundaries for other meters.
|
|
922
|
+
const ts = measureTimeSigs[mi];
|
|
923
|
+
if (ts) {
|
|
924
|
+
const tsStr = `${ts.numerator}/${ts.denominator}`;
|
|
925
|
+
if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
|
|
926
|
+
content = `${encodeTimeSig(ts)} ${content}`;
|
|
927
|
+
}
|
|
928
|
+
prevTimeSigStr = tsStr;
|
|
929
|
+
}
|
|
930
|
+
// For partial measures, prepend \partial DUR before all other commands
|
|
931
|
+
// so the lotus interpreter correctly tracks the pickup measure boundary.
|
|
932
|
+
if (partialDur && !content.includes('\\partial')) {
|
|
933
|
+
content = `\\partial ${partialDur} ${content}`;
|
|
934
|
+
}
|
|
830
935
|
// Wrap each measure in its own \relative c' to reset pitch context
|
|
831
936
|
return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
|
|
832
937
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RestEvent,
|
|
8
8
|
ContextChange,
|
|
9
9
|
TupletEvent,
|
|
10
|
+
TimesEvent,
|
|
10
11
|
TremoloEvent,
|
|
11
12
|
BarlineEvent,
|
|
12
13
|
HarmonyEvent,
|
|
@@ -943,7 +944,7 @@ interface LayerResult {
|
|
|
943
944
|
|
|
944
945
|
|
|
945
946
|
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
946
|
-
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
947
|
+
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
947
948
|
if (event.type === 'note') {
|
|
948
949
|
const markOptions = extractMarkOptions((event as NoteEvent).marks);
|
|
949
950
|
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
@@ -1201,9 +1201,9 @@ const convertMeasure = (
|
|
|
1201
1201
|
// Calculate total duration of tuplet for voiceTracker
|
|
1202
1202
|
let totalDuration = 0;
|
|
1203
1203
|
for (const evt of tupletEvent.events) {
|
|
1204
|
-
if (evt.duration) {
|
|
1204
|
+
if ((evt as NoteEvent | RestEvent).duration) {
|
|
1205
1205
|
// Convert division to duration units (quarter = 1)
|
|
1206
|
-
totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
|
|
1206
|
+
totalDuration += (4 / (evt as NoteEvent | RestEvent).duration.division) * voiceTracker.getDivisions();
|
|
1207
1207
|
}
|
|
1208
1208
|
}
|
|
1209
1209
|
// Apply tuplet ratio to get actual duration
|
|
@@ -754,7 +754,7 @@ const encodeMeasure = (
|
|
|
754
754
|
}
|
|
755
755
|
|
|
756
756
|
case 'tuplet': {
|
|
757
|
-
const tupletEvents = event.events;
|
|
757
|
+
const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest') as (NoteEvent | RestEvent)[];
|
|
758
758
|
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
759
759
|
const subEvent = tupletEvents[ti];
|
|
760
760
|
// Set tuplet ratio on duration so encodeDuration emits <time-modification>
|
package/source/lilylet/parser.ts
CHANGED
|
@@ -157,6 +157,14 @@ const resolveDocumentPitches = (doc: LilyletDoc): void => {
|
|
|
157
157
|
};
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
export interface ParseWarning {
|
|
161
|
+
type: 'partial-mismatch';
|
|
162
|
+
message: string;
|
|
163
|
+
declared: number; // ticks
|
|
164
|
+
actual: number; // ticks
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
160
168
|
const parseCode = (code: string): LilyletDoc => {
|
|
161
169
|
// Reset parser state before each parse to avoid contamination
|
|
162
170
|
if (parser && (parser as any).resetState) {
|
|
@@ -172,7 +180,19 @@ const parseCode = (code: string): LilyletDoc => {
|
|
|
172
180
|
};
|
|
173
181
|
|
|
174
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Return warnings emitted during the most recent parseCode() call.
|
|
185
|
+
* Currently reports \partial duration mismatches.
|
|
186
|
+
*/
|
|
187
|
+
const getParseWarnings = (): ParseWarning[] => {
|
|
188
|
+
if (parser && (parser as any).getWarnings) {
|
|
189
|
+
return (parser as any).getWarnings() as ParseWarning[];
|
|
190
|
+
}
|
|
191
|
+
return [];
|
|
192
|
+
};
|
|
193
|
+
|
|
175
194
|
|
|
176
195
|
export {
|
|
177
196
|
parseCode,
|
|
197
|
+
getParseWarnings,
|
|
178
198
|
};
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
RestEvent,
|
|
16
16
|
ContextChange,
|
|
17
17
|
TupletEvent,
|
|
18
|
+
TimesEvent,
|
|
18
19
|
TremoloEvent,
|
|
19
20
|
BarlineEvent,
|
|
20
21
|
Pitch,
|
|
@@ -400,6 +401,11 @@ const serializeContextChange = (event: ContextChange): string => {
|
|
|
400
401
|
parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
|
|
401
402
|
}
|
|
402
403
|
|
|
404
|
+
// Partial (pickup measure duration check)
|
|
405
|
+
if (event.partial) {
|
|
406
|
+
parts.push('\\partial ' + event.partial.division + '.'.repeat(event.partial.dots || 0));
|
|
407
|
+
}
|
|
408
|
+
|
|
403
409
|
// Ottava
|
|
404
410
|
if (event.ottava !== undefined) {
|
|
405
411
|
if (event.ottava === 0) {
|
|
@@ -447,14 +453,17 @@ const serializeTempo = (tempo: Tempo): string => {
|
|
|
447
453
|
|
|
448
454
|
// Serialize a tuplet event with pitch environment tracking
|
|
449
455
|
const serializeTupletEvent = (
|
|
450
|
-
event: TupletEvent,
|
|
456
|
+
event: TupletEvent | TimesEvent,
|
|
451
457
|
env: PitchEnv
|
|
452
458
|
): { str: string; newEnv: PitchEnv } => {
|
|
453
459
|
const parts: string[] = [];
|
|
454
460
|
let currentEnv = env;
|
|
455
461
|
|
|
456
|
-
// \times numerator/denominator
|
|
457
|
-
|
|
462
|
+
// \tuplet denominator/numerator { ... } for tuplet type, \times numerator/denominator for times type
|
|
463
|
+
const keyword = event.type === 'times'
|
|
464
|
+
? '\\times ' + event.ratio.numerator + '/' + event.ratio.denominator
|
|
465
|
+
: '\\tuplet ' + event.ratio.denominator + '/' + event.ratio.numerator;
|
|
466
|
+
parts.push(keyword + ' {');
|
|
458
467
|
|
|
459
468
|
let prevDuration: Duration | undefined;
|
|
460
469
|
for (const e of event.events) {
|
|
@@ -468,6 +477,15 @@ const serializeTupletEvent = (
|
|
|
468
477
|
parts.push(' ' + str);
|
|
469
478
|
currentEnv = newEnv;
|
|
470
479
|
prevDuration = (e as RestEvent).duration;
|
|
480
|
+
} else if (e.type === 'context') {
|
|
481
|
+
const ctx = e as ContextChange;
|
|
482
|
+
if (ctx.staff != null) {
|
|
483
|
+
parts.push(' \\staff "' + ctx.staff + '"');
|
|
484
|
+
} else if (ctx.stemDirection != null) {
|
|
485
|
+
if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
|
|
486
|
+
else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
|
|
487
|
+
else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
|
|
488
|
+
}
|
|
471
489
|
}
|
|
472
490
|
}
|
|
473
491
|
|
|
@@ -554,7 +572,8 @@ const serializeEvent = (
|
|
|
554
572
|
case 'context':
|
|
555
573
|
return { str: serializeContextChange(event as ContextChange), newEnv: env };
|
|
556
574
|
case 'tuplet':
|
|
557
|
-
|
|
575
|
+
case 'times':
|
|
576
|
+
return serializeTupletEvent(event as TupletEvent | TimesEvent, env);
|
|
558
577
|
case 'tremolo':
|
|
559
578
|
return serializeTremoloEvent(event as TremoloEvent, env);
|
|
560
579
|
case 'barline':
|
|
@@ -612,7 +631,7 @@ const serializeVoice = (
|
|
|
612
631
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
613
632
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
614
633
|
// context{staff} events before this index are skipped in the main loop.
|
|
615
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
|
|
634
|
+
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
616
635
|
let effectiveInitialStaff = voice.staff;
|
|
617
636
|
let leadStaffScanEnd = 0;
|
|
618
637
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -749,6 +768,12 @@ const serializeVoice = (
|
|
|
749
768
|
prevDuration = (event as NoteEvent).duration;
|
|
750
769
|
} else if (event.type === 'rest') {
|
|
751
770
|
prevDuration = (event as RestEvent).duration;
|
|
771
|
+
} else if (event.type === 'tuplet' || event.type === 'times') {
|
|
772
|
+
// After a tuplet/times block the LilyPond parser's "current duration" is the
|
|
773
|
+
// last note duration inside the tuplet, not the duration before the tuplet.
|
|
774
|
+
// Reset prevDuration so the first note after the block always emits its
|
|
775
|
+
// duration explicitly, avoiding wrong inheritance from inside the tuplet.
|
|
776
|
+
prevDuration = undefined;
|
|
752
777
|
} else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
|
|
753
778
|
const ctx = event as ContextChange;
|
|
754
779
|
emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
|
|
@@ -836,7 +861,7 @@ const serializeMeasure = (
|
|
|
836
861
|
}
|
|
837
862
|
staff = newStaff;
|
|
838
863
|
}
|
|
839
|
-
parts.push(partStrs.join('
|
|
864
|
+
parts.push(partStrs.join(' \\\\\\\n'));
|
|
840
865
|
}
|
|
841
866
|
|
|
842
867
|
return { str: parts.join(' '), newStaff: staff };
|
package/source/lilylet/types.ts
CHANGED
|
@@ -229,6 +229,7 @@ export interface ContextChange {
|
|
|
229
229
|
type: 'context';
|
|
230
230
|
key?: KeySignature;
|
|
231
231
|
time?: Fraction;
|
|
232
|
+
partial?: Duration; // Pickup measure duration (check-only, warns if mismatch)
|
|
232
233
|
clef?: Clef;
|
|
233
234
|
ottava?: number; // -1, 0, 1
|
|
234
235
|
stemDirection?: StemDirection;
|
|
@@ -247,7 +248,14 @@ export interface TremoloEvent {
|
|
|
247
248
|
export interface TupletEvent {
|
|
248
249
|
type: 'tuplet';
|
|
249
250
|
ratio: Fraction; // e.g., {numerator: 2, denominator: 3} for triplet
|
|
250
|
-
events: (NoteEvent | RestEvent)[];
|
|
251
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// TimesEvent: from lilylet \times syntax (distinct from \tuplet decoded from LilyPond)
|
|
255
|
+
export interface TimesEvent {
|
|
256
|
+
type: 'times';
|
|
257
|
+
ratio: Fraction;
|
|
258
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
export interface PitchResetEvent {
|
|
@@ -270,7 +278,7 @@ export interface MarkupEvent {
|
|
|
270
278
|
placement?: Placement; // Optional placement (above/below)
|
|
271
279
|
}
|
|
272
280
|
|
|
273
|
-
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
281
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
274
282
|
|
|
275
283
|
// === Structure ===
|
|
276
284
|
|