@k-l-lambda/lilylet 0.1.59 → 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 +194 -33
- 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 +80 -10
- 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 +174 -36
- 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 +73 -11
- 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':
|
|
@@ -607,10 +626,41 @@ const serializeVoice = (
|
|
|
607
626
|
// Each voice starts fresh from middle C (step=0, octave=0)
|
|
608
627
|
let pitchEnv: PitchEnv = { step: 0, octave: 0 };
|
|
609
628
|
|
|
629
|
+
// Scan leading context-staff events (before the first musical event or clef/ottava)
|
|
630
|
+
// to compute the effective initial staff. Multiple consecutive staff switches
|
|
631
|
+
// before any music collapse to the last one (earlier ones are no-ops).
|
|
632
|
+
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
633
|
+
// context{staff} events before this index are skipped in the main loop.
|
|
634
|
+
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
635
|
+
let effectiveInitialStaff = voice.staff;
|
|
636
|
+
let leadStaffScanEnd = 0;
|
|
637
|
+
for (let i = 0; i < voice.events.length; i++) {
|
|
638
|
+
const e = voice.events[i];
|
|
639
|
+
if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
|
|
640
|
+
if (e.type === 'context') {
|
|
641
|
+
const ctx = e as ContextChange;
|
|
642
|
+
if (ctx.staff != null) {
|
|
643
|
+
effectiveInitialStaff = ctx.staff;
|
|
644
|
+
if (!ctx.clef && !ctx.ottava) {
|
|
645
|
+
// Pure staff-only event — absorb
|
|
646
|
+
leadStaffScanEnd = i + 1;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// Compound (staff + clef/ottava) — update effectiveInitialStaff but stop scan
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
if (ctx.clef || ctx.ottava) break; // musical context — stop scan
|
|
653
|
+
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (MUSICAL_TYPES.has(e.type)) break;
|
|
657
|
+
}
|
|
658
|
+
|
|
610
659
|
// Output staff command if voice staff differs from current parser staff,
|
|
611
|
-
// or always output if it's a grand staff score for clarity
|
|
612
|
-
|
|
613
|
-
|
|
660
|
+
// or always output if it's a grand staff score for clarity.
|
|
661
|
+
// Use effectiveInitialStaff so carry-over replaces the default emission.
|
|
662
|
+
if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
|
|
663
|
+
parts.push('\\staff "' + effectiveInitialStaff + '"');
|
|
614
664
|
}
|
|
615
665
|
|
|
616
666
|
// Output key/time signatures after \staff (for first voice only)
|
|
@@ -644,10 +694,15 @@ const serializeVoice = (
|
|
|
644
694
|
// Skip redundant clef context events if this staff's clef is already established
|
|
645
695
|
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
646
696
|
|
|
647
|
-
let activeStaff =
|
|
697
|
+
let activeStaff = effectiveInitialStaff;
|
|
648
698
|
let activeStemDir: StemDirection | undefined;
|
|
649
699
|
|
|
650
|
-
for (
|
|
700
|
+
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
701
|
+
const event = voice.events[eventIdx];
|
|
702
|
+
// Skip leading context-staff events already absorbed into effectiveInitialStaff
|
|
703
|
+
if (eventIdx < leadStaffScanEnd && event.type === 'context' && (event as ContextChange).staff != null) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
651
706
|
if (event.type === 'context') {
|
|
652
707
|
const ctx = event as ContextChange;
|
|
653
708
|
// Cross-staff context: update activeStaff and emit \staff directive
|
|
@@ -662,7 +717,8 @@ const serializeVoice = (
|
|
|
662
717
|
}
|
|
663
718
|
continue;
|
|
664
719
|
}
|
|
665
|
-
if (ctx.staff) continue; // same staff, no-op
|
|
720
|
+
if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
|
|
721
|
+
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
666
722
|
// Skip clef-only context events if clef already established for this staff
|
|
667
723
|
if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
|
|
668
724
|
continue;
|
|
@@ -712,6 +768,12 @@ const serializeVoice = (
|
|
|
712
768
|
prevDuration = (event as NoteEvent).duration;
|
|
713
769
|
} else if (event.type === 'rest') {
|
|
714
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;
|
|
715
777
|
} else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
|
|
716
778
|
const ctx = event as ContextChange;
|
|
717
779
|
emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
|
|
@@ -799,7 +861,7 @@ const serializeMeasure = (
|
|
|
799
861
|
}
|
|
800
862
|
staff = newStaff;
|
|
801
863
|
}
|
|
802
|
-
parts.push(partStrs.join('
|
|
864
|
+
parts.push(partStrs.join(' \\\\\\\n'));
|
|
803
865
|
}
|
|
804
866
|
|
|
805
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
|
|