@k-l-lambda/lilylet 0.1.73 → 0.1.75
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 +180 -175
- package/lib/lilylet/highlight.js +1 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayout.d.ts +68 -0
- package/lib/lilylet/measureLayout.js +412 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +292 -86
- package/lib/lilylet/musicXmlDecoder.d.ts +12 -2
- package/lib/lilylet/musicXmlDecoder.js +498 -67
- package/lib/lilylet/musicXmlTypes.d.ts +11 -1
- package/lib/lilylet/serializer.js +124 -10
- package/lib/lilylet/types.d.ts +3 -0
- package/lib/measureLayout.d.ts +1 -0
- package/lib/measureLayout.js +1 -0
- package/lib/measureLayoutFromXml.d.ts +1 -0
- package/lib/measureLayoutFromXml.js +1 -0
- package/package.json +6 -2
- package/source/lilylet/grammar.jison.js +180 -175
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/measureLayout.ts +460 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +280 -87
- package/source/lilylet/musicXmlDecoder.ts +494 -67
- package/source/lilylet/musicXmlTypes.ts +14 -1
- package/source/lilylet/serializer.ts +118 -10
- package/source/lilylet/types.ts +6 -0
|
@@ -69,13 +69,14 @@ export interface MusicXmlNote {
|
|
|
69
69
|
isChord: boolean; // Has <chord/> tag
|
|
70
70
|
isRest: boolean; // Has <rest/> tag
|
|
71
71
|
isGrace: boolean; // Has <grace/> tag
|
|
72
|
+
isMeasureRest?: boolean; // <rest measure="yes"> — fills the whole measure
|
|
72
73
|
pitch?: MusicXmlPitch;
|
|
73
74
|
duration: MusicXmlDuration;
|
|
74
75
|
voice: number; // Voice number (1-based)
|
|
75
76
|
staff?: number; // Staff number (for cross-staff)
|
|
76
77
|
stem?: MusicXmlStemDirection;
|
|
77
78
|
notations?: MusicXmlNotations;
|
|
78
|
-
|
|
79
|
+
fingerings?: number[]; // one per chord member; a single note has at most one
|
|
79
80
|
beams?: { type: 'begin' | 'continue' | 'end'; number: number }[];
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -123,6 +124,17 @@ export interface MusicXmlDirection {
|
|
|
123
124
|
octaveShift?: MusicXmlOctaveShift;
|
|
124
125
|
coda?: boolean;
|
|
125
126
|
segno?: boolean;
|
|
127
|
+
// Navigation jumps from the <sound> sibling inside <direction>. Present when
|
|
128
|
+
// the score carries D.C./D.S./Coda/Fine playback semantics; drives the
|
|
129
|
+
// performance-order (measure-layout) derivation, not a notational glyph.
|
|
130
|
+
sound?: {
|
|
131
|
+
dacapo?: boolean; // <sound dacapo="yes"> — da capo
|
|
132
|
+
dalsegno?: string; // <sound dalsegno="N"> — dal segno (target segno id)
|
|
133
|
+
segno?: string; // <sound segno="N"> — marks a segno point
|
|
134
|
+
coda?: string; // <sound coda="N"> — marks a coda point
|
|
135
|
+
tocoda?: string; // <sound tocoda="N"> — "to coda" jump
|
|
136
|
+
fine?: boolean; // <sound fine="yes"> — Fine (stop point on D.C./D.S.)
|
|
137
|
+
};
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
// ============ Barline Types ============
|
|
@@ -132,6 +144,7 @@ export interface MusicXmlBarline {
|
|
|
132
144
|
barStyle?: string; // regular, light-light, light-heavy, heavy-light, heavy-heavy, etc.
|
|
133
145
|
repeat?: {
|
|
134
146
|
direction: 'forward' | 'backward';
|
|
147
|
+
times?: number; // <repeat times="N"> — repeat count (default 2 for backward)
|
|
135
148
|
};
|
|
136
149
|
ending?: {
|
|
137
150
|
type: 'start' | 'stop' | 'discontinue';
|
|
@@ -36,6 +36,40 @@ import {
|
|
|
36
36
|
Placement,
|
|
37
37
|
Metadata,
|
|
38
38
|
} from "./types";
|
|
39
|
+
import { parseMeasureLayout, MeasureLayout } from "./measureLayout";
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// Walk a measure-layout AST, recording the FIRST measure index of each alternate
|
|
43
|
+
// ending (1st/2nd house) → its ending number, for the `% volta N` serializer
|
|
44
|
+
// comment convention. Only the alternates of a VoltaMLayout produce labels.
|
|
45
|
+
const collectVoltaLabels = (layout: MeasureLayout, out: Map<number, number>): void => {
|
|
46
|
+
const firstIndexOf = (node: MeasureLayout): number | undefined => {
|
|
47
|
+
switch (node.kind) {
|
|
48
|
+
case "single": return node.measure;
|
|
49
|
+
case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
|
|
50
|
+
case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
|
|
51
|
+
case "aba": return firstIndexOf(node.main);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const walk = (node: MeasureLayout): void => {
|
|
55
|
+
switch (node.kind) {
|
|
56
|
+
case "block": node.seq.forEach(walk); break;
|
|
57
|
+
case "aba": walk(node.main); node.rest.forEach(walk); break;
|
|
58
|
+
case "volta":
|
|
59
|
+
node.body.forEach(walk);
|
|
60
|
+
if (node.alternates) {
|
|
61
|
+
node.alternates.forEach((alt, ai) => {
|
|
62
|
+
const first = alt.length ? firstIndexOf(alt[0]) : undefined;
|
|
63
|
+
if (first !== undefined && !out.has(first)) out.set(first, ai + 1);
|
|
64
|
+
alt.forEach(walk);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
default: break;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(layout);
|
|
72
|
+
};
|
|
39
73
|
|
|
40
74
|
|
|
41
75
|
const PHONETS = "cdefgab";
|
|
@@ -483,9 +517,20 @@ const serializeTupletEvent = (
|
|
|
483
517
|
prevDuration = (e as RestEvent).duration;
|
|
484
518
|
} else if (e.type === 'context') {
|
|
485
519
|
const ctx = e as ContextChange;
|
|
520
|
+
// A context event inside a tuplet may carry several components at once
|
|
521
|
+
// (e.g. staff + clef). Emit each present one — previously only staff and
|
|
522
|
+
// stemDirection were handled, so a clef/ottava change inside a tuplet was
|
|
523
|
+
// silently dropped on serialization.
|
|
486
524
|
if (ctx.staff != null) {
|
|
487
525
|
parts.push(' \\staff "' + ctx.staff + '"');
|
|
488
|
-
}
|
|
526
|
+
}
|
|
527
|
+
if (ctx.clef != null) {
|
|
528
|
+
parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
|
|
529
|
+
}
|
|
530
|
+
if (ctx.ottava != null) {
|
|
531
|
+
parts.push(' \\ottava #' + ctx.ottava);
|
|
532
|
+
}
|
|
533
|
+
if (ctx.stemDirection != null) {
|
|
489
534
|
if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
|
|
490
535
|
else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
|
|
491
536
|
else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
|
|
@@ -654,6 +699,28 @@ const serializeVoice = (
|
|
|
654
699
|
// context{staff} events before this index are skipped in the main loop.
|
|
655
700
|
let effectiveInitialStaff = voice.staff;
|
|
656
701
|
let leadStaffScanEnd = 0;
|
|
702
|
+
// Staff of the first MUSICAL event (note/rest/...), honouring an explicit
|
|
703
|
+
// note.staff. If it differs from the home staff, the first inline staff switch
|
|
704
|
+
// would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
|
|
705
|
+
// binds home to the first `\staff` before any music), relocating the voice and
|
|
706
|
+
// any leading clef. We then force an explicit home-staff anchor up front. This
|
|
707
|
+
// scan is independent of leadStaffScanEnd because a leading clef/key stops that
|
|
708
|
+
// scan before the first note is reached.
|
|
709
|
+
let firstMusicalStaff: number | null = null;
|
|
710
|
+
{
|
|
711
|
+
let scanStaff = voice.staff;
|
|
712
|
+
for (const e of voice.events) {
|
|
713
|
+
if (e.type === 'context') {
|
|
714
|
+
const ctx = e as ContextChange;
|
|
715
|
+
if (ctx.staff != null) scanStaff = ctx.staff;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (e.type === 'pitchReset') continue;
|
|
719
|
+
if (e.type === 'note') firstMusicalStaff = (e as NoteEvent).staff ?? scanStaff;
|
|
720
|
+
else firstMusicalStaff = scanStaff;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
657
724
|
for (let i = 0; i < voice.events.length; i++) {
|
|
658
725
|
const e = voice.events[i];
|
|
659
726
|
if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
|
|
@@ -678,10 +745,19 @@ const serializeVoice = (
|
|
|
678
745
|
break;
|
|
679
746
|
}
|
|
680
747
|
|
|
681
|
-
// Output staff command if voice staff
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
748
|
+
// Output staff command if the voice's initial staff is not what the PARSER will
|
|
749
|
+
// assume. The parser (lilylet.jison voice()) assigns every voice with no leading
|
|
750
|
+
// `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
|
|
751
|
+
// voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
|
|
752
|
+
// is not 1, even if the cross-measure carry (currentStaff) happens to match it
|
|
753
|
+
// (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
|
|
754
|
+
// this, the round-trip would silently relocate that voice, and its leading clef,
|
|
755
|
+
// to staff 1). Also emit when it differs from the carry (resets after grand staff)
|
|
756
|
+
// or always in a grand-staff part for clarity. Finally, force an anchor when the
|
|
757
|
+
// first note switches staff via note.staff (otherwise that inline `\staff` would
|
|
758
|
+
// be read as the leading staff and rebind the voice's home).
|
|
759
|
+
const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
|
|
760
|
+
if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
|
|
685
761
|
parts.push('\\staff "' + effectiveInitialStaff + '"');
|
|
686
762
|
}
|
|
687
763
|
|
|
@@ -710,7 +786,17 @@ const serializeVoice = (
|
|
|
710
786
|
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
711
787
|
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
712
788
|
// hoisted here — it is emitted inline at its position.
|
|
713
|
-
|
|
789
|
+
//
|
|
790
|
+
// The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
|
|
791
|
+
// the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
|
|
792
|
+
// with no clef of its own must only use the fallback to ESTABLISH a staff that has
|
|
793
|
+
// not been emitted yet — never to "restate" it once any sibling voice has already
|
|
794
|
+
// emitted/changed this staff's clef this measure, because the fallback would then be
|
|
795
|
+
// stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
|
|
796
|
+
// treble, then v2 with no clef must not re-emit the carried-in bass).
|
|
797
|
+
const ownClef = findVoiceClef(voice);
|
|
798
|
+
const carryClef = allStaffClefs?.[voice.staff];
|
|
799
|
+
const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
|
|
714
800
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
715
801
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
716
802
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
@@ -734,7 +820,8 @@ const serializeVoice = (
|
|
|
734
820
|
activeStaff = ctx.staff;
|
|
735
821
|
parts.push('\\staff "' + activeStaff + '"');
|
|
736
822
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
737
|
-
const ctxClef = ctx.clef ||
|
|
823
|
+
const ctxClef = ctx.clef ||
|
|
824
|
+
(emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
|
|
738
825
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
739
826
|
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
740
827
|
if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
|
|
@@ -763,7 +850,8 @@ const serializeVoice = (
|
|
|
763
850
|
activeStaff = effectiveStaff;
|
|
764
851
|
parts.push('\\staff "' + activeStaff + '"');
|
|
765
852
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
766
|
-
const targetClef =
|
|
853
|
+
const targetClef =
|
|
854
|
+
emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
|
|
767
855
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
768
856
|
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
769
857
|
if (emittedClefs) emittedClefs[activeStaff] = targetClef;
|
|
@@ -954,6 +1042,9 @@ const serializeMetadata = (metadata: Metadata): string => {
|
|
|
954
1042
|
if (metadata.staves) {
|
|
955
1043
|
lines.push('[staves "' + escapeString(metadata.staves) + '"]');
|
|
956
1044
|
}
|
|
1045
|
+
if (metadata.measureLayout) {
|
|
1046
|
+
lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
|
|
1047
|
+
}
|
|
957
1048
|
if (metadata.instruments) {
|
|
958
1049
|
for (const [key, instr] of Object.entries(metadata.instruments)) {
|
|
959
1050
|
let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
|
|
@@ -1048,9 +1139,26 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
1048
1139
|
currentStaff = newStaff;
|
|
1049
1140
|
}
|
|
1050
1141
|
|
|
1051
|
-
//
|
|
1142
|
+
// Volta house-label comments (non-binding convention, like the `% n` measure
|
|
1143
|
+
// index): if a measure-layout directive is present, mark the first measure of
|
|
1144
|
+
// each alternate ending with a `% volta N` comment. Derived from the layout
|
|
1145
|
+
// AST; purely cosmetic, never parsed back.
|
|
1146
|
+
const voltaLabels = new Map<number, number>(); // 1-based measure index → ending number
|
|
1147
|
+
if (doc.metadata?.measureLayout) {
|
|
1148
|
+
try {
|
|
1149
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
1150
|
+
collectVoltaLabels(layout, voltaLabels);
|
|
1151
|
+
} catch { /* ignore malformed layout */ }
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Join measures with bar, measure number comment, and double newline. A
|
|
1155
|
+
// `% volta N` label (if any) is prepended on its own line before the measure.
|
|
1052
1156
|
const measuresOutput = measureStrs
|
|
1053
|
-
.map((m, i) =>
|
|
1157
|
+
.map((m, i) => {
|
|
1158
|
+
const label = voltaLabels.get(i + 1);
|
|
1159
|
+
const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
|
|
1160
|
+
return prefix + m + ' | %' + (i + 1);
|
|
1161
|
+
})
|
|
1054
1162
|
.join('\n\n');
|
|
1055
1163
|
parts.push(measuresOutput);
|
|
1056
1164
|
|
package/source/lilylet/types.ts
CHANGED
|
@@ -157,6 +157,7 @@ export interface Tie {
|
|
|
157
157
|
export interface Slur {
|
|
158
158
|
markType: 'slur';
|
|
159
159
|
start: boolean;
|
|
160
|
+
number?: number; // MusicXML slur number, for pairing cross-voice/overlapping slurs (encoder hint only)
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
export interface Beam {
|
|
@@ -223,6 +224,7 @@ export interface RestEvent {
|
|
|
223
224
|
invisible?: boolean; // space rest (s)
|
|
224
225
|
fullMeasure?: boolean; // full measure rest (R)
|
|
225
226
|
pitch?: Pitch; // positioned rest (e.g., g'\rest)
|
|
227
|
+
marks?: Mark[]; // control-event marks a rest can host (e.g. fermata over a rest / grand pause)
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
export interface ContextChange {
|
|
@@ -302,6 +304,10 @@ export interface Metadata {
|
|
|
302
304
|
instrument?: string;
|
|
303
305
|
genre?: string;
|
|
304
306
|
staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
|
|
307
|
+
// Raw measure-layout (performance/repeat order) DSL, lotus-style, e.g.
|
|
308
|
+
// "2*[1..8]{9,10}, 11..16" (index-wise) or "s: 4 <2 6> 2" (segment-wise).
|
|
309
|
+
// Stored verbatim; parsing/expansion to MEI <expansion> is a later phase.
|
|
310
|
+
measureLayout?: string;
|
|
305
311
|
// Per-staff / per-group instrument names, keyed by staff-layout group key (a single
|
|
306
312
|
// staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
|
|
307
313
|
// [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.
|