@k-l-lambda/lilylet 0.1.67 → 0.1.69
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 +155 -135
- package/lib/lilylet/abcDecoder.d.ts +12 -6
- package/lib/lilylet/abcDecoder.js +161 -39
- package/lib/lilylet/grammar.jison.js +195 -185
- package/lib/lilylet/meiEncoder.js +31 -6
- package/lib/lilylet/serializer.js +76 -31
- package/lib/lilylet/types.d.ts +5 -1
- package/package.json +1 -2
- package/source/abc/abc.jison +38 -14
- package/source/abc/grammar.jison.js +155 -135
- package/source/lilylet/abcDecoder.ts +173 -33
- package/source/lilylet/grammar.jison.js +195 -185
- package/source/lilylet/lilylet.jison +8 -0
- package/source/lilylet/meiEncoder.ts +36 -8
- package/source/lilylet/serializer.ts +82 -30
- package/source/lilylet/types.ts +6 -1
|
@@ -538,7 +538,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
538
538
|
// Convert RestEvent to MEI
|
|
539
539
|
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
|
|
540
540
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
541
|
-
|
|
541
|
+
const restId = generateId('rest');
|
|
542
|
+
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
542
543
|
if (event.duration.dots > 0)
|
|
543
544
|
attrs += ` dots="${event.duration.dots}"`;
|
|
544
545
|
// Cross-staff attribute
|
|
@@ -551,13 +552,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
551
552
|
}
|
|
552
553
|
// Space rest (invisible)
|
|
553
554
|
if (event.invisible) {
|
|
554
|
-
return `${indent}<space ${attrs} />\n
|
|
555
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
555
556
|
}
|
|
556
557
|
// Full measure rest
|
|
557
558
|
if (event.fullMeasure) {
|
|
558
|
-
|
|
559
|
+
const mRestId = generateId('mrest');
|
|
560
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
559
561
|
}
|
|
560
|
-
return `${indent}<rest ${attrs} />\n
|
|
562
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
561
563
|
};
|
|
562
564
|
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
563
565
|
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
@@ -645,7 +647,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
645
647
|
}
|
|
646
648
|
else if (e.type === 'rest') {
|
|
647
649
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
648
|
-
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
650
|
+
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
|
|
649
651
|
}
|
|
650
652
|
else if (e.type === 'context') {
|
|
651
653
|
const ctx = e;
|
|
@@ -809,6 +811,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
809
811
|
const barlines = [];
|
|
810
812
|
const markups = [];
|
|
811
813
|
const pendingMarkups = [];
|
|
814
|
+
const pendingDynamics = [];
|
|
812
815
|
// Track current stem direction from context changes
|
|
813
816
|
let currentStemDirection = undefined;
|
|
814
817
|
// Track current staff for cross-staff notation
|
|
@@ -824,6 +827,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
824
827
|
}
|
|
825
828
|
pendingMarkups.length = 0;
|
|
826
829
|
};
|
|
830
|
+
// Helper to flush pending leading dynamics onto a note ID
|
|
831
|
+
const flushPendingDynamics = (noteId) => {
|
|
832
|
+
for (const label of pendingDynamics) {
|
|
833
|
+
dynamics.push({ startid: noteId, label });
|
|
834
|
+
}
|
|
835
|
+
pendingDynamics.length = 0;
|
|
836
|
+
};
|
|
827
837
|
// Helper to check if pitches match for tie continuation
|
|
828
838
|
const pitchesMatch = (p1, p2) => {
|
|
829
839
|
if (p1.length !== p2.length)
|
|
@@ -884,6 +894,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
884
894
|
}
|
|
885
895
|
// Flush any pending markups onto this note
|
|
886
896
|
flushPendingMarkups(result.elementId);
|
|
897
|
+
// Flush any pending leading dynamics onto this note
|
|
898
|
+
flushPendingDynamics(result.elementId);
|
|
887
899
|
// If there's a pending ottava, start the span on this note
|
|
888
900
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
889
901
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -996,7 +1008,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
996
1008
|
case 'rest': {
|
|
997
1009
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
998
1010
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
999
|
-
|
|
1011
|
+
const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1012
|
+
xml += restResult.xml;
|
|
1013
|
+
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1014
|
+
flushPendingMarkups(restResult.elementId);
|
|
1015
|
+
flushPendingDynamics(restResult.elementId);
|
|
1016
|
+
lastNoteId = restResult.elementId;
|
|
1000
1017
|
break;
|
|
1001
1018
|
}
|
|
1002
1019
|
case 'tuplet':
|
|
@@ -1012,6 +1029,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1012
1029
|
// Flush any pending markups onto the first note of the tuplet
|
|
1013
1030
|
if (tupletResult.firstNoteId) {
|
|
1014
1031
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1032
|
+
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1015
1033
|
lastNoteId = tupletResult.firstNoteId;
|
|
1016
1034
|
}
|
|
1017
1035
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
@@ -1127,6 +1145,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1127
1145
|
}
|
|
1128
1146
|
}
|
|
1129
1147
|
break;
|
|
1148
|
+
case 'dynamic':
|
|
1149
|
+
{
|
|
1150
|
+
// Standalone (leading) dynamic - attaches to the following note
|
|
1151
|
+
const dynEvent = event;
|
|
1152
|
+
pendingDynamics.push(dynEvent.dynamicType);
|
|
1153
|
+
}
|
|
1154
|
+
break;
|
|
1130
1155
|
}
|
|
1131
1156
|
// Close beam element if beam ends
|
|
1132
1157
|
if (beamEnd) {
|
|
@@ -199,11 +199,12 @@ const serializeMarks = (marks) => {
|
|
|
199
199
|
return parts.join('');
|
|
200
200
|
};
|
|
201
201
|
// Serialize a note event with pitch environment tracking
|
|
202
|
-
const serializeNoteEvent = (event, env, prevDuration) => {
|
|
202
|
+
const serializeNoteEvent = (event, env, prevDuration, suppressGracePrefix = false) => {
|
|
203
203
|
const parts = [];
|
|
204
204
|
let currentEnv = env;
|
|
205
|
-
// Grace note prefix
|
|
206
|
-
|
|
205
|
+
// Grace note prefix. When the caller groups consecutive grace notes into a single
|
|
206
|
+
// scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
|
|
207
|
+
if (event.grace && !suppressGracePrefix) {
|
|
207
208
|
parts.push('\\grace ');
|
|
208
209
|
}
|
|
209
210
|
// Single note or chord
|
|
@@ -440,10 +441,10 @@ const serializeBarlineEvent = (event) => {
|
|
|
440
441
|
return '';
|
|
441
442
|
};
|
|
442
443
|
// Serialize a single event with pitch environment tracking
|
|
443
|
-
const serializeEvent = (event, env, prevDuration) => {
|
|
444
|
+
const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) => {
|
|
444
445
|
switch (event.type) {
|
|
445
446
|
case 'note':
|
|
446
|
-
return serializeNoteEvent(event, env, prevDuration);
|
|
447
|
+
return serializeNoteEvent(event, env, prevDuration, suppressGracePrefix);
|
|
447
448
|
case 'rest':
|
|
448
449
|
return serializeRestEvent(event, env, prevDuration);
|
|
449
450
|
case 'context':
|
|
@@ -455,12 +456,25 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
455
456
|
return serializeTremoloEvent(event, env);
|
|
456
457
|
case 'barline':
|
|
457
458
|
return { str: serializeBarlineEvent(event), newEnv: env };
|
|
459
|
+
case 'markup': {
|
|
460
|
+
const mk = event;
|
|
461
|
+
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
462
|
+
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
463
|
+
}
|
|
464
|
+
case 'dynamic': {
|
|
465
|
+
const dynStr = DYNAMIC_MAP[event.dynamicType];
|
|
466
|
+
return { str: dynStr || '', newEnv: env };
|
|
467
|
+
}
|
|
458
468
|
default:
|
|
459
469
|
return { str: '', newEnv: env };
|
|
460
470
|
}
|
|
461
471
|
};
|
|
462
|
-
// Find
|
|
472
|
+
// Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
|
|
473
|
+
// a clef context event that appears before any musical event on the home staff.
|
|
474
|
+
// A clef that first appears AFTER a note is a mid-voice change and must be emitted
|
|
475
|
+
// inline where it occurs, not hoisted to the front — so it is not returned here.
|
|
463
476
|
const findVoiceClef = (voice) => {
|
|
477
|
+
const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
464
478
|
let activeStaff = voice.staff;
|
|
465
479
|
for (const event of voice.events) {
|
|
466
480
|
if (event.type === 'context') {
|
|
@@ -471,6 +485,11 @@ const findVoiceClef = (voice) => {
|
|
|
471
485
|
return ctx.clef;
|
|
472
486
|
}
|
|
473
487
|
}
|
|
488
|
+
else if (MUSICAL.has(event.type)) {
|
|
489
|
+
// Reached music on the home staff before any clef — no leading clef.
|
|
490
|
+
if (activeStaff === voice.staff)
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
474
493
|
}
|
|
475
494
|
return undefined;
|
|
476
495
|
};
|
|
@@ -490,7 +509,6 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
490
509
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
491
510
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
492
511
|
// context{staff} events before this index are skipped in the main loop.
|
|
493
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
494
512
|
let effectiveInitialStaff = voice.staff;
|
|
495
513
|
let leadStaffScanEnd = 0;
|
|
496
514
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -516,8 +534,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
516
534
|
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
517
535
|
continue;
|
|
518
536
|
}
|
|
519
|
-
|
|
520
|
-
|
|
537
|
+
// Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
|
|
538
|
+
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
539
|
+
break;
|
|
521
540
|
}
|
|
522
541
|
// Output staff command if voice staff differs from current parser staff,
|
|
523
542
|
// or always output if it's a grand staff score for clarity.
|
|
@@ -545,18 +564,20 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
545
564
|
parts.push('\\time ' + numerator + '/' + denominator);
|
|
546
565
|
}
|
|
547
566
|
}
|
|
548
|
-
// Output clef only if not yet emitted or changed for this staff
|
|
549
|
-
|
|
567
|
+
// Output clef only if not yet emitted or changed for this staff.
|
|
568
|
+
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
569
|
+
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
570
|
+
// hoisted here — it is emitted inline at its position.
|
|
571
|
+
const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
|
|
550
572
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
551
573
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
552
574
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
553
575
|
if (emittedClefs)
|
|
554
576
|
emittedClefs[voice.staff] = voiceClef;
|
|
555
577
|
}
|
|
556
|
-
// Skip redundant clef context events if this staff's clef is already established
|
|
557
|
-
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
558
578
|
let activeStaff = effectiveInitialStaff;
|
|
559
579
|
let activeStemDir;
|
|
580
|
+
let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
|
|
560
581
|
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
561
582
|
const event = voice.events[eventIdx];
|
|
562
583
|
// Skip leading context-staff events already absorbed into effectiveInitialStaff
|
|
@@ -581,8 +602,11 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
581
602
|
if (ctx.staff && !ctx.clef && !ctx.ottava)
|
|
582
603
|
continue; // same staff, pure no-op
|
|
583
604
|
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
584
|
-
// Skip clef-only context
|
|
585
|
-
|
|
605
|
+
// Skip a clef-only context event only if it is REDUNDANT — i.e. it restates the
|
|
606
|
+
// clef already active for this staff. A clef that differs is a genuine change and
|
|
607
|
+
// must be emitted inline at its position.
|
|
608
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
|
|
609
|
+
emittedClefs?.[ctx.staff || activeStaff] === ctx.clef) {
|
|
586
610
|
continue;
|
|
587
611
|
}
|
|
588
612
|
}
|
|
@@ -617,7 +641,18 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
617
641
|
activeStemDir = stemDir;
|
|
618
642
|
}
|
|
619
643
|
}
|
|
620
|
-
const
|
|
644
|
+
const isGraceNote = event.type === 'note' && !!event.grace;
|
|
645
|
+
// Group consecutive grace notes into one scoped \grace { ... } instead of
|
|
646
|
+
// emitting a separate \grace prefix per note.
|
|
647
|
+
if (isGraceNote && !graceGroupOpen) {
|
|
648
|
+
parts.push('\\grace {');
|
|
649
|
+
graceGroupOpen = true;
|
|
650
|
+
}
|
|
651
|
+
else if (!isGraceNote && graceGroupOpen) {
|
|
652
|
+
parts.push('}');
|
|
653
|
+
graceGroupOpen = false;
|
|
654
|
+
}
|
|
655
|
+
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
|
|
621
656
|
pitchEnv = newEnv;
|
|
622
657
|
if (eventStr) {
|
|
623
658
|
parts.push(eventStr);
|
|
@@ -641,6 +676,10 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
641
676
|
emittedClefs[ctx.staff || activeStaff] = ctx.clef;
|
|
642
677
|
}
|
|
643
678
|
}
|
|
679
|
+
// Close a grace group left open at the end of the voice (unusual but possible).
|
|
680
|
+
if (graceGroupOpen) {
|
|
681
|
+
parts.push('}');
|
|
682
|
+
}
|
|
644
683
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
645
684
|
};
|
|
646
685
|
// Serialize a part, tracking staff state across voices
|
|
@@ -775,25 +814,31 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
775
814
|
if (measure.timeSig) {
|
|
776
815
|
currentTime = measure.timeSig;
|
|
777
816
|
}
|
|
778
|
-
// Collect clefs from this measure's voices, per part
|
|
779
|
-
measure
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
817
|
+
// Collect clefs from this measure's voices, per part — but only AFTER serializing
|
|
818
|
+
// the measure, so that during serialization allStaffClefs reflects the clef state
|
|
819
|
+
// CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
|
|
820
|
+
// to the measure's front; it is emitted inline at its position).
|
|
821
|
+
const collectClefs = () => {
|
|
822
|
+
measure.parts.forEach((part, pi) => {
|
|
823
|
+
const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
|
|
824
|
+
for (const voice of part.voices) {
|
|
825
|
+
let clefActiveStaff = voice.staff;
|
|
826
|
+
for (const event of voice.events) {
|
|
827
|
+
if (event.type === 'context') {
|
|
828
|
+
const ctx = event;
|
|
829
|
+
if (ctx.staff) {
|
|
830
|
+
clefActiveStaff = ctx.staff;
|
|
831
|
+
}
|
|
832
|
+
if (ctx.clef) {
|
|
833
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
834
|
+
}
|
|
791
835
|
}
|
|
792
836
|
}
|
|
793
837
|
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
838
|
+
});
|
|
839
|
+
};
|
|
796
840
|
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
841
|
+
collectClefs();
|
|
797
842
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
798
843
|
measureStrs.push(measureStr || 's1');
|
|
799
844
|
currentStaff = newStaff;
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -221,7 +221,11 @@ export interface MarkupEvent {
|
|
|
221
221
|
content: string;
|
|
222
222
|
placement?: Placement;
|
|
223
223
|
}
|
|
224
|
-
export
|
|
224
|
+
export interface DynamicEvent {
|
|
225
|
+
type: 'dynamic';
|
|
226
|
+
dynamicType: DynamicType;
|
|
227
|
+
}
|
|
228
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
|
|
225
229
|
export interface Voice {
|
|
226
230
|
staff: number;
|
|
227
231
|
events: Event[];
|
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.69",
|
|
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",
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"test": "tsx ./tests/parser.ts",
|
|
31
31
|
"test:mei": "tsx ./tests/mei.ts",
|
|
32
32
|
"test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
|
|
33
|
-
"test:unit": "tsx ./tests/unit/encodePitch.test.ts",
|
|
34
33
|
"test:partial": "tsx ./tests/unit/partialWarning.test.ts",
|
|
35
34
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
36
35
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
package/source/abc/abc.jison
CHANGED
|
@@ -37,14 +37,14 @@
|
|
|
37
37
|
|
|
38
38
|
const patch = (terms, bar) => {
|
|
39
39
|
const control = {};
|
|
40
|
-
terms.forEach(term => {
|
|
40
|
+
(terms || []).forEach(term => {
|
|
41
41
|
if (term.control)
|
|
42
42
|
control[term.control.name] = term.control.value;
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
return {
|
|
46
46
|
control,
|
|
47
|
-
terms,
|
|
47
|
+
terms: terms || [],
|
|
48
48
|
bar,
|
|
49
49
|
};
|
|
50
50
|
};
|
|
@@ -195,10 +195,10 @@ Am \b[A-G](?=[m][a][j]|[m][i][n]\b)
|
|
|
195
195
|
a \b[a-g](?=[\W\d\sA-Ga-g_yzHJLMOPRSTuv]*\b)
|
|
196
196
|
z \b[z]
|
|
197
197
|
Z \b[Z]
|
|
198
|
-
x \b[x](?=[\W\d\s])
|
|
198
|
+
x \b[x](?=[\W\d\s]|[_^=]*[A-Ga-g])
|
|
199
199
|
y \b[y]
|
|
200
200
|
N [0-9]
|
|
201
|
-
P \b[HJLMOPRSTuv](?=[A-Ga-g][A-Ga-g0-
|
|
201
|
+
P \b[HJLMOPRSTuv](?=[_^=]*[A-Ga-g][A-Ga-g0-9_^=,']*)
|
|
202
202
|
PP \b[HJLMOPRSTuv](?=[xz!\[^_=\s"])
|
|
203
203
|
|
|
204
204
|
SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
@@ -211,8 +211,8 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
211
211
|
<string>\\\" return 'STR_CONTENT'
|
|
212
212
|
<string>[^"]+ return 'STR_CONTENT'
|
|
213
213
|
|
|
214
|
-
^[T][:][\s]* { this.pushState('title_string'); return 'T:'; }
|
|
215
|
-
^[C][:][\s]* { this.pushState('title_string'); return 'C:'; }
|
|
214
|
+
^[T][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'T:'; } this.unput(yytext.slice(1)); yytext = 'T'; return 'T'; }
|
|
215
|
+
^[C][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'C:'; } this.unput(yytext.slice(1)); yytext = 'C'; return 'A'; }
|
|
216
216
|
<title_string>\n { this.popState(); }
|
|
217
217
|
<title_string>[^\n]+ return 'STR_CONTENT'
|
|
218
218
|
|
|
@@ -226,10 +226,10 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
226
226
|
<voice_header>[ \t]+ {}
|
|
227
227
|
<voice_header>\n { this.popState(); }
|
|
228
228
|
<voice_header>\] { this.popState(); return ']'; }
|
|
229
|
-
<key_signature>"treble"
|
|
230
|
-
<key_signature>"bass"
|
|
231
|
-
<key_signature>"tenor"
|
|
232
|
-
<key_signature>"alto"
|
|
229
|
+
<key_signature>"treble"[0-9]* return 'TREBLE';
|
|
230
|
+
<key_signature>"bass"[0-9]* return 'BASS';
|
|
231
|
+
<key_signature>"tenor"[0-9]* return 'TENOR';
|
|
232
|
+
<key_signature>"alto"[0-9]* return 'ALTO';
|
|
233
233
|
<key_signature>"none" return 'NAME';
|
|
234
234
|
<key_signature>"Dor" return 'NAME';
|
|
235
235
|
<key_signature>"Phr" return 'NAME';
|
|
@@ -239,7 +239,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
239
239
|
<key_signature>"Loc" return 'NAME';
|
|
240
240
|
<key_signature>"HP" return 'NAME';
|
|
241
241
|
<key_signature>"Hp" return 'NAME';
|
|
242
|
-
<key_signature>[a-z]+[ \t]
|
|
242
|
+
<key_signature>[a-z]+[ \t]*[=][^\n\]]* {}
|
|
243
243
|
<key_signature>[A-G] return 'A';
|
|
244
244
|
<key_signature>[A-Z][a-z]+ return 'NAME';
|
|
245
245
|
<key_signature>[b] return 'FLAT';
|
|
@@ -262,9 +262,10 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
262
262
|
<spec_comment_name>[\w]+ { this.popState(); this.pushState('spec_comment_skip'); }
|
|
263
263
|
<spec_comment_name>\n { this.popState(); this.popState(); }
|
|
264
264
|
<spec_comment>[ \t]+ {}
|
|
265
|
-
<spec_comment>[([{]
|
|
265
|
+
<spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
|
|
266
266
|
<spec_comment>[)\]}] { this._scoreDepth = (this._scoreDepth || 0) - 1; return yytext; }
|
|
267
267
|
<spec_comment>[|] return yytext
|
|
268
|
+
<spec_comment>[A-Z][:] { this.popState(); this.popState(); this.unput(yytext); return 'LAYOUT_END'; }
|
|
268
269
|
<spec_comment>[\w]+ return 'NN'
|
|
269
270
|
<spec_comment>\n { if (this._scoreDepth > 0) { /* layout continues on next line */ } else { this.popState(); this.popState(); return 'LAYOUT_END'; } }
|
|
270
271
|
<spec_comment_skip>[^\n]+ {}
|
|
@@ -467,6 +468,7 @@ numeric_tempo
|
|
|
467
468
|
|
|
468
469
|
voice_exp
|
|
469
470
|
: number -> voice($1)
|
|
471
|
+
| number assigns -> voice($1, null, $2)
|
|
470
472
|
| number NAME -> voice($1, $2)
|
|
471
473
|
| number NAME assigns -> voice($1, $2, $3)
|
|
472
474
|
| number NAME plus_minus_number -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3))
|
|
@@ -539,10 +541,18 @@ bar
|
|
|
539
541
|
| '|' ']' ':' -> '|]'
|
|
540
542
|
| ':' '|' ']' -> ':|]'
|
|
541
543
|
| '|' N -> '|' + $2
|
|
544
|
+
| '|' N volta_rest -> '|' + $2 + $3
|
|
542
545
|
| ':' '|' N -> ':|' + $2
|
|
546
|
+
| ':' '|' N volta_rest -> ':|' + $2 + $3
|
|
543
547
|
| '&' -> '&'
|
|
544
548
|
;
|
|
545
549
|
|
|
550
|
+
// Additional volta endings after the first number, comma-separated: "1,2", "1,2,3".
|
|
551
|
+
volta_rest
|
|
552
|
+
: ',' N -> ',' + $2
|
|
553
|
+
| volta_rest ',' N -> $1 + ',' + $3
|
|
554
|
+
;
|
|
555
|
+
|
|
546
556
|
music
|
|
547
557
|
: %empty -> []
|
|
548
558
|
| music expressive_mark -> $1 ? [...$1, $2] : [$2]
|
|
@@ -555,10 +565,17 @@ music
|
|
|
555
565
|
| music N -> $1
|
|
556
566
|
| music NAME -> $1
|
|
557
567
|
| music '^' NAME -> $1
|
|
558
|
-
| music '^'
|
|
568
|
+
| music '^' articulation_letter -> $1
|
|
559
569
|
| music '[' N -> $1
|
|
560
570
|
;
|
|
561
571
|
|
|
572
|
+
// Articulation-class letters (P macro: HJLMOPRSTuv). After a stray '^' inside a
|
|
573
|
+
// text annotation, a word like "Menuetto"/"Largo" starts with one of these and the
|
|
574
|
+
// lexer emits it as the articulation token, not NAME — swallow it like '^' NAME.
|
|
575
|
+
articulation_letter
|
|
576
|
+
: 'P' | 'T' | 'H' | 'J' | 'L' | 'M' | 'R' | 'O' | 'S' | 'u' | 'v'
|
|
577
|
+
;
|
|
578
|
+
|
|
562
579
|
control
|
|
563
580
|
: '[' H ':' header_value ']' -> ({control: header($2, $4)})
|
|
564
581
|
| '[' 'K:' header_value ']' -> ({control: header("K", $3)})
|
|
@@ -757,7 +774,14 @@ duration
|
|
|
757
774
|
: number '/' number -> frac(Number($1), Number($3))
|
|
758
775
|
| '/' number -> frac(1, Number($2))
|
|
759
776
|
| number -> frac(Number($1))
|
|
760
|
-
|
|
|
777
|
+
| slashes -> frac(1, Math.pow(2, $1))
|
|
778
|
+
| number slashes -> frac(Number($1), Math.pow(2, $2))
|
|
779
|
+
;
|
|
780
|
+
|
|
781
|
+
// One or more '/' halve the unit length each: '/'=1/2, '//'=1/4, '///'=1/8.
|
|
782
|
+
slashes
|
|
783
|
+
: '/' -> 1
|
|
784
|
+
| slashes '/' -> $1 + 1
|
|
761
785
|
;
|
|
762
786
|
|
|
763
787
|
broken_rhythm
|