@k-l-lambda/lilylet 0.1.35 → 0.1.36
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/README.md +30 -25
- package/lib/grammar.jison.js +16 -5
- package/lib/meiEncoder.js +114 -25
- package/lib/musicXmlDecoder.js +4 -4
- package/lib/serializer.js +86 -20
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +16 -5
- package/source/lilylet/lilylet.jison +16 -5
- package/source/lilylet/meiEncoder.ts +130 -25
- package/source/lilylet/musicXmlDecoder.ts +4 -4
- package/source/lilylet/serializer.ts +111 -21
package/README.md
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
# Lilylet
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
Lilylet is a LilyPond-like music notation language designed for Markdown rendering and symbolic music representation in AIGC applications.
|
|
4
6
|
|
|
7
|
+
## Hello World
|
|
8
|
+
|
|
9
|
+
```lyl
|
|
10
|
+
\key c \major \time 4/4 \clef "treble" c1 \bar "|."
|
|
11
|
+
```
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## A Comprehensive Example
|
|
16
|
+
|
|
17
|
+
```lyl
|
|
18
|
+
[title "Jesu, meine Freude"]
|
|
19
|
+
[subtitle "BWV 610"]
|
|
20
|
+
[composer "J.S. Bach"]
|
|
21
|
+
|
|
22
|
+
\staff "1" \key c \minor \time 4/4 \clef "treble" \stemUp g'4 g f ef \\
|
|
23
|
+
\staff "1" \stemDown ef16[ d ef8]~ ef16[ f ef d] c8[ d]~ d[ c] \\
|
|
24
|
+
\staff "2" \clef "bass" c16[ b c8]~ c16[ b c g] a8[ g]~ g16[ g af ef] \\
|
|
25
|
+
\staff "3" \clef "bass" r8 c,16[ d] ef[ d ef8]~ ef16[ a, b g] c[ b c8] | % 1
|
|
26
|
+
|
|
27
|
+
\staff "1" \stemUp d2 c\fermata \\
|
|
28
|
+
\staff "1" \stemDown c8[ c4 b8] c8.[ \staff "2" \stemUp g16] \staff "1" c[ b c d] \\
|
|
29
|
+
\staff "2" f,16[ ef f d] g[ af g f] ef[ d ef8]~ ef16[ f ef d] \\
|
|
30
|
+
\staff "3" r16 g,[ af f] g[ f g8] c,2 | % 2
|
|
31
|
+
```
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
|
|
5
35
|
## Try It Online
|
|
6
36
|
|
|
7
37
|
- [Live Editor](https://k-l-lambda.github.io/lilylet-live-editor/) - Interactive editor with real-time music notation rendering
|
|
@@ -33,28 +63,3 @@ LilyPond is powerful but overly flexible—the same music can be written in mult
|
|
|
33
63
|
- **Shorter context description**: Removes redundant information, allowing LLMs to process more music content within limited context windows
|
|
34
64
|
- **Formatted layout**: Fixed syntax structure facilitates model learning and generation
|
|
35
65
|
- **Markdown-embeddable**: Music snippets can be directly embedded in documents
|
|
36
|
-
|
|
37
|
-
### Basic Syntax
|
|
38
|
-
|
|
39
|
-
| Element | Syntax | Description |
|
|
40
|
-
|---------|--------|-------------|
|
|
41
|
-
| Staff | `\staff "1"` | Specifies which staff the current voice belongs to |
|
|
42
|
-
| Key | `\key c \major` | C major |
|
|
43
|
-
| Time | `\time 4/4` | 4/4 time signature |
|
|
44
|
-
| Clef | `\clef "treble"` | Treble clef |
|
|
45
|
-
| Notes | `c4 d8 e16` | C quarter note, D eighth note, E sixteenth note |
|
|
46
|
-
| Accidentals | `cs` `cf` `css` `cff` | C sharp, C flat, C double-sharp, C double-flat |
|
|
47
|
-
| Octave | `c'` `c,` | One octave higher, one octave lower |
|
|
48
|
-
| Chord | `<c e g>4` | C major triad, quarter note |
|
|
49
|
-
| Voice separator | `\\` | Separates multiple voices within the same staff |
|
|
50
|
-
| Part separator | `\\\` | Separates different instrument tracks (parts) in a score |
|
|
51
|
-
| Bar line | `\|` | Separates measures |
|
|
52
|
-
|
|
53
|
-
## Syntax Example
|
|
54
|
-
|
|
55
|
-
```lilylet
|
|
56
|
-
\staff "1" \key e \major \time 2/4 \clef "treble" \stemUp e8 [ ds16 e16 ] fs4 ~ \\
|
|
57
|
-
\staff "1" s4 \stemDown ds4 ~ \\
|
|
58
|
-
\staff "2" \key e \major \clef "bass" \stemUp e,,4 b4 \\
|
|
59
|
-
\staff "2" \stemDown e,,16 [ b'8 -> b16 ] b,16 [ b'8 -> b16 ] |
|
|
60
|
-
```
|
package/lib/grammar.jison.js
CHANGED
|
@@ -150,7 +150,7 @@ case 25:
|
|
|
150
150
|
this.$ = $$[$0-3].concat([part($$[$0])]);
|
|
151
151
|
break;
|
|
152
152
|
case 26:
|
|
153
|
-
currentStaff = 1;
|
|
153
|
+
currentStaff = 1; currentOttava = 0;
|
|
154
154
|
break;
|
|
155
155
|
case 27:
|
|
156
156
|
this.$ = [voice(currentStaff, $$[$0])];
|
|
@@ -174,7 +174,16 @@ case 43:
|
|
|
174
174
|
this.$ = markupEvent($$[$0].slice(1, -1));
|
|
175
175
|
break;
|
|
176
176
|
case 44:
|
|
177
|
-
|
|
177
|
+
|
|
178
|
+
// On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
|
|
179
|
+
if (currentOttava !== 0) {
|
|
180
|
+
const ottavaReset = contextChange({ ottava: 0 });
|
|
181
|
+
currentOttava = 0;
|
|
182
|
+
this.$ = [ottavaReset, { type: 'pitchReset' }];
|
|
183
|
+
} else {
|
|
184
|
+
this.$ = { type: 'pitchReset' };
|
|
185
|
+
}
|
|
186
|
+
|
|
178
187
|
break;
|
|
179
188
|
case 45: case 46:
|
|
180
189
|
currentDuration = $$[$0-1]; this.$ = noteEvent($$[$0-2], $$[$0-1], $$[$0]);
|
|
@@ -299,13 +308,13 @@ case 85:
|
|
|
299
308
|
currentStaff = Number($$[$0].slice(1, -1)); this.$ = currentStaff;
|
|
300
309
|
break;
|
|
301
310
|
case 86:
|
|
302
|
-
|
|
311
|
+
currentOttava = Number($$[$0]); this.$ = currentOttava;
|
|
303
312
|
break;
|
|
304
313
|
case 87:
|
|
305
|
-
|
|
314
|
+
currentOttava = -Number($$[$0]); this.$ = currentOttava;
|
|
306
315
|
break;
|
|
307
316
|
case 88:
|
|
308
|
-
this.$ = 0;
|
|
317
|
+
currentOttava = 0; this.$ = 0;
|
|
309
318
|
break;
|
|
310
319
|
case 89:
|
|
311
320
|
this.$ = 'up';
|
|
@@ -737,6 +746,7 @@ parse: function parse(input) {
|
|
|
737
746
|
let currentTimeSig = null;
|
|
738
747
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
739
748
|
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
749
|
+
let currentOttava = 0; // Current ottava level, resets on newline
|
|
740
750
|
|
|
741
751
|
// Reset parser state - call before each parse
|
|
742
752
|
const resetParserState = () => {
|
|
@@ -745,6 +755,7 @@ parse: function parse(input) {
|
|
|
745
755
|
currentTimeSig = null;
|
|
746
756
|
currentDuration = { division: 4, dots: 0 };
|
|
747
757
|
numericTimeSignature = false;
|
|
758
|
+
currentOttava = 0;
|
|
748
759
|
};
|
|
749
760
|
|
|
750
761
|
// Export reset function
|
package/lib/meiEncoder.js
CHANGED
|
@@ -618,7 +618,7 @@ const getEventBeamMarks = (event) => {
|
|
|
618
618
|
return { beamStart: false, beamEnd: false };
|
|
619
619
|
};
|
|
620
620
|
// Encode a layer (voice)
|
|
621
|
-
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null) => {
|
|
621
|
+
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
|
|
622
622
|
const layerId = generateId("layer");
|
|
623
623
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
624
624
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
@@ -630,12 +630,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
630
630
|
let currentHairpin = initialHairpin;
|
|
631
631
|
// Track pedal marks (each is independent, not paired spans)
|
|
632
632
|
const pedals = [];
|
|
633
|
-
// Track octave spans
|
|
633
|
+
// Track octave spans - initialize from previous measure if continuing
|
|
634
634
|
const octaves = [];
|
|
635
|
-
let currentOctave = null;
|
|
635
|
+
let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
|
|
636
636
|
let pendingOttava = null; // Track ottava to apply to next note
|
|
637
|
-
let currentOttavaShift = 0; // Track current ottava shift for pitch encoding
|
|
637
|
+
let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
638
638
|
let lastNoteId = null; // Track last note id for ending ottava spans
|
|
639
|
+
let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
|
|
639
640
|
// Track slur spans - slurs must be encoded as control events in MEI
|
|
640
641
|
const slurs = [];
|
|
641
642
|
let currentSlur = initialSlur ? { startId: initialSlur } : null;
|
|
@@ -697,7 +698,17 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
697
698
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
698
699
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
699
700
|
const disPlace = pendingOttava > 0 ? 'above' : 'below';
|
|
700
|
-
|
|
701
|
+
// Close existing span first if it has a different value
|
|
702
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
703
|
+
// Different value - close the old span
|
|
704
|
+
// Use the lastNoteId from before this note (which we saved before processing)
|
|
705
|
+
// Note: The span from previous measure will be closed by encodeMeasure
|
|
706
|
+
currentOctave = null;
|
|
707
|
+
}
|
|
708
|
+
// Start new span if we don't already have one with the same value
|
|
709
|
+
if (!currentOctave) {
|
|
710
|
+
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
711
|
+
}
|
|
701
712
|
pendingOttava = null;
|
|
702
713
|
}
|
|
703
714
|
// Update pending tie pitches
|
|
@@ -835,12 +846,25 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
835
846
|
endId: lastNoteId,
|
|
836
847
|
});
|
|
837
848
|
currentOctave = null;
|
|
849
|
+
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
838
850
|
}
|
|
839
|
-
|
|
851
|
+
// Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
|
|
852
|
+
// It may be continued by a subsequent ottava command with the same value
|
|
853
|
+
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
840
854
|
}
|
|
841
855
|
else {
|
|
842
|
-
//
|
|
843
|
-
|
|
856
|
+
// Check if this continues an existing span (same value)
|
|
857
|
+
const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
|
|
858
|
+
const disPlace = ctx.ottava > 0 ? 'above' : 'below';
|
|
859
|
+
if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
|
|
860
|
+
// Continuation - restore the shift but don't change the span
|
|
861
|
+
currentOttavaShift = ctx.ottava;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
// Different value - start new ottava span (will be applied to next note)
|
|
865
|
+
// If there's an existing span with different value, it will be closed when the note is processed
|
|
866
|
+
pendingOttava = ctx.ottava;
|
|
867
|
+
}
|
|
844
868
|
}
|
|
845
869
|
}
|
|
846
870
|
// Check for stem direction changes
|
|
@@ -889,20 +913,16 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
889
913
|
if (beamElementOpen) {
|
|
890
914
|
xml += `${baseIndent}</beam>\n`;
|
|
891
915
|
}
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
startId: currentOctave.startId,
|
|
898
|
-
endId: lastNoteId,
|
|
899
|
-
});
|
|
900
|
-
}
|
|
916
|
+
// Don't close ottava span at measure end - it may continue in the next measure
|
|
917
|
+
// Build pending octave state to return
|
|
918
|
+
const pendingOctave = currentOctave
|
|
919
|
+
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
|
|
920
|
+
: null;
|
|
901
921
|
xml += `${indent}</layer>\n`;
|
|
902
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, endingClef: currentClef };
|
|
922
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
|
|
903
923
|
};
|
|
904
924
|
// Encode a staff
|
|
905
|
-
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, keyFifths = 0, initialClef) => {
|
|
925
|
+
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
|
|
906
926
|
const staffId = generateId("staff");
|
|
907
927
|
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
908
928
|
const allHairpins = [];
|
|
@@ -923,6 +943,9 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
923
943
|
const pendingTies = {};
|
|
924
944
|
const pendingSlurs = {};
|
|
925
945
|
const pendingHairpins = {};
|
|
946
|
+
const pendingOctaves = {};
|
|
947
|
+
const ottavaExplicitlyClosed = {};
|
|
948
|
+
const lastNoteIds = {};
|
|
926
949
|
let endingClef = initialClef;
|
|
927
950
|
if (voices.length === 0) {
|
|
928
951
|
xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
|
|
@@ -934,7 +957,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
934
957
|
const initialTies = tieState[tieKey] || [];
|
|
935
958
|
const initialSlur = slurState[tieKey] || null;
|
|
936
959
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
937
|
-
const
|
|
960
|
+
const initialOctave = ottavaState[tieKey] || null;
|
|
961
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
938
962
|
xml += result.xml;
|
|
939
963
|
allHairpins.push(...result.hairpins);
|
|
940
964
|
allPedals.push(...result.pedals);
|
|
@@ -963,6 +987,16 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
963
987
|
if (result.pendingHairpin) {
|
|
964
988
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
965
989
|
}
|
|
990
|
+
// Track pending ottava spans for this layer
|
|
991
|
+
if (result.pendingOctave) {
|
|
992
|
+
pendingOctaves[tieKey] = result.pendingOctave;
|
|
993
|
+
}
|
|
994
|
+
// Track if ottava was explicitly closed in this layer
|
|
995
|
+
if (result.ottavaExplicitlyClosed) {
|
|
996
|
+
ottavaExplicitlyClosed[tieKey] = true;
|
|
997
|
+
}
|
|
998
|
+
// Track last note IDs for this layer (for closing ottava spans)
|
|
999
|
+
lastNoteIds[tieKey] = result.lastNoteId;
|
|
966
1000
|
// Track ending clef for cross-measure tracking
|
|
967
1001
|
if (result.endingClef) {
|
|
968
1002
|
endingClef = result.endingClef;
|
|
@@ -990,6 +1024,9 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
990
1024
|
pendingTies,
|
|
991
1025
|
pendingSlurs,
|
|
992
1026
|
pendingHairpins,
|
|
1027
|
+
pendingOctaves,
|
|
1028
|
+
ottavaExplicitlyClosed,
|
|
1029
|
+
lastNoteIds,
|
|
993
1030
|
endingClef,
|
|
994
1031
|
};
|
|
995
1032
|
};
|
|
@@ -1030,8 +1067,8 @@ const BARLINE_TO_MEI = {
|
|
|
1030
1067
|
':..:': 'rptboth',
|
|
1031
1068
|
};
|
|
1032
1069
|
// Encode a measure
|
|
1033
|
-
// encodeMeasure accepts mutable tieState, slurState, hairpinState and clefState that persist across measures
|
|
1034
|
-
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, keyFifths = 0, partInfos = [], clefState = {}) => {
|
|
1070
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1071
|
+
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
|
|
1035
1072
|
const measureId = generateId("measure");
|
|
1036
1073
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1037
1074
|
const allHairpins = [];
|
|
@@ -1083,11 +1120,11 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1083
1120
|
voicesByStaff[globalStaff].push(voice);
|
|
1084
1121
|
}
|
|
1085
1122
|
}
|
|
1086
|
-
// Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
|
|
1123
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1087
1124
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1088
1125
|
const voices = voicesByStaff[si] || [];
|
|
1089
1126
|
const initialClef = clefState[si];
|
|
1090
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
|
|
1127
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1091
1128
|
staffContent += result.xml;
|
|
1092
1129
|
allHairpins.push(...result.hairpins);
|
|
1093
1130
|
allPedals.push(...result.pedals);
|
|
@@ -1110,6 +1147,56 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1110
1147
|
Object.assign(slurState, result.pendingSlurs);
|
|
1111
1148
|
// Update hairpin state with pending hairpins from this staff
|
|
1112
1149
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1150
|
+
// Update ottava state with pending octaves from this staff
|
|
1151
|
+
// Also handle closing spans when ottava ends
|
|
1152
|
+
const currentStaffPrefix = `${si}-`;
|
|
1153
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1154
|
+
if (pending) {
|
|
1155
|
+
// Check if this is a continuation or a new span
|
|
1156
|
+
const prevPending = ottavaState[key];
|
|
1157
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1158
|
+
// Same ottava value continues - keep the original startId
|
|
1159
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
// Different ottava value - close the old span first if exists
|
|
1163
|
+
if (prevPending) {
|
|
1164
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1165
|
+
if (lastNoteId) {
|
|
1166
|
+
allOctaves.push({
|
|
1167
|
+
dis: prevPending.dis,
|
|
1168
|
+
disPlace: prevPending.disPlace,
|
|
1169
|
+
startId: prevPending.startId,
|
|
1170
|
+
endId: lastNoteId,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
// Start new span
|
|
1175
|
+
ottavaState[key] = pending;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1180
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1181
|
+
// Only process keys for the current staff
|
|
1182
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1183
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1184
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1185
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1186
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1187
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1188
|
+
if (lastNoteId) {
|
|
1189
|
+
allOctaves.push({
|
|
1190
|
+
dis: pending.dis,
|
|
1191
|
+
disPlace: pending.disPlace,
|
|
1192
|
+
startId: pending.startId,
|
|
1193
|
+
endId: lastNoteId,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
delete ottavaState[key];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1113
1200
|
// Update clef state with ending clef from this staff
|
|
1114
1201
|
if (result.endingClef) {
|
|
1115
1202
|
clefState[si] = result.endingClef;
|
|
@@ -1344,6 +1431,8 @@ const encode = (doc, options = {}) => {
|
|
|
1344
1431
|
const slurState = {};
|
|
1345
1432
|
// Track hairpin state across measures for cross-measure hairpins
|
|
1346
1433
|
const hairpinState = {};
|
|
1434
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1435
|
+
const ottavaState = {};
|
|
1347
1436
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1348
1437
|
const clefState = {};
|
|
1349
1438
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1399,7 +1488,7 @@ const encode = (doc, options = {}) => {
|
|
|
1399
1488
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1400
1489
|
}
|
|
1401
1490
|
}
|
|
1402
|
-
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1491
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1403
1492
|
});
|
|
1404
1493
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
1405
1494
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
package/lib/musicXmlDecoder.js
CHANGED
|
@@ -727,13 +727,13 @@ const directionToContextChange = (direction, ottavaTracker) => {
|
|
|
727
727
|
ottava = 0;
|
|
728
728
|
ottavaTracker.current = 0;
|
|
729
729
|
}
|
|
730
|
-
else if (type === '
|
|
731
|
-
// 8va = 1, 15ma = 2
|
|
730
|
+
else if (type === 'down') {
|
|
731
|
+
// 8va = 1, 15ma = 2 (type="down" means written notes sound higher)
|
|
732
732
|
ottava = size === 15 ? 2 : 1;
|
|
733
733
|
ottavaTracker.current = ottava;
|
|
734
734
|
}
|
|
735
|
-
else if (type === '
|
|
736
|
-
// 8vb = -1, 15mb = -2
|
|
735
|
+
else if (type === 'up') {
|
|
736
|
+
// 8vb = -1, 15mb = -2 (type="up" means written notes sound lower)
|
|
737
737
|
ottava = size === 15 ? -2 : -1;
|
|
738
738
|
ottavaTracker.current = ottava;
|
|
739
739
|
}
|
package/lib/serializer.js
CHANGED
|
@@ -406,6 +406,14 @@ const serializeTremoloEvent = (event, env) => {
|
|
|
406
406
|
parts.push(' }');
|
|
407
407
|
return { str: parts.join(''), newEnv: currentEnv };
|
|
408
408
|
};
|
|
409
|
+
// Serialize a barline event
|
|
410
|
+
const serializeBarlineEvent = (event) => {
|
|
411
|
+
// Only output non-default barlines
|
|
412
|
+
if (event.style && event.style !== '|') {
|
|
413
|
+
return '\\bar "' + event.style + '"';
|
|
414
|
+
}
|
|
415
|
+
return '';
|
|
416
|
+
};
|
|
409
417
|
// Serialize a single event with pitch environment tracking
|
|
410
418
|
const serializeEvent = (event, env, prevDuration) => {
|
|
411
419
|
switch (event.type) {
|
|
@@ -419,15 +427,30 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
419
427
|
return serializeTupletEvent(event, env);
|
|
420
428
|
case 'tremolo':
|
|
421
429
|
return serializeTremoloEvent(event, env);
|
|
430
|
+
case 'barline':
|
|
431
|
+
return { str: serializeBarlineEvent(event), newEnv: env };
|
|
422
432
|
default:
|
|
423
433
|
return { str: '', newEnv: env };
|
|
424
434
|
}
|
|
425
435
|
};
|
|
436
|
+
// Find first clef in voice events
|
|
437
|
+
const findVoiceClef = (voice) => {
|
|
438
|
+
for (const event of voice.events) {
|
|
439
|
+
if (event.type === 'context') {
|
|
440
|
+
const ctx = event;
|
|
441
|
+
if (ctx.clef) {
|
|
442
|
+
return ctx.clef;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
};
|
|
426
448
|
// Serialize a voice with pitch environment tracking
|
|
427
449
|
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
428
450
|
// If isGrandStaff is true, always output \staff command for clarity
|
|
429
|
-
//
|
|
430
|
-
|
|
451
|
+
// measureContext provides key/time for first voice
|
|
452
|
+
// staffClef is the clef for this voice's staff (tracked across measures)
|
|
453
|
+
const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, staffClef) => {
|
|
431
454
|
const parts = [];
|
|
432
455
|
let prevDuration;
|
|
433
456
|
// Each voice starts fresh from middle C (step=0, octave=0)
|
|
@@ -437,8 +460,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
437
460
|
if (isGrandStaff || voice.staff !== currentStaff) {
|
|
438
461
|
parts.push('\\staff "' + voice.staff + '"');
|
|
439
462
|
}
|
|
440
|
-
// Output key/time signatures after \staff (for first voice
|
|
441
|
-
if (measureContext) {
|
|
463
|
+
// Output key/time signatures after \staff (for first voice only)
|
|
464
|
+
if (measureContext && isFirstVoice) {
|
|
442
465
|
if (measureContext.key) {
|
|
443
466
|
let keyStr = String(measureContext.key.pitch);
|
|
444
467
|
if (measureContext.key.accidental) {
|
|
@@ -457,7 +480,22 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
457
480
|
parts.push('\\time ' + numerator + '/' + denominator);
|
|
458
481
|
}
|
|
459
482
|
}
|
|
483
|
+
// Output clef for every voice (use staff clef tracked across measures, or find from voice events)
|
|
484
|
+
const voiceClef = staffClef || findVoiceClef(voice);
|
|
485
|
+
if (voiceClef) {
|
|
486
|
+
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
487
|
+
}
|
|
488
|
+
// Track if we've already output the clef to avoid duplication
|
|
489
|
+
let clefOutputted = !!voiceClef;
|
|
460
490
|
for (const event of voice.events) {
|
|
491
|
+
// Skip clef context events if we've already output the clef at the beginning
|
|
492
|
+
if (clefOutputted && event.type === 'context') {
|
|
493
|
+
const ctx = event;
|
|
494
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
|
|
495
|
+
// This is a clef-only context event, skip it
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
461
499
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
462
500
|
pitchEnv = newEnv;
|
|
463
501
|
if (eventStr) {
|
|
@@ -474,8 +512,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
474
512
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
475
513
|
};
|
|
476
514
|
// Serialize a part, tracking staff state across voices
|
|
477
|
-
//
|
|
478
|
-
const serializePart = (part, currentStaff, isGrandStaff = false, measureContext) => {
|
|
515
|
+
// measureContext is passed to all voices (for clef), but key/time only to first voice
|
|
516
|
+
const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff) => {
|
|
479
517
|
if (part.voices.length === 0) {
|
|
480
518
|
return { str: '', newStaff: currentStaff };
|
|
481
519
|
}
|
|
@@ -483,9 +521,11 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
|
|
|
483
521
|
let staff = currentStaff;
|
|
484
522
|
for (let i = 0; i < part.voices.length; i++) {
|
|
485
523
|
const voice = part.voices[i];
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
const
|
|
524
|
+
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
525
|
+
// Pass staff clef from clefsByStaff map
|
|
526
|
+
const isFirstVoice = isFirstPart && i === 0;
|
|
527
|
+
const staffClef = clefsByStaff?.[voice.staff];
|
|
528
|
+
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
489
529
|
voiceStrs.push(str);
|
|
490
530
|
staff = newStaff;
|
|
491
531
|
}
|
|
@@ -493,17 +533,22 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
|
|
|
493
533
|
return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
|
|
494
534
|
};
|
|
495
535
|
// Serialize a measure, tracking staff state across parts
|
|
496
|
-
|
|
536
|
+
// Always output key/time at start of each measure
|
|
537
|
+
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
|
|
497
538
|
const parts = [];
|
|
498
|
-
// Build measure context for
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
539
|
+
// Build measure context for all voices (key/time)
|
|
540
|
+
// Key and time are written to first voice, clef to all voices based on staff
|
|
541
|
+
// Use passed currentKey/currentTime which tracks across all measures
|
|
542
|
+
const measureContext = {
|
|
543
|
+
key: currentKey,
|
|
544
|
+
time: currentTime,
|
|
545
|
+
};
|
|
546
|
+
// Pass staffClefs to parts for per-voice clef lookup
|
|
547
|
+
const clefsByStaff = staffClefs || {};
|
|
503
548
|
// Parts
|
|
504
549
|
let staff = currentStaff;
|
|
505
550
|
if (measure.parts.length === 1) {
|
|
506
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
|
|
551
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
507
552
|
if (partStr) {
|
|
508
553
|
parts.push(partStr);
|
|
509
554
|
}
|
|
@@ -514,9 +559,8 @@ const serializeMeasure = (measure, isFirst, currentStaff, isGrandStaff = false)
|
|
|
514
559
|
const partStrs = [];
|
|
515
560
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
516
561
|
const part = measure.parts[i];
|
|
517
|
-
//
|
|
518
|
-
const
|
|
519
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
|
|
562
|
+
// Pass measureContext to all parts, isFirstPart to first part only
|
|
563
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
520
564
|
if (str) {
|
|
521
565
|
partStrs.push(str);
|
|
522
566
|
}
|
|
@@ -567,10 +611,32 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
567
611
|
const isGrandStaff = doc.measures.some(m => m.parts.some(p => p.voices.some(v => v.staff > 1)));
|
|
568
612
|
// Measures with bar lines, measure numbers, and double newlines
|
|
569
613
|
// Track staff state across measures (parser remembers staff across bar lines)
|
|
614
|
+
// Track key/time/clef across measures to output in every measure
|
|
570
615
|
const measureStrs = [];
|
|
571
616
|
let currentStaff = 1; // Parser starts at staff 1
|
|
617
|
+
let currentKey;
|
|
618
|
+
let currentTime;
|
|
619
|
+
const staffClefs = {}; // Track clef per staff
|
|
572
620
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
573
|
-
const
|
|
621
|
+
const measure = doc.measures[i];
|
|
622
|
+
// Update current key/time if measure has them
|
|
623
|
+
if (measure.key) {
|
|
624
|
+
currentKey = measure.key;
|
|
625
|
+
}
|
|
626
|
+
if (measure.timeSig) {
|
|
627
|
+
currentTime = measure.timeSig;
|
|
628
|
+
}
|
|
629
|
+
// Collect clefs from this measure's voices
|
|
630
|
+
for (const part of measure.parts) {
|
|
631
|
+
for (const voice of part.voices) {
|
|
632
|
+
for (const event of voice.events) {
|
|
633
|
+
if (event.type === 'context' && event.clef) {
|
|
634
|
+
staffClefs[voice.staff] = event.clef;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
574
640
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
575
641
|
measureStrs.push(measureStr || 's1');
|
|
576
642
|
currentStaff = newStaff;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.36",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -150,7 +150,7 @@ case 25:
|
|
|
150
150
|
this.$ = $$[$0-3].concat([part($$[$0])]);
|
|
151
151
|
break;
|
|
152
152
|
case 26:
|
|
153
|
-
currentStaff = 1;
|
|
153
|
+
currentStaff = 1; currentOttava = 0;
|
|
154
154
|
break;
|
|
155
155
|
case 27:
|
|
156
156
|
this.$ = [voice(currentStaff, $$[$0])];
|
|
@@ -174,7 +174,16 @@ case 43:
|
|
|
174
174
|
this.$ = markupEvent($$[$0].slice(1, -1));
|
|
175
175
|
break;
|
|
176
176
|
case 44:
|
|
177
|
-
|
|
177
|
+
|
|
178
|
+
// On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
|
|
179
|
+
if (currentOttava !== 0) {
|
|
180
|
+
const ottavaReset = contextChange({ ottava: 0 });
|
|
181
|
+
currentOttava = 0;
|
|
182
|
+
this.$ = [ottavaReset, { type: 'pitchReset' }];
|
|
183
|
+
} else {
|
|
184
|
+
this.$ = { type: 'pitchReset' };
|
|
185
|
+
}
|
|
186
|
+
|
|
178
187
|
break;
|
|
179
188
|
case 45: case 46:
|
|
180
189
|
currentDuration = $$[$0-1]; this.$ = noteEvent($$[$0-2], $$[$0-1], $$[$0]);
|
|
@@ -299,13 +308,13 @@ case 85:
|
|
|
299
308
|
currentStaff = Number($$[$0].slice(1, -1)); this.$ = currentStaff;
|
|
300
309
|
break;
|
|
301
310
|
case 86:
|
|
302
|
-
|
|
311
|
+
currentOttava = Number($$[$0]); this.$ = currentOttava;
|
|
303
312
|
break;
|
|
304
313
|
case 87:
|
|
305
|
-
|
|
314
|
+
currentOttava = -Number($$[$0]); this.$ = currentOttava;
|
|
306
315
|
break;
|
|
307
316
|
case 88:
|
|
308
|
-
this.$ = 0;
|
|
317
|
+
currentOttava = 0; this.$ = 0;
|
|
309
318
|
break;
|
|
310
319
|
case 89:
|
|
311
320
|
this.$ = 'up';
|
|
@@ -737,6 +746,7 @@ parse: function parse(input) {
|
|
|
737
746
|
let currentTimeSig = null;
|
|
738
747
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
739
748
|
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
749
|
+
let currentOttava = 0; // Current ottava level, resets on newline
|
|
740
750
|
|
|
741
751
|
// Reset parser state - call before each parse
|
|
742
752
|
const resetParserState = () => {
|
|
@@ -745,6 +755,7 @@ parse: function parse(input) {
|
|
|
745
755
|
currentTimeSig = null;
|
|
746
756
|
currentDuration = { division: 4, dots: 0 };
|
|
747
757
|
numericTimeSignature = false;
|
|
758
|
+
currentOttava = 0;
|
|
748
759
|
};
|
|
749
760
|
|
|
750
761
|
// Export reset function
|
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
let currentTimeSig = null;
|
|
130
130
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
131
131
|
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
132
|
+
let currentOttava = 0; // Current ottava level, resets on newline
|
|
132
133
|
|
|
133
134
|
// Reset parser state - call before each parse
|
|
134
135
|
const resetParserState = () => {
|
|
@@ -137,6 +138,7 @@
|
|
|
137
138
|
currentTimeSig = null;
|
|
138
139
|
currentDuration = { division: 4, dots: 0 };
|
|
139
140
|
numericTimeSignature = false;
|
|
141
|
+
currentOttava = 0;
|
|
140
142
|
};
|
|
141
143
|
|
|
142
144
|
// Export reset function
|
|
@@ -323,7 +325,7 @@ parts
|
|
|
323
325
|
;
|
|
324
326
|
|
|
325
327
|
part_start
|
|
326
|
-
: /* empty */ %{ currentStaff = 1; %}
|
|
328
|
+
: /* empty */ %{ currentStaff = 1; currentOttava = 0; %}
|
|
327
329
|
;
|
|
328
330
|
|
|
329
331
|
part_voices
|
|
@@ -362,7 +364,16 @@ markup_event
|
|
|
362
364
|
;
|
|
363
365
|
|
|
364
366
|
pitch_reset_event
|
|
365
|
-
: NEWLINE
|
|
367
|
+
: NEWLINE %{
|
|
368
|
+
// On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
|
|
369
|
+
if (currentOttava !== 0) {
|
|
370
|
+
const ottavaReset = contextChange({ ottava: 0 });
|
|
371
|
+
currentOttava = 0;
|
|
372
|
+
$$ = [ottavaReset, { type: 'pitchReset' }];
|
|
373
|
+
} else {
|
|
374
|
+
$$ = { type: 'pitchReset' };
|
|
375
|
+
}
|
|
376
|
+
%}
|
|
366
377
|
;
|
|
367
378
|
|
|
368
379
|
note_event
|
|
@@ -475,9 +486,9 @@ staff_cmd
|
|
|
475
486
|
;
|
|
476
487
|
|
|
477
488
|
ottava_cmd
|
|
478
|
-
: CMD_OTTAVA '#' NUMBER
|
|
479
|
-
| CMD_OTTAVA '#' '-' NUMBER
|
|
480
|
-
| CMD_OTTAVA
|
|
489
|
+
: CMD_OTTAVA '#' NUMBER %{ currentOttava = Number($3); $$ = currentOttava; %}
|
|
490
|
+
| CMD_OTTAVA '#' '-' NUMBER %{ currentOttava = -Number($4); $$ = currentOttava; %}
|
|
491
|
+
| CMD_OTTAVA %{ currentOttava = 0; $$ = 0; %}
|
|
481
492
|
;
|
|
482
493
|
|
|
483
494
|
stem_cmd
|
|
@@ -816,6 +816,15 @@ type TieState = Record<string, Pitch[]>;
|
|
|
816
816
|
type SlurState = Record<string, string | null>; // voice key -> pending slur startId
|
|
817
817
|
type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
|
|
818
818
|
|
|
819
|
+
// Pending octave span for cross-measure continuation
|
|
820
|
+
interface PendingOctave {
|
|
821
|
+
dis: 8 | 15;
|
|
822
|
+
disPlace: 'above' | 'below';
|
|
823
|
+
startId: string;
|
|
824
|
+
shift: number; // The ottava value (1, -1, 2, -2)
|
|
825
|
+
}
|
|
826
|
+
type OttavaState = Record<string, PendingOctave | null>; // voice key -> pending octave span
|
|
827
|
+
|
|
819
828
|
// Layer result type
|
|
820
829
|
interface LayerResult {
|
|
821
830
|
xml: string;
|
|
@@ -837,7 +846,11 @@ interface LayerResult {
|
|
|
837
846
|
pendingTiePitches: Pitch[]; // For cross-measure tie tracking
|
|
838
847
|
pendingSlur: string | null; // For cross-measure slur tracking (startId)
|
|
839
848
|
pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
|
|
849
|
+
pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
|
|
850
|
+
ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
|
|
840
851
|
endingClef?: Clef; // For cross-measure clef tracking
|
|
852
|
+
lastNoteId: string | null; // For cross-measure ottava span end tracking
|
|
853
|
+
currentOttavaShift: number; // Current ottava shift for pitch encoding
|
|
841
854
|
}
|
|
842
855
|
|
|
843
856
|
|
|
@@ -864,7 +877,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
|
|
|
864
877
|
};
|
|
865
878
|
|
|
866
879
|
// Encode a layer (voice)
|
|
867
|
-
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null): LayerResult => {
|
|
880
|
+
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
|
|
868
881
|
const layerId = generateId("layer");
|
|
869
882
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
870
883
|
|
|
@@ -881,12 +894,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
881
894
|
// Track pedal marks (each is independent, not paired spans)
|
|
882
895
|
const pedals: PedalMark[] = [];
|
|
883
896
|
|
|
884
|
-
// Track octave spans
|
|
897
|
+
// Track octave spans - initialize from previous measure if continuing
|
|
885
898
|
const octaves: OctaveSpan[] = [];
|
|
886
|
-
let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
|
|
899
|
+
let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
|
|
900
|
+
initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
|
|
887
901
|
let pendingOttava: number | null = null; // Track ottava to apply to next note
|
|
888
|
-
let currentOttavaShift: number = 0; // Track current ottava shift for pitch encoding
|
|
902
|
+
let currentOttavaShift: number = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
889
903
|
let lastNoteId: string | null = null; // Track last note id for ending ottava spans
|
|
904
|
+
let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
|
|
890
905
|
|
|
891
906
|
// Track slur spans - slurs must be encoded as control events in MEI
|
|
892
907
|
const slurs: SlurSpan[] = [];
|
|
@@ -961,7 +976,17 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
961
976
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
962
977
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
963
978
|
const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
|
|
964
|
-
|
|
979
|
+
// Close existing span first if it has a different value
|
|
980
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
981
|
+
// Different value - close the old span
|
|
982
|
+
// Use the lastNoteId from before this note (which we saved before processing)
|
|
983
|
+
// Note: The span from previous measure will be closed by encodeMeasure
|
|
984
|
+
currentOctave = null;
|
|
985
|
+
}
|
|
986
|
+
// Start new span if we don't already have one with the same value
|
|
987
|
+
if (!currentOctave) {
|
|
988
|
+
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
989
|
+
}
|
|
965
990
|
pendingOttava = null;
|
|
966
991
|
}
|
|
967
992
|
|
|
@@ -1106,11 +1131,23 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1106
1131
|
endId: lastNoteId,
|
|
1107
1132
|
});
|
|
1108
1133
|
currentOctave = null;
|
|
1134
|
+
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
1109
1135
|
}
|
|
1110
|
-
|
|
1136
|
+
// Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
|
|
1137
|
+
// It may be continued by a subsequent ottava command with the same value
|
|
1138
|
+
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
1111
1139
|
} else {
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1140
|
+
// Check if this continues an existing span (same value)
|
|
1141
|
+
const dis: 8 | 15 = Math.abs(ctx.ottava) === 2 ? 15 : 8;
|
|
1142
|
+
const disPlace: 'above' | 'below' = ctx.ottava > 0 ? 'above' : 'below';
|
|
1143
|
+
if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
|
|
1144
|
+
// Continuation - restore the shift but don't change the span
|
|
1145
|
+
currentOttavaShift = ctx.ottava;
|
|
1146
|
+
} else {
|
|
1147
|
+
// Different value - start new ottava span (will be applied to next note)
|
|
1148
|
+
// If there's an existing span with different value, it will be closed when the note is processed
|
|
1149
|
+
pendingOttava = ctx.ottava;
|
|
1150
|
+
}
|
|
1114
1151
|
}
|
|
1115
1152
|
}
|
|
1116
1153
|
// Check for stem direction changes
|
|
@@ -1162,18 +1199,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1162
1199
|
xml += `${baseIndent}</beam>\n`;
|
|
1163
1200
|
}
|
|
1164
1201
|
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
startId: currentOctave.startId,
|
|
1171
|
-
endId: lastNoteId,
|
|
1172
|
-
});
|
|
1173
|
-
}
|
|
1202
|
+
// Don't close ottava span at measure end - it may continue in the next measure
|
|
1203
|
+
// Build pending octave state to return
|
|
1204
|
+
const pendingOctave: PendingOctave | null = currentOctave
|
|
1205
|
+
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
|
|
1206
|
+
: null;
|
|
1174
1207
|
|
|
1175
1208
|
xml += `${indent}</layer>\n`;
|
|
1176
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, endingClef: currentClef };
|
|
1209
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
|
|
1177
1210
|
};
|
|
1178
1211
|
|
|
1179
1212
|
// Staff result type
|
|
@@ -1197,11 +1230,14 @@ interface StaffResult {
|
|
|
1197
1230
|
pendingTies: TieState; // For cross-measure tie tracking
|
|
1198
1231
|
pendingSlurs: SlurState; // For cross-measure slur tracking
|
|
1199
1232
|
pendingHairpins: HairpinState; // For cross-measure hairpin tracking
|
|
1233
|
+
pendingOctaves: OttavaState; // For cross-measure ottava span tracking
|
|
1234
|
+
ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
|
|
1235
|
+
lastNoteIds: Record<string, string | null>; // For cross-measure ottava span end tracking
|
|
1200
1236
|
endingClef?: Clef; // For cross-measure clef tracking
|
|
1201
1237
|
}
|
|
1202
1238
|
|
|
1203
1239
|
// Encode a staff
|
|
1204
|
-
const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
|
|
1240
|
+
const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
|
|
1205
1241
|
const staffId = generateId("staff");
|
|
1206
1242
|
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
1207
1243
|
const allHairpins: HairpinSpan[] = [];
|
|
@@ -1222,6 +1258,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1222
1258
|
const pendingTies: TieState = {};
|
|
1223
1259
|
const pendingSlurs: SlurState = {};
|
|
1224
1260
|
const pendingHairpins: HairpinState = {};
|
|
1261
|
+
const pendingOctaves: OttavaState = {};
|
|
1262
|
+
const ottavaExplicitlyClosed: Record<string, boolean> = {};
|
|
1263
|
+
const lastNoteIds: Record<string, string | null> = {};
|
|
1225
1264
|
let endingClef: Clef | undefined = initialClef;
|
|
1226
1265
|
|
|
1227
1266
|
if (voices.length === 0) {
|
|
@@ -1233,7 +1272,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1233
1272
|
const initialTies = tieState[tieKey] || [];
|
|
1234
1273
|
const initialSlur = slurState[tieKey] || null;
|
|
1235
1274
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1236
|
-
const
|
|
1275
|
+
const initialOctave = ottavaState[tieKey] || null;
|
|
1276
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
1237
1277
|
xml += result.xml;
|
|
1238
1278
|
allHairpins.push(...result.hairpins);
|
|
1239
1279
|
allPedals.push(...result.pedals);
|
|
@@ -1262,6 +1302,16 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1262
1302
|
if (result.pendingHairpin) {
|
|
1263
1303
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
1264
1304
|
}
|
|
1305
|
+
// Track pending ottava spans for this layer
|
|
1306
|
+
if (result.pendingOctave) {
|
|
1307
|
+
pendingOctaves[tieKey] = result.pendingOctave;
|
|
1308
|
+
}
|
|
1309
|
+
// Track if ottava was explicitly closed in this layer
|
|
1310
|
+
if (result.ottavaExplicitlyClosed) {
|
|
1311
|
+
ottavaExplicitlyClosed[tieKey] = true;
|
|
1312
|
+
}
|
|
1313
|
+
// Track last note IDs for this layer (for closing ottava spans)
|
|
1314
|
+
lastNoteIds[tieKey] = result.lastNoteId;
|
|
1265
1315
|
// Track ending clef for cross-measure tracking
|
|
1266
1316
|
if (result.endingClef) {
|
|
1267
1317
|
endingClef = result.endingClef;
|
|
@@ -1290,6 +1340,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1290
1340
|
pendingTies,
|
|
1291
1341
|
pendingSlurs,
|
|
1292
1342
|
pendingHairpins,
|
|
1343
|
+
pendingOctaves,
|
|
1344
|
+
ottavaExplicitlyClosed,
|
|
1345
|
+
lastNoteIds,
|
|
1293
1346
|
endingClef,
|
|
1294
1347
|
};
|
|
1295
1348
|
};
|
|
@@ -1339,8 +1392,8 @@ const BARLINE_TO_MEI: Record<string, string> = {
|
|
|
1339
1392
|
};
|
|
1340
1393
|
|
|
1341
1394
|
// Encode a measure
|
|
1342
|
-
// encodeMeasure accepts mutable tieState, slurState, hairpinState and clefState that persist across measures
|
|
1343
|
-
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
|
|
1395
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1396
|
+
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
|
|
1344
1397
|
const measureId = generateId("measure");
|
|
1345
1398
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1346
1399
|
const allHairpins: HairpinSpan[] = [];
|
|
@@ -1395,11 +1448,11 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1395
1448
|
}
|
|
1396
1449
|
}
|
|
1397
1450
|
|
|
1398
|
-
// Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
|
|
1451
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1399
1452
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1400
1453
|
const voices = voicesByStaff[si] || [];
|
|
1401
1454
|
const initialClef = clefState[si];
|
|
1402
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
|
|
1455
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1403
1456
|
staffContent += result.xml;
|
|
1404
1457
|
allHairpins.push(...result.hairpins);
|
|
1405
1458
|
allPedals.push(...result.pedals);
|
|
@@ -1422,6 +1475,55 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1422
1475
|
Object.assign(slurState, result.pendingSlurs);
|
|
1423
1476
|
// Update hairpin state with pending hairpins from this staff
|
|
1424
1477
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1478
|
+
// Update ottava state with pending octaves from this staff
|
|
1479
|
+
// Also handle closing spans when ottava ends
|
|
1480
|
+
const currentStaffPrefix = `${si}-`;
|
|
1481
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1482
|
+
if (pending) {
|
|
1483
|
+
// Check if this is a continuation or a new span
|
|
1484
|
+
const prevPending = ottavaState[key];
|
|
1485
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1486
|
+
// Same ottava value continues - keep the original startId
|
|
1487
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1488
|
+
} else {
|
|
1489
|
+
// Different ottava value - close the old span first if exists
|
|
1490
|
+
if (prevPending) {
|
|
1491
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1492
|
+
if (lastNoteId) {
|
|
1493
|
+
allOctaves.push({
|
|
1494
|
+
dis: prevPending.dis,
|
|
1495
|
+
disPlace: prevPending.disPlace,
|
|
1496
|
+
startId: prevPending.startId,
|
|
1497
|
+
endId: lastNoteId,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
// Start new span
|
|
1502
|
+
ottavaState[key] = pending;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1507
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1508
|
+
// Only process keys for the current staff
|
|
1509
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1510
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1511
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1512
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1513
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1514
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1515
|
+
if (lastNoteId) {
|
|
1516
|
+
allOctaves.push({
|
|
1517
|
+
dis: pending.dis,
|
|
1518
|
+
disPlace: pending.disPlace,
|
|
1519
|
+
startId: pending.startId,
|
|
1520
|
+
endId: lastNoteId,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
delete ottavaState[key];
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1425
1527
|
// Update clef state with ending clef from this staff
|
|
1426
1528
|
if (result.endingClef) {
|
|
1427
1529
|
clefState[si] = result.endingClef;
|
|
@@ -1718,6 +1820,9 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1718
1820
|
// Track hairpin state across measures for cross-measure hairpins
|
|
1719
1821
|
const hairpinState: HairpinState = {};
|
|
1720
1822
|
|
|
1823
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1824
|
+
const ottavaState: OttavaState = {};
|
|
1825
|
+
|
|
1721
1826
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1722
1827
|
const clefState: ClefState = {};
|
|
1723
1828
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1776,7 +1881,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1776
1881
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1777
1882
|
}
|
|
1778
1883
|
}
|
|
1779
|
-
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1884
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1780
1885
|
});
|
|
1781
1886
|
|
|
1782
1887
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
@@ -922,12 +922,12 @@ const directionToContextChange = (
|
|
|
922
922
|
if (type === 'stop') {
|
|
923
923
|
ottava = 0;
|
|
924
924
|
ottavaTracker.current = 0;
|
|
925
|
-
} else if (type === '
|
|
926
|
-
// 8va = 1, 15ma = 2
|
|
925
|
+
} else if (type === 'down') {
|
|
926
|
+
// 8va = 1, 15ma = 2 (type="down" means written notes sound higher)
|
|
927
927
|
ottava = size === 15 ? 2 : 1;
|
|
928
928
|
ottavaTracker.current = ottava;
|
|
929
|
-
} else if (type === '
|
|
930
|
-
// 8vb = -1, 15mb = -2
|
|
929
|
+
} else if (type === 'up') {
|
|
930
|
+
// 8vb = -1, 15mb = -2 (type="up" means written notes sound lower)
|
|
931
931
|
ottava = size === 15 ? -2 : -1;
|
|
932
932
|
ottavaTracker.current = ottava;
|
|
933
933
|
} else {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
ContextChange,
|
|
17
17
|
TupletEvent,
|
|
18
18
|
TremoloEvent,
|
|
19
|
+
BarlineEvent,
|
|
19
20
|
Pitch,
|
|
20
21
|
Duration,
|
|
21
22
|
Mark,
|
|
@@ -524,6 +525,16 @@ const serializeTremoloEvent = (
|
|
|
524
525
|
};
|
|
525
526
|
|
|
526
527
|
|
|
528
|
+
// Serialize a barline event
|
|
529
|
+
const serializeBarlineEvent = (event: BarlineEvent): string => {
|
|
530
|
+
// Only output non-default barlines
|
|
531
|
+
if (event.style && event.style !== '|') {
|
|
532
|
+
return '\\bar "' + event.style + '"';
|
|
533
|
+
}
|
|
534
|
+
return '';
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
|
|
527
538
|
// Serialize a single event with pitch environment tracking
|
|
528
539
|
const serializeEvent = (
|
|
529
540
|
event: Event,
|
|
@@ -541,27 +552,46 @@ const serializeEvent = (
|
|
|
541
552
|
return serializeTupletEvent(event as TupletEvent, env);
|
|
542
553
|
case 'tremolo':
|
|
543
554
|
return serializeTremoloEvent(event as TremoloEvent, env);
|
|
555
|
+
case 'barline':
|
|
556
|
+
return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
|
|
544
557
|
default:
|
|
545
558
|
return { str: '', newEnv: env };
|
|
546
559
|
}
|
|
547
560
|
};
|
|
548
561
|
|
|
549
562
|
|
|
550
|
-
// Key/time signature info to inject into
|
|
563
|
+
// Key/time/clef signature info to inject into voices
|
|
551
564
|
interface MeasureContext {
|
|
552
565
|
key?: KeySignature;
|
|
553
566
|
time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
|
|
567
|
+
clef?: Clef;
|
|
554
568
|
}
|
|
555
569
|
|
|
570
|
+
// Find first clef in voice events
|
|
571
|
+
const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
572
|
+
for (const event of voice.events) {
|
|
573
|
+
if (event.type === 'context') {
|
|
574
|
+
const ctx = event as ContextChange;
|
|
575
|
+
if (ctx.clef) {
|
|
576
|
+
return ctx.clef;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
581
|
+
};
|
|
582
|
+
|
|
556
583
|
// Serialize a voice with pitch environment tracking
|
|
557
584
|
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
558
585
|
// If isGrandStaff is true, always output \staff command for clarity
|
|
559
|
-
//
|
|
586
|
+
// measureContext provides key/time for first voice
|
|
587
|
+
// staffClef is the clef for this voice's staff (tracked across measures)
|
|
560
588
|
const serializeVoice = (
|
|
561
589
|
voice: Voice,
|
|
562
590
|
currentStaff: number,
|
|
563
591
|
isGrandStaff: boolean = false,
|
|
564
|
-
measureContext?: MeasureContext
|
|
592
|
+
measureContext?: MeasureContext,
|
|
593
|
+
isFirstVoice: boolean = false,
|
|
594
|
+
staffClef?: Clef
|
|
565
595
|
): { str: string; newStaff: number } => {
|
|
566
596
|
const parts: string[] = [];
|
|
567
597
|
let prevDuration: Duration | undefined;
|
|
@@ -574,8 +604,8 @@ const serializeVoice = (
|
|
|
574
604
|
parts.push('\\staff "' + voice.staff + '"');
|
|
575
605
|
}
|
|
576
606
|
|
|
577
|
-
// Output key/time signatures after \staff (for first voice
|
|
578
|
-
if (measureContext) {
|
|
607
|
+
// Output key/time signatures after \staff (for first voice only)
|
|
608
|
+
if (measureContext && isFirstVoice) {
|
|
579
609
|
if (measureContext.key) {
|
|
580
610
|
let keyStr = String(measureContext.key.pitch);
|
|
581
611
|
if (measureContext.key.accidental) {
|
|
@@ -595,7 +625,25 @@ const serializeVoice = (
|
|
|
595
625
|
}
|
|
596
626
|
}
|
|
597
627
|
|
|
628
|
+
// Output clef for every voice (use staff clef tracked across measures, or find from voice events)
|
|
629
|
+
const voiceClef = staffClef || findVoiceClef(voice);
|
|
630
|
+
if (voiceClef) {
|
|
631
|
+
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Track if we've already output the clef to avoid duplication
|
|
635
|
+
let clefOutputted = !!voiceClef;
|
|
636
|
+
|
|
598
637
|
for (const event of voice.events) {
|
|
638
|
+
// Skip clef context events if we've already output the clef at the beginning
|
|
639
|
+
if (clefOutputted && event.type === 'context') {
|
|
640
|
+
const ctx = event as ContextChange;
|
|
641
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
|
|
642
|
+
// This is a clef-only context event, skip it
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
599
647
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
600
648
|
pitchEnv = newEnv;
|
|
601
649
|
|
|
@@ -616,12 +664,14 @@ const serializeVoice = (
|
|
|
616
664
|
|
|
617
665
|
|
|
618
666
|
// Serialize a part, tracking staff state across voices
|
|
619
|
-
//
|
|
667
|
+
// measureContext is passed to all voices (for clef), but key/time only to first voice
|
|
620
668
|
const serializePart = (
|
|
621
669
|
part: Part,
|
|
622
670
|
currentStaff: number,
|
|
623
671
|
isGrandStaff: boolean = false,
|
|
624
|
-
measureContext?: MeasureContext
|
|
672
|
+
measureContext?: MeasureContext,
|
|
673
|
+
isFirstPart: boolean = false,
|
|
674
|
+
clefsByStaff?: Record<number, Clef>
|
|
625
675
|
): { str: string; newStaff: number } => {
|
|
626
676
|
if (part.voices.length === 0) {
|
|
627
677
|
return { str: '', newStaff: currentStaff };
|
|
@@ -632,9 +682,11 @@ const serializePart = (
|
|
|
632
682
|
|
|
633
683
|
for (let i = 0; i < part.voices.length; i++) {
|
|
634
684
|
const voice = part.voices[i];
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
const
|
|
685
|
+
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
686
|
+
// Pass staff clef from clefsByStaff map
|
|
687
|
+
const isFirstVoice = isFirstPart && i === 0;
|
|
688
|
+
const staffClef = clefsByStaff?.[voice.staff];
|
|
689
|
+
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
638
690
|
voiceStrs.push(str);
|
|
639
691
|
staff = newStaff;
|
|
640
692
|
}
|
|
@@ -645,19 +697,33 @@ const serializePart = (
|
|
|
645
697
|
|
|
646
698
|
|
|
647
699
|
// Serialize a measure, tracking staff state across parts
|
|
648
|
-
|
|
700
|
+
// Always output key/time at start of each measure
|
|
701
|
+
const serializeMeasure = (
|
|
702
|
+
measure: Measure,
|
|
703
|
+
_isFirst: boolean,
|
|
704
|
+
currentStaff: number,
|
|
705
|
+
isGrandStaff: boolean = false,
|
|
706
|
+
currentKey?: KeySignature,
|
|
707
|
+
currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
|
|
708
|
+
staffClefs?: Record<number, Clef>
|
|
709
|
+
): { str: string; newStaff: number } => {
|
|
649
710
|
const parts: string[] = [];
|
|
650
711
|
|
|
651
|
-
// Build measure context for
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
712
|
+
// Build measure context for all voices (key/time)
|
|
713
|
+
// Key and time are written to first voice, clef to all voices based on staff
|
|
714
|
+
// Use passed currentKey/currentTime which tracks across all measures
|
|
715
|
+
const measureContext: MeasureContext = {
|
|
716
|
+
key: currentKey,
|
|
717
|
+
time: currentTime,
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// Pass staffClefs to parts for per-voice clef lookup
|
|
721
|
+
const clefsByStaff = staffClefs || {};
|
|
656
722
|
|
|
657
723
|
// Parts
|
|
658
724
|
let staff = currentStaff;
|
|
659
725
|
if (measure.parts.length === 1) {
|
|
660
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
|
|
726
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
661
727
|
if (partStr) {
|
|
662
728
|
parts.push(partStr);
|
|
663
729
|
}
|
|
@@ -667,9 +733,8 @@ const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: numb
|
|
|
667
733
|
const partStrs: string[] = [];
|
|
668
734
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
669
735
|
const part = measure.parts[i];
|
|
670
|
-
//
|
|
671
|
-
const
|
|
672
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
|
|
736
|
+
// Pass measureContext to all parts, isFirstPart to first part only
|
|
737
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
673
738
|
if (str) {
|
|
674
739
|
partStrs.push(str);
|
|
675
740
|
}
|
|
@@ -735,10 +800,35 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
735
800
|
|
|
736
801
|
// Measures with bar lines, measure numbers, and double newlines
|
|
737
802
|
// Track staff state across measures (parser remembers staff across bar lines)
|
|
803
|
+
// Track key/time/clef across measures to output in every measure
|
|
738
804
|
const measureStrs: string[] = [];
|
|
739
805
|
let currentStaff = 1; // Parser starts at staff 1
|
|
806
|
+
let currentKey: KeySignature | undefined;
|
|
807
|
+
let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
|
|
808
|
+
const staffClefs: Record<number, Clef> = {}; // Track clef per staff
|
|
809
|
+
|
|
740
810
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
741
|
-
const
|
|
811
|
+
const measure = doc.measures[i];
|
|
812
|
+
// Update current key/time if measure has them
|
|
813
|
+
if (measure.key) {
|
|
814
|
+
currentKey = measure.key;
|
|
815
|
+
}
|
|
816
|
+
if (measure.timeSig) {
|
|
817
|
+
currentTime = measure.timeSig;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Collect clefs from this measure's voices
|
|
821
|
+
for (const part of measure.parts) {
|
|
822
|
+
for (const voice of part.voices) {
|
|
823
|
+
for (const event of voice.events) {
|
|
824
|
+
if (event.type === 'context' && (event as ContextChange).clef) {
|
|
825
|
+
staffClefs[voice.staff] = (event as ContextChange).clef!;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
742
832
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
743
833
|
measureStrs.push(measureStr || 's1');
|
|
744
834
|
currentStaff = newStaff;
|