@k-l-lambda/lilylet 0.1.76 → 0.1.79
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/abc/grammar.jison.js +85 -79
- package/lib/lilylet/abcDecoder.js +167 -0
- package/lib/lilylet/grammar.jison.js +120 -109
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +52 -3
- package/lib/lilylet/meiEncoder.js +62 -7
- package/lib/lilylet/musicXmlDecoder.js +119 -24
- package/lib/lilylet/musicXmlEncoder.js +85 -18
- package/lib/lilylet/musicXmlTypes.d.ts +3 -1
- package/lib/lilylet/musicXmlUtils.js +1 -1
- package/lib/lilylet/serializer.js +14 -0
- package/lib/lilylet/types.d.ts +6 -2
- package/lib/lilylet/types.js +1 -0
- package/package.json +3 -1
- package/source/abc/abc.jison +8 -4
- package/source/abc/grammar.jison.js +85 -79
- package/source/lilylet/abcDecoder.ts +189 -0
- package/source/lilylet/grammar.jison.js +120 -109
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +10 -1
- package/source/lilylet/measureLayoutFromXml.ts +53 -3
- package/source/lilylet/meiEncoder.ts +83 -7
- package/source/lilylet/musicXmlDecoder.ts +117 -23
- package/source/lilylet/musicXmlEncoder.ts +89 -16
- package/source/lilylet/musicXmlTypes.ts +5 -1
- package/source/lilylet/musicXmlUtils.ts +1 -1
- package/source/lilylet/serializer.ts +14 -0
- package/source/lilylet/types.ts +10 -1
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
DynamicType,
|
|
33
33
|
HairpinType,
|
|
34
34
|
PedalType,
|
|
35
|
+
NavigationMarkType,
|
|
35
36
|
Tempo,
|
|
36
37
|
Metadata,
|
|
37
38
|
Fraction,
|
|
@@ -124,6 +125,7 @@ const DYNAMIC_TO_XML: Record<string, string> = {
|
|
|
124
125
|
fff: 'fff',
|
|
125
126
|
sfz: 'sfz',
|
|
126
127
|
rfz: 'rfz',
|
|
128
|
+
fp: 'fp',
|
|
127
129
|
};
|
|
128
130
|
|
|
129
131
|
// Barline style to MusicXML
|
|
@@ -289,6 +291,7 @@ const encodeAttributes = (
|
|
|
289
291
|
const encodeNotations = (marks: Mark[], level: number): string => {
|
|
290
292
|
const articulations: string[] = [];
|
|
291
293
|
const ornaments: string[] = [];
|
|
294
|
+
const technical: string[] = [];
|
|
292
295
|
const otherNotations: string[] = [];
|
|
293
296
|
|
|
294
297
|
for (const mark of marks) {
|
|
@@ -301,15 +304,20 @@ const encodeNotations = (marks: Mark[], level: number): string => {
|
|
|
301
304
|
break;
|
|
302
305
|
|
|
303
306
|
case 'ornament':
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
307
|
+
// fermata / arpeggio live at the <notations> level; breath-mark is an
|
|
308
|
+
// <articulations> child; the rest are true <ornaments>. (shortFermata is
|
|
309
|
+
// a fermata with an "angled" shape — decoder maps it back.)
|
|
310
|
+
if (mark.type === 'fermata') {
|
|
311
|
+
otherNotations.push('<fermata/>');
|
|
312
|
+
} else if (mark.type === 'shortFermata') {
|
|
313
|
+
otherNotations.push('<fermata>angled</fermata>');
|
|
314
|
+
} else if (mark.type === 'arpeggio') {
|
|
315
|
+
otherNotations.push('<arpeggiate/>');
|
|
316
|
+
} else if (mark.type === 'breath') {
|
|
317
|
+
articulations.push('<breath-mark/>');
|
|
318
|
+
} else {
|
|
319
|
+
const ornXml = ORNAMENT_TO_XML[mark.type];
|
|
320
|
+
if (ornXml) ornaments.push(`<${ornXml}/>`);
|
|
313
321
|
}
|
|
314
322
|
break;
|
|
315
323
|
|
|
@@ -325,13 +333,22 @@ const encodeNotations = (marks: Mark[], level: number): string => {
|
|
|
325
333
|
otherNotations.push(`<tuplet type="${(mark as any).start ? 'start' : 'stop'}"/>`);
|
|
326
334
|
break;
|
|
327
335
|
|
|
336
|
+
case 'glissando':
|
|
337
|
+
// Lilylet \glissando is start-only (the line auto-connects to the next
|
|
338
|
+
// note); emit just the start spanner so the decoder reads it back.
|
|
339
|
+
otherNotations.push('<glissando type="start" number="1"/>');
|
|
340
|
+
break;
|
|
341
|
+
|
|
328
342
|
case 'fingering':
|
|
329
|
-
|
|
343
|
+
technical.push(`<fingering>${mark.finger}</fingering>`);
|
|
330
344
|
break;
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
347
|
|
|
334
|
-
if (
|
|
348
|
+
if (
|
|
349
|
+
articulations.length === 0 && ornaments.length === 0 &&
|
|
350
|
+
technical.length === 0 && otherNotations.length === 0
|
|
351
|
+
) {
|
|
335
352
|
return '';
|
|
336
353
|
}
|
|
337
354
|
|
|
@@ -357,6 +374,14 @@ const encodeNotations = (marks: Mark[], level: number): string => {
|
|
|
357
374
|
xml += `${indent(level + 1)}</ornaments>\n`;
|
|
358
375
|
}
|
|
359
376
|
|
|
377
|
+
if (technical.length > 0) {
|
|
378
|
+
xml += `${indent(level + 1)}<technical>\n`;
|
|
379
|
+
for (const tech of technical) {
|
|
380
|
+
xml += `${indent(level + 2)}${tech}\n`;
|
|
381
|
+
}
|
|
382
|
+
xml += `${indent(level + 1)}</technical>\n`;
|
|
383
|
+
}
|
|
384
|
+
|
|
360
385
|
xml += `${indent(level)}</notations>\n`;
|
|
361
386
|
|
|
362
387
|
return xml;
|
|
@@ -518,10 +543,19 @@ const encodeRestWithTuplet = (
|
|
|
518
543
|
*/
|
|
519
544
|
const encodeDirection = (
|
|
520
545
|
marks: Mark[],
|
|
521
|
-
level: number
|
|
546
|
+
level: number,
|
|
547
|
+
staff: number = 0,
|
|
548
|
+
emitStaff: boolean = false
|
|
522
549
|
): string => {
|
|
523
550
|
let xml = '';
|
|
524
551
|
|
|
552
|
+
// In a grand-staff part, a <direction> must name its <staff> or the decoder
|
|
553
|
+
// can't route the mark back to the right staff (it would attach to the current
|
|
554
|
+
// voice/staff and drift — observed on chopin dynamics and cross-measure pedals).
|
|
555
|
+
const staffTag = (emitStaff && staff > 0)
|
|
556
|
+
? `${indent(level + 1)}<staff>${staff}</staff>\n`
|
|
557
|
+
: '';
|
|
558
|
+
|
|
525
559
|
for (const mark of marks) {
|
|
526
560
|
if (mark.markType === 'dynamic') {
|
|
527
561
|
const dynXml = DYNAMIC_TO_XML[mark.type];
|
|
@@ -532,6 +566,7 @@ const encodeDirection = (
|
|
|
532
566
|
xml += `${indent(level + 3)}<${dynXml}/>\n`;
|
|
533
567
|
xml += `${indent(level + 2)}</dynamics>\n`;
|
|
534
568
|
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
569
|
+
xml += staffTag;
|
|
535
570
|
xml += `${indent(level)}</direction>\n`;
|
|
536
571
|
}
|
|
537
572
|
} else if (mark.markType === 'hairpin') {
|
|
@@ -548,6 +583,7 @@ const encodeDirection = (
|
|
|
548
583
|
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
549
584
|
xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
|
|
550
585
|
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
586
|
+
xml += staffTag;
|
|
551
587
|
xml += `${indent(level)}</direction>\n`;
|
|
552
588
|
}
|
|
553
589
|
} else if (mark.markType === 'pedal') {
|
|
@@ -562,8 +598,28 @@ const encodeDirection = (
|
|
|
562
598
|
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
563
599
|
xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
|
|
564
600
|
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
601
|
+
xml += staffTag;
|
|
565
602
|
xml += `${indent(level)}</direction>\n`;
|
|
566
603
|
}
|
|
604
|
+
} else if (mark.markType === 'markup') {
|
|
605
|
+
// Text direction → <words>. The decoder reads any non-tempo <words> back
|
|
606
|
+
// as a markup mark (text-as-markup convention).
|
|
607
|
+
const placement = mark.placement ? ` placement="${mark.placement}"` : '';
|
|
608
|
+
xml += `${indent(level)}<direction${placement}>\n`;
|
|
609
|
+
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
610
|
+
xml += `${indent(level + 2)}<words>${escapeXml(mark.content)}</words>\n`;
|
|
611
|
+
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
612
|
+
xml += staffTag;
|
|
613
|
+
xml += `${indent(level)}</direction>\n`;
|
|
614
|
+
} else if (mark.markType === 'navigation') {
|
|
615
|
+
// \coda / \segno glyph → <direction-type><coda|segno>.
|
|
616
|
+
const glyph = mark.type === NavigationMarkType.coda ? 'coda' : 'segno';
|
|
617
|
+
xml += `${indent(level)}<direction>\n`;
|
|
618
|
+
xml += `${indent(level + 1)}<direction-type>\n`;
|
|
619
|
+
xml += `${indent(level + 2)}<${glyph}/>\n`;
|
|
620
|
+
xml += `${indent(level + 1)}</direction-type>\n`;
|
|
621
|
+
xml += staffTag;
|
|
622
|
+
xml += `${indent(level)}</direction>\n`;
|
|
567
623
|
}
|
|
568
624
|
}
|
|
569
625
|
|
|
@@ -718,12 +774,14 @@ const encodeMeasure = (
|
|
|
718
774
|
for (const event of voice.events) {
|
|
719
775
|
switch (event.type) {
|
|
720
776
|
case 'note': {
|
|
721
|
-
// Check for direction marks (dynamics, hairpins, pedals)
|
|
777
|
+
// Check for direction marks (dynamics, hairpins, pedals, text, nav)
|
|
722
778
|
const directionMarks = event.marks?.filter(m =>
|
|
723
|
-
m.markType === 'dynamic' || m.markType === 'hairpin' ||
|
|
779
|
+
m.markType === 'dynamic' || m.markType === 'hairpin' ||
|
|
780
|
+
m.markType === 'pedal' || m.markType === 'markup' ||
|
|
781
|
+
m.markType === 'navigation'
|
|
724
782
|
) || [];
|
|
725
783
|
if (directionMarks.length > 0) {
|
|
726
|
-
xml += encodeDirection(directionMarks, level + 1);
|
|
784
|
+
xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
|
|
727
785
|
}
|
|
728
786
|
|
|
729
787
|
// Encode main note
|
|
@@ -731,11 +789,15 @@ const encodeMeasure = (
|
|
|
731
789
|
const dur = calculateDuration(event.duration);
|
|
732
790
|
voicePosition += dur;
|
|
733
791
|
|
|
734
|
-
// Encode chord notes
|
|
792
|
+
// Encode chord notes. Marks live on the parent NoteEvent (the whole
|
|
793
|
+
// chord) and are emitted on the first note only — re-emitting them on
|
|
794
|
+
// each member would multiply them on decode (the decoder aggregates
|
|
795
|
+
// every member's marks back onto the one chord event).
|
|
735
796
|
for (let i = 1; i < event.pitches.length; i++) {
|
|
736
797
|
const chordEvent: NoteEvent = {
|
|
737
798
|
...event,
|
|
738
799
|
pitches: [event.pitches[i]],
|
|
800
|
+
marks: undefined,
|
|
739
801
|
};
|
|
740
802
|
xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
|
|
741
803
|
}
|
|
@@ -773,6 +835,17 @@ const encodeMeasure = (
|
|
|
773
835
|
const isLast = ti === tupletEvents.length - 1;
|
|
774
836
|
|
|
775
837
|
if (subEvent.type === 'note') {
|
|
838
|
+
// Direction marks (dynamics, hairpins, pedals, text, nav) — same
|
|
839
|
+
// as the plain-note case; without this they vanish inside tuplets.
|
|
840
|
+
const directionMarks = subEvent.marks?.filter(m =>
|
|
841
|
+
m.markType === 'dynamic' || m.markType === 'hairpin' ||
|
|
842
|
+
m.markType === 'pedal' || m.markType === 'markup' ||
|
|
843
|
+
m.markType === 'navigation'
|
|
844
|
+
) || [];
|
|
845
|
+
if (directionMarks.length > 0) {
|
|
846
|
+
xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
|
|
847
|
+
}
|
|
848
|
+
|
|
776
849
|
// Add tuplet notation marks
|
|
777
850
|
const tupletMarks: Mark[] = [];
|
|
778
851
|
if (isFirst) tupletMarks.push({ markType: 'tuplet', start: true } as any);
|
|
@@ -59,10 +59,13 @@ export interface MusicXmlNotations {
|
|
|
59
59
|
beams?: { type: 'begin' | 'continue' | 'end'; number: number }[];
|
|
60
60
|
articulations?: string[]; // staccato, accent, tenuto, etc.
|
|
61
61
|
ornaments?: string[]; // trill, turn, mordent, etc.
|
|
62
|
-
fermata?:
|
|
62
|
+
fermata?: 'normal' | 'short'; // 'short' = angled shape (LilyPond \shortfermata)
|
|
63
63
|
arpeggiate?: boolean;
|
|
64
64
|
tremolo?: { type: 'single' | 'start' | 'stop'; value: number };
|
|
65
65
|
tuplet?: { type: 'start' | 'stop'; number: number };
|
|
66
|
+
// Glissando / slide spanner: only the start matters for lilylet (LilyPond emits
|
|
67
|
+
// \glissando on the start note; the line auto-connects to the next note).
|
|
68
|
+
glissando?: boolean;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export interface MusicXmlNote {
|
|
@@ -121,6 +124,7 @@ export interface MusicXmlDirection {
|
|
|
121
124
|
pedal?: MusicXmlPedal;
|
|
122
125
|
metronome?: MusicXmlMetronome;
|
|
123
126
|
words?: MusicXmlWords[];
|
|
127
|
+
rehearsal?: string; // <direction-type><rehearsal> text (e.g. "A", "段")
|
|
124
128
|
octaveShift?: MusicXmlOctaveShift;
|
|
125
129
|
coda?: boolean;
|
|
126
130
|
segno?: boolean;
|
|
@@ -416,7 +416,7 @@ const DYNAMIC_MAP: Record<string, DynamicType> = {
|
|
|
416
416
|
fz: DynamicType.sfz,
|
|
417
417
|
sfp: DynamicType.sfz,
|
|
418
418
|
sfpp: DynamicType.sfz,
|
|
419
|
-
fp: DynamicType.
|
|
419
|
+
fp: DynamicType.fp, // forte-piano
|
|
420
420
|
};
|
|
421
421
|
|
|
422
422
|
export const convertDynamic = (name: string): DynamicType | undefined => {
|
|
@@ -166,6 +166,7 @@ const ORNAMENT_MAP: Record<string, string> = {
|
|
|
166
166
|
fermata: '\\fermata',
|
|
167
167
|
shortFermata: '\\shortfermata',
|
|
168
168
|
arpeggio: '\\arpeggio',
|
|
169
|
+
breath: '\\breathe',
|
|
169
170
|
};
|
|
170
171
|
|
|
171
172
|
|
|
@@ -301,6 +302,19 @@ const serializeMarks = (marks: Mark[]): string => {
|
|
|
301
302
|
case 'fingering':
|
|
302
303
|
parts.push('-' + mark.finger);
|
|
303
304
|
break;
|
|
305
|
+
case 'markup': {
|
|
306
|
+
// Text direction attached to a note (e.g. ^\markup "cantabile").
|
|
307
|
+
// Placement: above → ^, below → _, unspecified → - (default).
|
|
308
|
+
const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
|
|
309
|
+
parts.push(prefix + '\\markup "' + escapeString(mark.content) + '"');
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
case 'navigation':
|
|
313
|
+
parts.push(mark.type === 'coda' ? '\\coda' : '\\segno');
|
|
314
|
+
break;
|
|
315
|
+
case 'glissando':
|
|
316
|
+
parts.push('\\glissando');
|
|
317
|
+
break;
|
|
304
318
|
}
|
|
305
319
|
}
|
|
306
320
|
|
package/source/lilylet/types.ts
CHANGED
|
@@ -47,6 +47,7 @@ export enum OrnamentType {
|
|
|
47
47
|
fermata = 'fermata',
|
|
48
48
|
shortFermata = 'shortFermata',
|
|
49
49
|
arpeggio = 'arpeggio',
|
|
50
|
+
breath = 'breath', // breath mark (\breathe) — MusicXML <breath-mark>
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export enum DynamicType {
|
|
@@ -187,7 +188,15 @@ export interface MarkupMark {
|
|
|
187
188
|
placement?: Placement;
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
|
|
191
|
+
// Glissando / slide: a note-to-note slide line. Mirrors LilyPond's \glissando,
|
|
192
|
+
// which is emitted only on the START note (the line auto-connects to the next
|
|
193
|
+
// note), so this is a single mark — no start/stop pairing. MusicXML <glissando>
|
|
194
|
+
// and <slide> both decode to this.
|
|
195
|
+
export interface Glissando {
|
|
196
|
+
markType: 'glissando';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark | Glissando;
|
|
191
200
|
|
|
192
201
|
// === Key Signature ===
|
|
193
202
|
|