@k-l-lambda/lilylet 0.1.70 → 0.1.72
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/gmInstruments.d.ts +1 -0
- package/lib/gmInstruments.js +1 -0
- package/lib/highlight.d.ts +1 -0
- package/lib/highlight.js +1 -0
- package/lib/lilylet/abcDecoder.js +16 -7
- package/lib/lilylet/gmInstruments.d.ts +1 -0
- package/lib/lilylet/gmInstruments.js +295 -0
- package/lib/lilylet/highlight.d.ts +29 -0
- package/lib/lilylet/highlight.js +145 -0
- package/lib/lilylet/meiEncoder.js +126 -14
- package/lib/lilylet/staffLayout.d.ts +5 -0
- package/lib/lilylet/staffLayout.js +62 -0
- package/package.json +8 -2
- package/source/lilylet/abcDecoder.ts +14 -7
- package/source/lilylet/gmInstruments.ts +305 -0
- package/source/lilylet/highlight.ts +192 -0
- package/source/lilylet/meiEncoder.ts +135 -11
- package/source/lilylet/staffLayout.ts +76 -0
- package/lib/source/abc/abc.d.ts +0 -102
- package/lib/source/abc/abc.js +0 -25
- package/lib/source/abc/parser.d.ts +0 -3
- package/lib/source/abc/parser.js +0 -6
- package/lib/source/lilylet/abcDecoder.d.ts +0 -25
- package/lib/source/lilylet/abcDecoder.js +0 -1035
- package/lib/source/lilylet/index.d.ts +0 -10
- package/lib/source/lilylet/index.js +0 -10
- package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
- package/lib/source/lilylet/lilypondDecoder.js +0 -1223
- package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
- package/lib/source/lilylet/lilypondEncoder.js +0 -893
- package/lib/source/lilylet/meiEncoder.d.ts +0 -8
- package/lib/source/lilylet/meiEncoder.js +0 -1985
- package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
- package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
- package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
- package/lib/source/lilylet/musicXmlEncoder.js +0 -701
- package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
- package/lib/source/lilylet/musicXmlTypes.js +0 -7
- package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
- package/lib/source/lilylet/musicXmlUtils.js +0 -469
- package/lib/source/lilylet/parser.d.ts +0 -14
- package/lib/source/lilylet/parser.js +0 -161
- package/lib/source/lilylet/serializer.d.ts +0 -11
- package/lib/source/lilylet/serializer.js +0 -791
- package/lib/source/lilylet/types.d.ts +0 -253
- package/lib/source/lilylet/types.js +0 -100
- package/lib/tests/abc-abcjs-parse.d.ts +0 -8
- package/lib/tests/abc-abcjs-parse.js +0 -90
- package/lib/tests/abc-abcjs-svg.d.ts +0 -1
- package/lib/tests/abc-abcjs-svg.js +0 -143
- package/lib/tests/abc-decoder.d.ts +0 -1
- package/lib/tests/abc-decoder.js +0 -67
- package/lib/tests/abc-mei-compare.d.ts +0 -1
- package/lib/tests/abc-mei-compare.js +0 -525
- package/lib/tests/auto-beam.d.ts +0 -9
- package/lib/tests/auto-beam.js +0 -151
- package/lib/tests/computeMeiHashes.d.ts +0 -1
- package/lib/tests/computeMeiHashes.js +0 -87
- package/lib/tests/encoder-mutation.d.ts +0 -9
- package/lib/tests/encoder-mutation.js +0 -110
- package/lib/tests/gpt-review-issues.d.ts +0 -5
- package/lib/tests/gpt-review-issues.js +0 -255
- package/lib/tests/json-to-lyl.d.ts +0 -1
- package/lib/tests/json-to-lyl.js +0 -18
- package/lib/tests/lilypond-roundtrip.d.ts +0 -7
- package/lib/tests/lilypond-roundtrip.js +0 -558
- package/lib/tests/lilypondDecoder.d.ts +0 -6
- package/lib/tests/lilypondDecoder.js +0 -95
- package/lib/tests/ly-to-lyl.d.ts +0 -1
- package/lib/tests/ly-to-lyl.js +0 -12
- package/lib/tests/mei.d.ts +0 -1
- package/lib/tests/mei.js +0 -278
- package/lib/tests/musicxml-decoder.d.ts +0 -4
- package/lib/tests/musicxml-decoder.js +0 -61
- package/lib/tests/musicxml-detail.d.ts +0 -4
- package/lib/tests/musicxml-detail.js +0 -85
- package/lib/tests/musicxml-fprod.d.ts +0 -9
- package/lib/tests/musicxml-fprod.js +0 -153
- package/lib/tests/musicxml-roundtrip.d.ts +0 -7
- package/lib/tests/musicxml-roundtrip.js +0 -296
- package/lib/tests/musicxml-to-mei.d.ts +0 -6
- package/lib/tests/musicxml-to-mei.js +0 -115
- package/lib/tests/parser.d.ts +0 -1
- package/lib/tests/parser.js +0 -17
- package/lib/tests/render-k283.d.ts +0 -1
- package/lib/tests/render-k283.js +0 -33
- package/lib/tests/render-lyl.d.ts +0 -1
- package/lib/tests/render-lyl.js +0 -35
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
- package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
- package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
- package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
- package/lib/tests/unit/gptReviewIssues.test.js +0 -240
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
- package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
- package/lib/tests/unit/partialWarning.test.d.ts +0 -4
- package/lib/tests/unit/partialWarning.test.js +0 -65
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
- package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
- package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
- package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
- package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
- package/lib/tests/unit/voiceStaffParsing.test.js +0 -118
|
@@ -1,893 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lilylet to LilyPond Encoder
|
|
3
|
-
*
|
|
4
|
-
* Converts LilyletDoc to LilyPond (.ly) format.
|
|
5
|
-
* Uses relative pitch mode matching LilyPond's default behavior.
|
|
6
|
-
*/
|
|
7
|
-
import { Accidental, } from "./types.js";
|
|
8
|
-
// === Constants and Mappings ===
|
|
9
|
-
const PHONETS = "cdefgab";
|
|
10
|
-
// Key signature to LilyPond notation (using English note names)
|
|
11
|
-
const KEY_MAP = {
|
|
12
|
-
c: { major: "c \\major", minor: "c \\minor" },
|
|
13
|
-
d: { major: "d \\major", minor: "d \\minor" },
|
|
14
|
-
e: { major: "e \\major", minor: "e \\minor" },
|
|
15
|
-
f: { major: "f \\major", minor: "f \\minor" },
|
|
16
|
-
g: { major: "g \\major", minor: "g \\minor" },
|
|
17
|
-
a: { major: "a \\major", minor: "a \\minor" },
|
|
18
|
-
b: { major: "b \\major", minor: "b \\minor" },
|
|
19
|
-
};
|
|
20
|
-
// Accidentals for key signatures
|
|
21
|
-
const KEY_ACCIDENTAL_MAP = {
|
|
22
|
-
sharp: "s",
|
|
23
|
-
flat: "f",
|
|
24
|
-
doubleSharp: "ss",
|
|
25
|
-
doubleFlat: "ff",
|
|
26
|
-
};
|
|
27
|
-
// Clef names
|
|
28
|
-
const CLEF_MAP = {
|
|
29
|
-
treble: "treble",
|
|
30
|
-
bass: "bass",
|
|
31
|
-
alto: "alto",
|
|
32
|
-
};
|
|
33
|
-
// Accidental to LilyPond notation
|
|
34
|
-
const ACCIDENTAL_MAP = {
|
|
35
|
-
natural: "!",
|
|
36
|
-
sharp: "s",
|
|
37
|
-
flat: "f",
|
|
38
|
-
doubleSharp: "ss",
|
|
39
|
-
doubleFlat: "ff",
|
|
40
|
-
};
|
|
41
|
-
// Articulation to LilyPond notation
|
|
42
|
-
const ARTICULATION_MAP = {
|
|
43
|
-
staccato: "-.",
|
|
44
|
-
staccatissimo: "-!",
|
|
45
|
-
tenuto: "--",
|
|
46
|
-
marcato: "-^",
|
|
47
|
-
accent: "->",
|
|
48
|
-
portato: "-_",
|
|
49
|
-
};
|
|
50
|
-
// Ornament to LilyPond notation
|
|
51
|
-
const ORNAMENT_MAP = {
|
|
52
|
-
trill: "\\trill",
|
|
53
|
-
turn: "\\turn",
|
|
54
|
-
mordent: "\\mordent",
|
|
55
|
-
prall: "\\prall",
|
|
56
|
-
fermata: "\\fermata",
|
|
57
|
-
shortFermata: "\\shortfermata",
|
|
58
|
-
arpeggio: "\\arpeggio",
|
|
59
|
-
};
|
|
60
|
-
// Dynamic to LilyPond notation
|
|
61
|
-
const DYNAMIC_MAP = {
|
|
62
|
-
ppp: "\\ppp",
|
|
63
|
-
pp: "\\pp",
|
|
64
|
-
p: "\\p",
|
|
65
|
-
mp: "\\mp",
|
|
66
|
-
mf: "\\mf",
|
|
67
|
-
f: "\\f",
|
|
68
|
-
ff: "\\ff",
|
|
69
|
-
fff: "\\fff",
|
|
70
|
-
sfz: "\\sfz",
|
|
71
|
-
rfz: "\\rfz",
|
|
72
|
-
};
|
|
73
|
-
// Hairpin to LilyPond notation
|
|
74
|
-
const HAIRPIN_MAP = {
|
|
75
|
-
crescendoStart: "\\<",
|
|
76
|
-
crescendoEnd: "\\!",
|
|
77
|
-
diminuendoStart: "\\>",
|
|
78
|
-
diminuendoEnd: "\\!",
|
|
79
|
-
};
|
|
80
|
-
// Pedal to LilyPond notation
|
|
81
|
-
const PEDAL_MAP = {
|
|
82
|
-
sustainOn: "\\sustainOn",
|
|
83
|
-
sustainOff: "\\sustainOff",
|
|
84
|
-
sostenutoOn: "\\sostenutoOn",
|
|
85
|
-
sostenutoOff: "\\sostenutoOff",
|
|
86
|
-
unaCordaOn: "\\unaCorda",
|
|
87
|
-
unaCordaOff: "\\treCorde",
|
|
88
|
-
};
|
|
89
|
-
// Stem direction
|
|
90
|
-
const STEM_MAP = {
|
|
91
|
-
up: "\\stemUp",
|
|
92
|
-
down: "\\stemDown",
|
|
93
|
-
auto: "\\stemNeutral",
|
|
94
|
-
};
|
|
95
|
-
// Barline styles
|
|
96
|
-
const BARLINE_MAP = {
|
|
97
|
-
"|": "|",
|
|
98
|
-
"||": "||",
|
|
99
|
-
"|.": "|.",
|
|
100
|
-
".|:": ".|:",
|
|
101
|
-
":|.": ":|.",
|
|
102
|
-
":..:": ":..:",
|
|
103
|
-
":..:|": ":..:|",
|
|
104
|
-
};
|
|
105
|
-
// === Helper Functions ===
|
|
106
|
-
/**
|
|
107
|
-
* Generate a spacer rest that fills a measure based on time signature.
|
|
108
|
-
* Uses multiplication syntax: s{denominator}*{numerator}
|
|
109
|
-
* @param timeSig - Time signature { numerator, denominator }
|
|
110
|
-
* @returns LilyPond spacer rest string (e.g., "s4*3" for 3/4, "s8*6" for 6/8)
|
|
111
|
-
*/
|
|
112
|
-
const getSpacerRest = (timeSig) => {
|
|
113
|
-
if (!timeSig)
|
|
114
|
-
return 's1';
|
|
115
|
-
const { numerator, denominator } = timeSig;
|
|
116
|
-
return `s${denominator}*${numerator}`;
|
|
117
|
-
};
|
|
118
|
-
// === Partial Measure Helpers ===
|
|
119
|
-
const TPQN = 480;
|
|
120
|
-
const voiceDurationTicks = (voice) => {
|
|
121
|
-
let ticks = 0;
|
|
122
|
-
const addEvent = (e, factor = 1) => {
|
|
123
|
-
if (e.type === 'note' || e.type === 'rest') {
|
|
124
|
-
const ev = e;
|
|
125
|
-
if (ev.grace)
|
|
126
|
-
return;
|
|
127
|
-
let t = (TPQN * 4) / ev.duration.division;
|
|
128
|
-
let dot = t / 2;
|
|
129
|
-
for (let i = 0; i < ev.duration.dots; i++) {
|
|
130
|
-
t += dot;
|
|
131
|
-
dot /= 2;
|
|
132
|
-
}
|
|
133
|
-
ticks += Math.round(t * factor);
|
|
134
|
-
}
|
|
135
|
-
else if (e.type === 'tuplet' || e.type === 'times') {
|
|
136
|
-
const te = e;
|
|
137
|
-
const f = factor * te.ratio.numerator / te.ratio.denominator;
|
|
138
|
-
for (const inner of te.events)
|
|
139
|
-
addEvent(inner, f);
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
for (const e of voice.events)
|
|
143
|
-
addEvent(e);
|
|
144
|
-
return ticks;
|
|
145
|
-
};
|
|
146
|
-
const ticksToLyDuration = (ticks) => {
|
|
147
|
-
const whole = TPQN * 4;
|
|
148
|
-
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
149
|
-
const t = whole / div;
|
|
150
|
-
if (Math.abs(ticks - t) < 1)
|
|
151
|
-
return String(div);
|
|
152
|
-
if (Math.abs(ticks - Math.round(t * 1.5)) < 1)
|
|
153
|
-
return `${div}.`;
|
|
154
|
-
if (Math.abs(ticks - Math.round(t * 1.75)) < 1)
|
|
155
|
-
return `${div}..`;
|
|
156
|
-
}
|
|
157
|
-
return '4';
|
|
158
|
-
};
|
|
159
|
-
// Return a spacer-rest suffix to pad a voice to the full measure duration.
|
|
160
|
-
// Uses s16*N to avoid complexity with dotted/compound values.
|
|
161
|
-
const padVoiceToMeasure = (voiceTicks, measureTicks) => {
|
|
162
|
-
const remaining = Math.round(measureTicks - voiceTicks);
|
|
163
|
-
if (remaining <= 0)
|
|
164
|
-
return '';
|
|
165
|
-
const sixteenth = Math.round(TPQN / 4); // 120 ticks
|
|
166
|
-
const numSixteenths = Math.round(remaining / sixteenth);
|
|
167
|
-
if (numSixteenths <= 0)
|
|
168
|
-
return '';
|
|
169
|
-
return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
|
|
170
|
-
};
|
|
171
|
-
/**
|
|
172
|
-
* Calculate the octave markers needed to serialize a pitch in relative mode.
|
|
173
|
-
*/
|
|
174
|
-
const getRelativeOctaveMarkers = (env, pitch) => {
|
|
175
|
-
const step = PHONETS.indexOf(pitch.phonet);
|
|
176
|
-
if (step === -1) {
|
|
177
|
-
return { markers: '', newEnv: env };
|
|
178
|
-
}
|
|
179
|
-
const interval = step - env.step;
|
|
180
|
-
// Parser's octave adjustment calculation
|
|
181
|
-
const octInc = Math.floor(Math.abs(interval) / 4) * -Math.sign(interval);
|
|
182
|
-
// Without any markers, parser would calculate:
|
|
183
|
-
const baseOctave = env.octave + octInc;
|
|
184
|
-
// We need markers to reach pitch.octave from baseOctave
|
|
185
|
-
const markerCount = pitch.octave - baseOctave;
|
|
186
|
-
let markers = '';
|
|
187
|
-
if (markerCount > 0) {
|
|
188
|
-
markers = "'".repeat(markerCount);
|
|
189
|
-
}
|
|
190
|
-
else if (markerCount < 0) {
|
|
191
|
-
markers = ",".repeat(-markerCount);
|
|
192
|
-
}
|
|
193
|
-
// Update environment
|
|
194
|
-
const newEnv = {
|
|
195
|
-
step: step,
|
|
196
|
-
octave: pitch.octave
|
|
197
|
-
};
|
|
198
|
-
return { markers, newEnv };
|
|
199
|
-
};
|
|
200
|
-
const DEFAULT_OPTIONS = {
|
|
201
|
-
paper: { width: 210, height: 297 }, // A4 size in mm
|
|
202
|
-
fontSize: 20,
|
|
203
|
-
withMIDI: false,
|
|
204
|
-
autoBeaming: false,
|
|
205
|
-
};
|
|
206
|
-
// === Encoding Functions ===
|
|
207
|
-
/**
|
|
208
|
-
* Encode key signature to LilyPond
|
|
209
|
-
*/
|
|
210
|
-
const encodeKey = (key) => {
|
|
211
|
-
let keyStr = key.pitch;
|
|
212
|
-
if (key.accidental) {
|
|
213
|
-
keyStr += KEY_ACCIDENTAL_MAP[key.accidental] || '';
|
|
214
|
-
}
|
|
215
|
-
return `\\key ${keyStr} \\${key.mode}`;
|
|
216
|
-
};
|
|
217
|
-
/**
|
|
218
|
-
* Encode time signature to LilyPond
|
|
219
|
-
*/
|
|
220
|
-
const encodeTimeSig = (timeSig) => {
|
|
221
|
-
if (timeSig.symbol === 'common') {
|
|
222
|
-
return "\\time 4/4"; // LilyPond handles C automatically
|
|
223
|
-
}
|
|
224
|
-
if (timeSig.symbol === 'cut') {
|
|
225
|
-
return "\\time 2/2"; // LilyPond handles C| automatically
|
|
226
|
-
}
|
|
227
|
-
return `\\time ${timeSig.numerator}/${timeSig.denominator}`;
|
|
228
|
-
};
|
|
229
|
-
/**
|
|
230
|
-
* Encode clef to LilyPond
|
|
231
|
-
*/
|
|
232
|
-
const encodeClef = (clef) => {
|
|
233
|
-
return `\\clef ${CLEF_MAP[clef] || clef}`;
|
|
234
|
-
};
|
|
235
|
-
/**
|
|
236
|
-
* Encode tempo to LilyPond
|
|
237
|
-
*/
|
|
238
|
-
const encodeTempo = (tempo) => {
|
|
239
|
-
let result = "\\tempo";
|
|
240
|
-
if (tempo.text) {
|
|
241
|
-
result += ` "${tempo.text}"`;
|
|
242
|
-
}
|
|
243
|
-
if (tempo.beat && tempo.bpm) {
|
|
244
|
-
const beatValue = tempo.beat.division;
|
|
245
|
-
let dots = "";
|
|
246
|
-
if (tempo.beat.dots) {
|
|
247
|
-
dots = ".".repeat(tempo.beat.dots);
|
|
248
|
-
}
|
|
249
|
-
result += ` ${beatValue}${dots} = ${tempo.bpm}`;
|
|
250
|
-
}
|
|
251
|
-
return result;
|
|
252
|
-
};
|
|
253
|
-
/**
|
|
254
|
-
* Encode a single pitch in relative mode
|
|
255
|
-
*/
|
|
256
|
-
const encodePitch = (pitch, env) => {
|
|
257
|
-
let result = pitch.phonet;
|
|
258
|
-
// Add accidental
|
|
259
|
-
if (pitch.accidental && pitch.accidental !== Accidental.natural) {
|
|
260
|
-
result += ACCIDENTAL_MAP[pitch.accidental] || '';
|
|
261
|
-
}
|
|
262
|
-
else if (pitch.accidental === Accidental.natural) {
|
|
263
|
-
result += '!';
|
|
264
|
-
}
|
|
265
|
-
// Calculate relative octave markers
|
|
266
|
-
const { markers, newEnv } = getRelativeOctaveMarkers(env, pitch);
|
|
267
|
-
result += markers;
|
|
268
|
-
return { str: result, newEnv };
|
|
269
|
-
};
|
|
270
|
-
/**
|
|
271
|
-
* Encode duration to LilyPond
|
|
272
|
-
*/
|
|
273
|
-
const encodeDuration = (duration) => {
|
|
274
|
-
let result = String(duration.division);
|
|
275
|
-
if (duration.dots) {
|
|
276
|
-
result += ".".repeat(duration.dots);
|
|
277
|
-
}
|
|
278
|
-
return result;
|
|
279
|
-
};
|
|
280
|
-
/**
|
|
281
|
-
* Encode marks (articulations, dynamics, etc.) to LilyPond
|
|
282
|
-
*/
|
|
283
|
-
const encodeMarks = (marks) => {
|
|
284
|
-
let result = '';
|
|
285
|
-
for (const mark of marks) {
|
|
286
|
-
switch (mark.markType) {
|
|
287
|
-
case 'articulation':
|
|
288
|
-
result += ARTICULATION_MAP[mark.type] || '';
|
|
289
|
-
break;
|
|
290
|
-
case 'ornament':
|
|
291
|
-
result += ORNAMENT_MAP[mark.type] || '';
|
|
292
|
-
break;
|
|
293
|
-
case 'dynamic':
|
|
294
|
-
result += DYNAMIC_MAP[mark.type] || '';
|
|
295
|
-
break;
|
|
296
|
-
case 'hairpin':
|
|
297
|
-
result += HAIRPIN_MAP[mark.type] || '';
|
|
298
|
-
break;
|
|
299
|
-
case 'tie':
|
|
300
|
-
if (mark.start) {
|
|
301
|
-
result += '~';
|
|
302
|
-
}
|
|
303
|
-
break;
|
|
304
|
-
case 'slur':
|
|
305
|
-
result += mark.start ? '(' : ')';
|
|
306
|
-
break;
|
|
307
|
-
case 'beam':
|
|
308
|
-
result += mark.start ? '[' : ']';
|
|
309
|
-
break;
|
|
310
|
-
case 'pedal':
|
|
311
|
-
result += PEDAL_MAP[mark.type] || '';
|
|
312
|
-
break;
|
|
313
|
-
case 'fingering':
|
|
314
|
-
result += `-${mark.finger}`;
|
|
315
|
-
break;
|
|
316
|
-
case 'navigation':
|
|
317
|
-
if (mark.type === 'coda') {
|
|
318
|
-
result += '\\coda';
|
|
319
|
-
}
|
|
320
|
-
else if (mark.type === 'segno') {
|
|
321
|
-
result += '\\segno';
|
|
322
|
-
}
|
|
323
|
-
break;
|
|
324
|
-
case 'markup':
|
|
325
|
-
const placement = mark.placement === 'below' ? '_' : '^';
|
|
326
|
-
result += `${placement}\\markup { ${mark.content} }`;
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return result;
|
|
331
|
-
};
|
|
332
|
-
/**
|
|
333
|
-
* Encode a note event
|
|
334
|
-
*/
|
|
335
|
-
const encodeNoteEvent = (event, env, lastDuration) => {
|
|
336
|
-
let result = '';
|
|
337
|
-
let newEnv = env;
|
|
338
|
-
// Grace note
|
|
339
|
-
if (event.grace) {
|
|
340
|
-
result += '\\grace ';
|
|
341
|
-
}
|
|
342
|
-
// Stem direction
|
|
343
|
-
if (event.stemDirection) {
|
|
344
|
-
result += STEM_MAP[event.stemDirection] + ' ';
|
|
345
|
-
}
|
|
346
|
-
// Pitches (chord or single note)
|
|
347
|
-
if (event.pitches.length > 1) {
|
|
348
|
-
result += '<';
|
|
349
|
-
const pitchStrs = [];
|
|
350
|
-
let firstPitchEnv;
|
|
351
|
-
for (let i = 0; i < event.pitches.length; i++) {
|
|
352
|
-
// In LilyPond relative mode, each pitch in a chord is relative
|
|
353
|
-
// to the previous pitch in the chord (cascading).
|
|
354
|
-
// After the chord, env becomes the first pitch.
|
|
355
|
-
const { str, newEnv: ne } = encodePitch(event.pitches[i], newEnv);
|
|
356
|
-
pitchStrs.push(str);
|
|
357
|
-
newEnv = ne;
|
|
358
|
-
if (i === 0)
|
|
359
|
-
firstPitchEnv = ne;
|
|
360
|
-
}
|
|
361
|
-
// After chord, reference resets to first pitch
|
|
362
|
-
newEnv = firstPitchEnv;
|
|
363
|
-
result += pitchStrs.join(' ');
|
|
364
|
-
result += '>';
|
|
365
|
-
}
|
|
366
|
-
else if (event.pitches.length === 1) {
|
|
367
|
-
const { str, newEnv: ne } = encodePitch(event.pitches[0], newEnv);
|
|
368
|
-
result += str;
|
|
369
|
-
newEnv = ne;
|
|
370
|
-
}
|
|
371
|
-
// Duration (only if different from last)
|
|
372
|
-
const needDuration = !lastDuration ||
|
|
373
|
-
lastDuration.division !== event.duration.division ||
|
|
374
|
-
lastDuration.dots !== event.duration.dots;
|
|
375
|
-
if (needDuration) {
|
|
376
|
-
result += encodeDuration(event.duration);
|
|
377
|
-
}
|
|
378
|
-
// Tremolo
|
|
379
|
-
if (event.tremolo) {
|
|
380
|
-
result += `:${event.tremolo}`;
|
|
381
|
-
}
|
|
382
|
-
// Marks
|
|
383
|
-
if (event.marks) {
|
|
384
|
-
result += encodeMarks(event.marks);
|
|
385
|
-
}
|
|
386
|
-
return { str: result, newEnv, newDuration: event.duration };
|
|
387
|
-
};
|
|
388
|
-
/**
|
|
389
|
-
* Encode a rest event
|
|
390
|
-
*/
|
|
391
|
-
const encodeRestEvent = (event, env, lastDuration) => {
|
|
392
|
-
let result = '';
|
|
393
|
-
// Rest type
|
|
394
|
-
if (event.fullMeasure) {
|
|
395
|
-
result += 'R';
|
|
396
|
-
}
|
|
397
|
-
else if (event.invisible) {
|
|
398
|
-
result += 's';
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
result += 'r';
|
|
402
|
-
}
|
|
403
|
-
// Duration
|
|
404
|
-
const needDuration = !lastDuration ||
|
|
405
|
-
lastDuration.division !== event.duration.division ||
|
|
406
|
-
lastDuration.dots !== event.duration.dots;
|
|
407
|
-
if (needDuration) {
|
|
408
|
-
result += encodeDuration(event.duration);
|
|
409
|
-
}
|
|
410
|
-
// Positioned rest
|
|
411
|
-
let newEnv = env;
|
|
412
|
-
if (event.pitch && !event.fullMeasure && !event.invisible) {
|
|
413
|
-
const { str, newEnv: ne } = encodePitch(event.pitch, env);
|
|
414
|
-
result = str + result.slice(1); // Replace 'r' with pitch
|
|
415
|
-
result += '\\rest';
|
|
416
|
-
newEnv = ne;
|
|
417
|
-
}
|
|
418
|
-
return { str: result, newEnv, newDuration: event.duration };
|
|
419
|
-
};
|
|
420
|
-
/**
|
|
421
|
-
* Encode a context change event
|
|
422
|
-
*/
|
|
423
|
-
const encodeContextChange = (event) => {
|
|
424
|
-
const parts = [];
|
|
425
|
-
if (event.key) {
|
|
426
|
-
parts.push(encodeKey(event.key));
|
|
427
|
-
}
|
|
428
|
-
if (event.time) {
|
|
429
|
-
parts.push(encodeTimeSig(event.time));
|
|
430
|
-
}
|
|
431
|
-
if (event.clef) {
|
|
432
|
-
parts.push(encodeClef(event.clef));
|
|
433
|
-
}
|
|
434
|
-
if (event.ottava !== undefined) {
|
|
435
|
-
parts.push(`\\ottava #${event.ottava}`);
|
|
436
|
-
}
|
|
437
|
-
if (event.stemDirection) {
|
|
438
|
-
parts.push(STEM_MAP[event.stemDirection]);
|
|
439
|
-
}
|
|
440
|
-
if (event.tempo) {
|
|
441
|
-
parts.push(encodeTempo(event.tempo));
|
|
442
|
-
}
|
|
443
|
-
if (event.staff) {
|
|
444
|
-
parts.push(`\\change Staff = "${event.staff}"`);
|
|
445
|
-
}
|
|
446
|
-
return parts.join(' ');
|
|
447
|
-
};
|
|
448
|
-
/**
|
|
449
|
-
* Encode a tuplet event
|
|
450
|
-
*/
|
|
451
|
-
const encodeTupletEvent = (event, env, lastDuration) => {
|
|
452
|
-
// \times preserves type:"times"; \tuplet is denominator/numerator
|
|
453
|
-
const header = event.type === 'times'
|
|
454
|
-
? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
|
|
455
|
-
: `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
|
|
456
|
-
let result = `${header} { `;
|
|
457
|
-
let newEnv = env;
|
|
458
|
-
let newDuration = lastDuration;
|
|
459
|
-
for (const subEvent of event.events) {
|
|
460
|
-
if (subEvent.type === 'note') {
|
|
461
|
-
const { str, newEnv: ne, newDuration: nd } = encodeNoteEvent(subEvent, newEnv, newDuration);
|
|
462
|
-
result += str + ' ';
|
|
463
|
-
newEnv = ne;
|
|
464
|
-
newDuration = nd;
|
|
465
|
-
}
|
|
466
|
-
else if (subEvent.type === 'rest') {
|
|
467
|
-
const { str, newEnv: ne, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
|
|
468
|
-
result += str + ' ';
|
|
469
|
-
newEnv = ne;
|
|
470
|
-
newDuration = nd;
|
|
471
|
-
}
|
|
472
|
-
else if (subEvent.type === 'context') {
|
|
473
|
-
result += encodeContextChange(subEvent) + ' ';
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
result += '}';
|
|
477
|
-
return { str: result, newEnv, newDuration };
|
|
478
|
-
};
|
|
479
|
-
/**
|
|
480
|
-
* Encode a tremolo event
|
|
481
|
-
*/
|
|
482
|
-
const encodeTremoloEvent = (event, env) => {
|
|
483
|
-
let newEnv = env;
|
|
484
|
-
// First chord/note
|
|
485
|
-
let pitchA = '';
|
|
486
|
-
if (event.pitchA.length > 1) {
|
|
487
|
-
pitchA += '<';
|
|
488
|
-
const pitchStrs = [];
|
|
489
|
-
let firstPitchEnv;
|
|
490
|
-
for (let i = 0; i < event.pitchA.length; i++) {
|
|
491
|
-
const { str, newEnv: ne } = encodePitch(event.pitchA[i], newEnv);
|
|
492
|
-
pitchStrs.push(str);
|
|
493
|
-
newEnv = ne;
|
|
494
|
-
if (i === 0)
|
|
495
|
-
firstPitchEnv = ne;
|
|
496
|
-
}
|
|
497
|
-
newEnv = firstPitchEnv;
|
|
498
|
-
pitchA += pitchStrs.join(' ');
|
|
499
|
-
pitchA += '>';
|
|
500
|
-
}
|
|
501
|
-
else if (event.pitchA.length === 1) {
|
|
502
|
-
const { str, newEnv: ne } = encodePitch(event.pitchA[0], newEnv);
|
|
503
|
-
pitchA += str;
|
|
504
|
-
newEnv = ne;
|
|
505
|
-
}
|
|
506
|
-
// Second chord/note
|
|
507
|
-
let pitchB = '';
|
|
508
|
-
if (event.pitchB.length > 1) {
|
|
509
|
-
pitchB += '<';
|
|
510
|
-
const pitchStrs = [];
|
|
511
|
-
let firstPitchEnv;
|
|
512
|
-
for (let i = 0; i < event.pitchB.length; i++) {
|
|
513
|
-
const { str, newEnv: ne } = encodePitch(event.pitchB[i], newEnv);
|
|
514
|
-
pitchStrs.push(str);
|
|
515
|
-
newEnv = ne;
|
|
516
|
-
if (i === 0)
|
|
517
|
-
firstPitchEnv = ne;
|
|
518
|
-
}
|
|
519
|
-
newEnv = firstPitchEnv;
|
|
520
|
-
pitchB += pitchStrs.join(' ');
|
|
521
|
-
pitchB += '>';
|
|
522
|
-
}
|
|
523
|
-
else if (event.pitchB.length === 1) {
|
|
524
|
-
const { str, newEnv: ne } = encodePitch(event.pitchB[0], newEnv);
|
|
525
|
-
pitchB += str;
|
|
526
|
-
newEnv = ne;
|
|
527
|
-
}
|
|
528
|
-
const result = `\\repeat tremolo ${event.count} { ${pitchA}${event.division} ${pitchB}${event.division} }`;
|
|
529
|
-
return { str: result, newEnv };
|
|
530
|
-
};
|
|
531
|
-
/**
|
|
532
|
-
* Encode a barline event
|
|
533
|
-
*/
|
|
534
|
-
const encodeBarlineEvent = (event) => {
|
|
535
|
-
const style = BARLINE_MAP[event.style] || event.style;
|
|
536
|
-
if (style === '|') {
|
|
537
|
-
return ''; // Default barline, no need to encode
|
|
538
|
-
}
|
|
539
|
-
return `\\bar "${style}"`;
|
|
540
|
-
};
|
|
541
|
-
/**
|
|
542
|
-
* Encode a harmony event (chord symbol)
|
|
543
|
-
* Uses the lilylet extension: \chords "text" (parsed by modified lotus grammar)
|
|
544
|
-
*/
|
|
545
|
-
const encodeHarmonyEvent = (event) => {
|
|
546
|
-
return `\\chords "${event.text}"`;
|
|
547
|
-
};
|
|
548
|
-
/**
|
|
549
|
-
* Encode a markup event
|
|
550
|
-
*/
|
|
551
|
-
const encodeMarkupEvent = (event) => {
|
|
552
|
-
const placement = event.placement === 'below' ? '_' : '^';
|
|
553
|
-
return `${placement}\\markup { ${event.content} }`;
|
|
554
|
-
};
|
|
555
|
-
/**
|
|
556
|
-
* Encode a voice to LilyPond
|
|
557
|
-
*/
|
|
558
|
-
const encodeVoice = (voice, measureContext, voiceIndex) => {
|
|
559
|
-
let result = '';
|
|
560
|
-
let env = { step: 0, octave: 0 }; // Start at middle C
|
|
561
|
-
let lastDuration = null;
|
|
562
|
-
for (const event of voice.events) {
|
|
563
|
-
switch (event.type) {
|
|
564
|
-
case 'note': {
|
|
565
|
-
const { str, newEnv, newDuration } = encodeNoteEvent(event, env, lastDuration);
|
|
566
|
-
result += str + ' ';
|
|
567
|
-
env = newEnv;
|
|
568
|
-
lastDuration = newDuration;
|
|
569
|
-
break;
|
|
570
|
-
}
|
|
571
|
-
case 'rest': {
|
|
572
|
-
const { str, newEnv, newDuration } = encodeRestEvent(event, env, lastDuration);
|
|
573
|
-
result += str + ' ';
|
|
574
|
-
env = newEnv;
|
|
575
|
-
lastDuration = newDuration;
|
|
576
|
-
break;
|
|
577
|
-
}
|
|
578
|
-
case 'context': {
|
|
579
|
-
result += encodeContextChange(event) + ' ';
|
|
580
|
-
break;
|
|
581
|
-
}
|
|
582
|
-
case 'tuplet':
|
|
583
|
-
case 'times': {
|
|
584
|
-
const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
|
|
585
|
-
result += str + ' ';
|
|
586
|
-
env = newEnv;
|
|
587
|
-
lastDuration = newDuration;
|
|
588
|
-
break;
|
|
589
|
-
}
|
|
590
|
-
case 'tremolo': {
|
|
591
|
-
const { str, newEnv } = encodeTremoloEvent(event, env);
|
|
592
|
-
result += str + ' ';
|
|
593
|
-
env = newEnv;
|
|
594
|
-
break;
|
|
595
|
-
}
|
|
596
|
-
case 'barline': {
|
|
597
|
-
const str = encodeBarlineEvent(event);
|
|
598
|
-
if (str) {
|
|
599
|
-
result += str + ' ';
|
|
600
|
-
}
|
|
601
|
-
break;
|
|
602
|
-
}
|
|
603
|
-
case 'harmony': {
|
|
604
|
-
result += encodeHarmonyEvent(event) + ' ';
|
|
605
|
-
break;
|
|
606
|
-
}
|
|
607
|
-
case 'markup': {
|
|
608
|
-
result += encodeMarkupEvent(event) + ' ';
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
case 'pitchReset': {
|
|
612
|
-
// Ignore: each measure already gets its own \relative c' block,
|
|
613
|
-
// and within a measure the LilyPond reference pitch is not reset.
|
|
614
|
-
break;
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
return result.trim();
|
|
619
|
-
};
|
|
620
|
-
/**
|
|
621
|
-
* Encode metadata to LilyPond header block
|
|
622
|
-
*/
|
|
623
|
-
const encodeMetadata = (metadata) => {
|
|
624
|
-
const entries = [];
|
|
625
|
-
if (metadata.title) {
|
|
626
|
-
entries.push(` title = "${metadata.title}"`);
|
|
627
|
-
}
|
|
628
|
-
if (metadata.subtitle) {
|
|
629
|
-
entries.push(` subtitle = "${metadata.subtitle}"`);
|
|
630
|
-
}
|
|
631
|
-
if (metadata.composer) {
|
|
632
|
-
entries.push(` composer = "${metadata.composer}"`);
|
|
633
|
-
}
|
|
634
|
-
if (metadata.arranger) {
|
|
635
|
-
entries.push(` arranger = "${metadata.arranger}"`);
|
|
636
|
-
}
|
|
637
|
-
if (metadata.lyricist) {
|
|
638
|
-
entries.push(` poet = "${metadata.lyricist}"`);
|
|
639
|
-
}
|
|
640
|
-
if (metadata.opus) {
|
|
641
|
-
entries.push(` opus = "${metadata.opus}"`);
|
|
642
|
-
}
|
|
643
|
-
if (metadata.instrument) {
|
|
644
|
-
entries.push(` instrument = "${metadata.instrument}"`);
|
|
645
|
-
}
|
|
646
|
-
entries.push(' tagline = ##f');
|
|
647
|
-
return entries.join('\n');
|
|
648
|
-
};
|
|
649
|
-
/**
|
|
650
|
-
* Encode a complete LilyletDoc to LilyPond format
|
|
651
|
-
*
|
|
652
|
-
* Structure:
|
|
653
|
-
* - Multiple parts → outer <<>>
|
|
654
|
-
* - Part with multiple staves → GrandStaff
|
|
655
|
-
* - Part with single staff → standalone Staff
|
|
656
|
-
*/
|
|
657
|
-
export const encode = (doc, options = {}) => {
|
|
658
|
-
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
659
|
-
// Filter out trailing empty measures (measures with no musical content)
|
|
660
|
-
const hasMusicContent = (measure) => {
|
|
661
|
-
for (const part of measure.parts) {
|
|
662
|
-
for (const voice of part.voices) {
|
|
663
|
-
for (const event of voice.events) {
|
|
664
|
-
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
665
|
-
return true;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
return false;
|
|
671
|
-
};
|
|
672
|
-
// Trim trailing empty measures
|
|
673
|
-
let measureCount = doc.measures.length;
|
|
674
|
-
while (measureCount > 0 && !hasMusicContent(doc.measures[measureCount - 1])) {
|
|
675
|
-
measureCount--;
|
|
676
|
-
}
|
|
677
|
-
const measures = doc.measures.slice(0, measureCount);
|
|
678
|
-
// Determine number of parts from the document
|
|
679
|
-
const partCount = Math.max(...measures.map(m => m.parts.length), 1);
|
|
680
|
-
const partVoices = [];
|
|
681
|
-
for (let pi = 0; pi < partCount; pi++) {
|
|
682
|
-
partVoices.push(new Map());
|
|
683
|
-
}
|
|
684
|
-
// Track time signature for each measure (for spacer rests)
|
|
685
|
-
const measureTimeSigs = [];
|
|
686
|
-
// Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
|
|
687
|
-
const measurePartialDurs = [];
|
|
688
|
-
let currentKey;
|
|
689
|
-
let currentTimeSig;
|
|
690
|
-
for (let mi = 0; mi < measures.length; mi++) {
|
|
691
|
-
const measure = measures[mi];
|
|
692
|
-
// Update context from measure
|
|
693
|
-
if (measure.key)
|
|
694
|
-
currentKey = measure.key;
|
|
695
|
-
if (measure.timeSig)
|
|
696
|
-
currentTimeSig = measure.timeSig;
|
|
697
|
-
// Store time signature for this measure
|
|
698
|
-
measureTimeSigs[mi] = currentTimeSig;
|
|
699
|
-
// Detect partial (pickup) measures and compute their duration.
|
|
700
|
-
// Only the first measure (mi===0) can be an implicit partial;
|
|
701
|
-
// subsequent incomplete measures are NOT treated as partial.
|
|
702
|
-
const isExplicitPartial = measure.partial === true;
|
|
703
|
-
if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
|
|
704
|
-
let maxVoiceTicks = 0;
|
|
705
|
-
for (const part of measure.parts) {
|
|
706
|
-
for (const v of part.voices) {
|
|
707
|
-
maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
const expectedTicks = currentTimeSig
|
|
711
|
-
? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
|
|
712
|
-
: TPQN * 4;
|
|
713
|
-
if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
|
|
714
|
-
measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
|
|
715
|
-
}
|
|
716
|
-
else {
|
|
717
|
-
measurePartialDurs[mi] = undefined;
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
else {
|
|
721
|
-
measurePartialDurs[mi] = undefined;
|
|
722
|
-
}
|
|
723
|
-
// Process each part
|
|
724
|
-
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
725
|
-
const part = measure.parts[pi];
|
|
726
|
-
const staffMap = partVoices[pi];
|
|
727
|
-
for (let vi = 0; vi < part.voices.length; vi++) {
|
|
728
|
-
const voice = part.voices[vi];
|
|
729
|
-
const staff = voice.staff || 1;
|
|
730
|
-
if (!staffMap.has(staff)) {
|
|
731
|
-
staffMap.set(staff, []);
|
|
732
|
-
}
|
|
733
|
-
const staffMeasures = staffMap.get(staff);
|
|
734
|
-
// Ensure we have enough measure slots
|
|
735
|
-
while (staffMeasures.length <= mi) {
|
|
736
|
-
staffMeasures.push([]);
|
|
737
|
-
}
|
|
738
|
-
// Encode voice content
|
|
739
|
-
let voiceContent = encodeVoice(voice, {
|
|
740
|
-
key: currentKey,
|
|
741
|
-
timeSig: currentTimeSig,
|
|
742
|
-
isFirst: mi === 0
|
|
743
|
-
}, vi);
|
|
744
|
-
// For non-partial measures, pad incomplete voices to fill the full
|
|
745
|
-
// measure duration. lotus doesn't auto-advance on barlines, so
|
|
746
|
-
// under-full voices cause measure boundary miscounting.
|
|
747
|
-
if (!measurePartialDurs[mi] && currentTimeSig) {
|
|
748
|
-
const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
|
|
749
|
-
const voiceTicks = voiceDurationTicks(voice);
|
|
750
|
-
voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
|
|
751
|
-
}
|
|
752
|
-
staffMeasures[mi].push(voiceContent);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
// Build a staff string (used for both GrandStaff children and standalone Staff)
|
|
757
|
-
// Staff ID format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1") for multi-part decoding
|
|
758
|
-
const buildStaffString = (measures, staffId, indent) => {
|
|
759
|
-
// Find max voices per measure for this staff
|
|
760
|
-
const maxVoices = Math.max(...measures.map(m => m.length), 1);
|
|
761
|
-
// Build voice lines
|
|
762
|
-
const voiceLines = [];
|
|
763
|
-
for (let vi = 0; vi < maxVoices; vi++) {
|
|
764
|
-
let prevTimeSigStr;
|
|
765
|
-
const measureContents = measures.map((m, mi) => {
|
|
766
|
-
// For partial (pickup) measures, use a partial-duration spacer
|
|
767
|
-
const partialDur = measurePartialDurs[mi];
|
|
768
|
-
const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
|
|
769
|
-
let content = m[vi] || spacer;
|
|
770
|
-
// Inject \time if the content lacks it and the time sig changed.
|
|
771
|
-
// lotus processes each voice independently — without \time it
|
|
772
|
-
// defaults to 4/4, miscounting measure boundaries for other meters.
|
|
773
|
-
const ts = measureTimeSigs[mi];
|
|
774
|
-
if (ts) {
|
|
775
|
-
const tsStr = `${ts.numerator}/${ts.denominator}`;
|
|
776
|
-
if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
|
|
777
|
-
content = `${encodeTimeSig(ts)} ${content}`;
|
|
778
|
-
}
|
|
779
|
-
prevTimeSigStr = tsStr;
|
|
780
|
-
}
|
|
781
|
-
// For partial measures, prepend \partial DUR before all other commands
|
|
782
|
-
// so the lotus interpreter correctly tracks the pickup measure boundary.
|
|
783
|
-
if (partialDur && !content.includes('\\partial')) {
|
|
784
|
-
content = `\\partial ${partialDur} ${content}`;
|
|
785
|
-
}
|
|
786
|
-
// Wrap each measure in its own \relative c' to reset pitch context
|
|
787
|
-
return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
|
|
788
|
-
});
|
|
789
|
-
voiceLines.push(`${indent} \\new Voice {\n${measureContents.join('\n')}\n${indent} }`);
|
|
790
|
-
}
|
|
791
|
-
return `${indent}\\new Staff = "${staffId}" <<\n${voiceLines.join('\n')}\n${indent}>>`;
|
|
792
|
-
};
|
|
793
|
-
// Build music content for each part
|
|
794
|
-
const partStrings = [];
|
|
795
|
-
for (let pi = 0; pi < partCount; pi++) {
|
|
796
|
-
const staffMap = partVoices[pi];
|
|
797
|
-
const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
|
|
798
|
-
if (staffNums.length === 0) {
|
|
799
|
-
// Empty part, skip
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
const partIndex = pi + 1; // 1-based part index
|
|
803
|
-
if (staffNums.length === 1) {
|
|
804
|
-
// Single staff part → standalone Staff
|
|
805
|
-
const staffNum = staffNums[0];
|
|
806
|
-
const measures = staffMap.get(staffNum);
|
|
807
|
-
const staffId = `${partIndex}_${staffNum}`;
|
|
808
|
-
const staffStr = buildStaffString(measures, staffId, ' ');
|
|
809
|
-
partStrings.push(staffStr);
|
|
810
|
-
}
|
|
811
|
-
else {
|
|
812
|
-
// Multiple staves → GrandStaff
|
|
813
|
-
const staffStrings = [];
|
|
814
|
-
for (const staffNum of staffNums) {
|
|
815
|
-
const measures = staffMap.get(staffNum);
|
|
816
|
-
const staffId = `${partIndex}_${staffNum}`;
|
|
817
|
-
const staffStr = buildStaffString(measures, staffId, ' ');
|
|
818
|
-
staffStrings.push(staffStr);
|
|
819
|
-
}
|
|
820
|
-
partStrings.push(` \\new GrandStaff <<\n${staffStrings.join('\n')}\n >>`);
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
const musicContent = partStrings.join('\n');
|
|
824
|
-
// Determine outer wrapper
|
|
825
|
-
// - Single part with single staff → just Staff (no outer <<>>)
|
|
826
|
-
// - Single part with multiple staves → GrandStaff (no extra outer <<>>)
|
|
827
|
-
// - Multiple parts → outer <<>>
|
|
828
|
-
let scoreContent;
|
|
829
|
-
if (partCount === 1 && partStrings.length === 1) {
|
|
830
|
-
// Single part - use as-is (already has Staff or GrandStaff)
|
|
831
|
-
scoreContent = musicContent;
|
|
832
|
-
}
|
|
833
|
-
else {
|
|
834
|
-
// Multiple parts - wrap in <<>>
|
|
835
|
-
scoreContent = ` <<\n${musicContent}\n >>`;
|
|
836
|
-
}
|
|
837
|
-
// Build header
|
|
838
|
-
const headerContent = doc.metadata ? encodeMetadata(doc.metadata) : ' tagline = ##f';
|
|
839
|
-
// Build document
|
|
840
|
-
const paperWidth = typeof opts.paper?.width === 'number' ? `${opts.paper.width}\\mm` : opts.paper?.width || '210\\mm';
|
|
841
|
-
const paperHeight = typeof opts.paper?.height === 'number' ? `${opts.paper.height}\\mm` : opts.paper?.height || '297\\mm';
|
|
842
|
-
const lyDoc = `\\version "2.22.0"
|
|
843
|
-
|
|
844
|
-
\\language "english"
|
|
845
|
-
|
|
846
|
-
\\header {
|
|
847
|
-
${headerContent}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
#(set-global-staff-size ${opts.fontSize})
|
|
851
|
-
|
|
852
|
-
\\paper {
|
|
853
|
-
paper-width = ${paperWidth}
|
|
854
|
-
paper-height = ${paperHeight}
|
|
855
|
-
ragged-last = ##t
|
|
856
|
-
ragged-last-bottom = ##f
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
\\layout {
|
|
860
|
-
\\context {
|
|
861
|
-
\\Score
|
|
862
|
-
autoBeaming = ##${opts.autoBeaming ? 't' : 'f'}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
\\score {
|
|
867
|
-
${scoreContent}
|
|
868
|
-
|
|
869
|
-
\\layout { }${opts.withMIDI ? '\n \\midi { }' : ''}
|
|
870
|
-
}
|
|
871
|
-
`;
|
|
872
|
-
return lyDoc;
|
|
873
|
-
};
|
|
874
|
-
/**
|
|
875
|
-
* Encode LilyletDoc to minimal LilyPond (music content only, no headers)
|
|
876
|
-
*/
|
|
877
|
-
export const encodeMinimal = (doc) => {
|
|
878
|
-
const parts = [];
|
|
879
|
-
for (const measure of doc.measures) {
|
|
880
|
-
for (const part of measure.parts) {
|
|
881
|
-
for (const voice of part.voices) {
|
|
882
|
-
const content = encodeVoice(voice, { isFirst: false }, 0);
|
|
883
|
-
parts.push(content);
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
parts.push('|');
|
|
887
|
-
}
|
|
888
|
-
return parts.join(' ');
|
|
889
|
-
};
|
|
890
|
-
export default {
|
|
891
|
-
encode,
|
|
892
|
-
encodeMinimal,
|
|
893
|
-
};
|