@k-l-lambda/lilylet 0.1.74 → 0.1.76

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.
@@ -7,6 +7,7 @@
7
7
  import { DOMParser } from '@xmldom/xmldom';
8
8
  import { HairpinType, NavigationMarkType, Placement, } from "./types.js";
9
9
  import { getElementText, getElementInt, getElements, getDirectChildren, getChildElements, getAttribute, getAttributeNumber, hasElement, convertPitch, convertDuration, convertKeySignature, convertClef, convertStemDirection, convertArticulation, convertOrnament, convertDynamic, convertPedal, convertBarlineStyle, convertHarmonyToText, createFraction, TYPE_TO_DIVISION, } from "./musicXmlUtils.js";
10
+ import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
10
11
  // ============ Spanner Tracker ============
11
12
  /**
12
13
  * Track spanners (slurs, ties, wedges) by number attribute.
@@ -476,6 +477,31 @@ const parseDirection = (dirEl) => {
476
477
  const result = {};
477
478
  result.placement = getAttribute(dirEl, 'placement');
478
479
  result.staff = getElementInt(dirEl, 'staff');
480
+ // Navigation jumps live on the <sound> child of <direction> (a sibling of
481
+ // <direction-type>). Parse it first, before the no-direction-type early return,
482
+ // so a sound-only direction still contributes its jump semantics.
483
+ const soundEl = dirEl.getElementsByTagName('sound')[0];
484
+ if (soundEl) {
485
+ const sound = {};
486
+ if (getAttribute(soundEl, 'dacapo') === 'yes')
487
+ sound.dacapo = true;
488
+ if (getAttribute(soundEl, 'fine') === 'yes')
489
+ sound.fine = true;
490
+ const dalsegno = getAttribute(soundEl, 'dalsegno');
491
+ if (dalsegno)
492
+ sound.dalsegno = dalsegno;
493
+ const segno = getAttribute(soundEl, 'segno');
494
+ if (segno)
495
+ sound.segno = segno;
496
+ const coda = getAttribute(soundEl, 'coda');
497
+ if (coda)
498
+ sound.coda = coda;
499
+ const tocoda = getAttribute(soundEl, 'tocoda');
500
+ if (tocoda)
501
+ sound.tocoda = tocoda;
502
+ if (Object.keys(sound).length > 0)
503
+ result.sound = sound;
504
+ }
479
505
  const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
480
506
  if (!dirTypeEl) {
481
507
  return result;
@@ -557,7 +583,8 @@ const parseBarline = (barlineEl) => {
557
583
  if (repeatEl) {
558
584
  const direction = getAttribute(repeatEl, 'direction');
559
585
  if (direction) {
560
- result.repeat = { direction };
586
+ const times = getAttributeNumber(repeatEl, 'times');
587
+ result.repeat = times !== undefined ? { direction, times } : { direction };
561
588
  }
562
589
  }
563
590
  const endingEl = barlineEl.getElementsByTagName('ending')[0];
@@ -864,7 +891,10 @@ const directionToMarks = (direction, spannerTracker) => {
864
891
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
865
892
  // ContextChange by directionToContextChange, so skip those here to avoid
866
893
  // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
867
- // directions are tempo too, never markup.
894
+ // directions are tempo too, never markup. Navigation text ("D.C. al Fine",
895
+ // "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
896
+ // markup glyph — the jump SEMANTICS are captured separately as measure-layout.
897
+ let emittedWords = false;
868
898
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
869
899
  const text = direction.words.map(w => w.text).join('').trim();
870
900
  if (text && !isTempoWord(text)) {
@@ -872,6 +902,26 @@ const directionToMarks = (direction, spannerTracker) => {
872
902
  : direction.placement === 'below' ? Placement.below
873
903
  : undefined;
874
904
  marks.push({ markType: 'markup', content: text, placement });
905
+ emittedWords = true;
906
+ }
907
+ }
908
+ // A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
909
+ // synthesize the conventional glyph text so the score still shows the marking.
910
+ // Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
911
+ // emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
912
+ // synthesizing, and only when the engraver left them implicit.
913
+ if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
914
+ const s = direction.sound;
915
+ const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
916
+ : s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
917
+ : s.tocoda ? 'To Coda'
918
+ : s.fine ? 'Fine'
919
+ : undefined;
920
+ if (label) {
921
+ const placement = direction.placement === 'above' ? Placement.above
922
+ : direction.placement === 'below' ? Placement.below
923
+ : undefined;
924
+ marks.push({ markType: 'markup', content: label, placement });
875
925
  }
876
926
  }
877
927
  return marks;
@@ -928,6 +978,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
928
978
  let key;
929
979
  let timeSig;
930
980
  let barline;
981
+ let leadingBarline;
931
982
  const harmonies = [];
932
983
  const clefs = new Map();
933
984
  // Pending marks from directions (to attach to next note), per voice
@@ -944,6 +995,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
944
995
  // layer (otherwise the encoder can't pair them and drops the span).
945
996
  const pendingContextChanges = [];
946
997
  let currentVoice = 1; // Track current voice for directions
998
+ // Mid-measure clef support: a staff can change clef partway through a measure
999
+ // (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
1000
+ // in an <attributes> block AFTER notes on that staff. We must attach it inline at
1001
+ // that point on the staff's currently-active voice, not collapse it into the
1002
+ // measure-start `clefs` map (which keeps only one clef per staff and emits it at
1003
+ // the bar start). Track the last voice that added a note on each staff, and which
1004
+ // staves already have notes this measure.
1005
+ const lastVoiceOnStaff = new Map();
1006
+ const staffHasNotes = new Set();
947
1007
  // Process all children in order
948
1008
  for (const child of getChildElements(measureEl)) {
949
1009
  const tagName = child.tagName;
@@ -968,7 +1028,32 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
968
1028
  for (const clefEntry of attrs.clefs) {
969
1029
  const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
970
1030
  if (clef) {
971
- clefs.set(clefEntry.staff, { type: 'context', clef });
1031
+ const staff = clefEntry.staff;
1032
+ if (staffHasNotes.has(staff)) {
1033
+ // Mid-measure clef change: this staff already has notes in this
1034
+ // measure, so the clef takes effect HERE, not at the bar start.
1035
+ // Append it inline on the staff's currently-active voice via
1036
+ // addEvent (which inserts a staff-switch context if that voice is
1037
+ // currently positioned on another staff — e.g. a cross-staff
1038
+ // voice — so the clef binds to `staff`, not the voice's current
1039
+ // staff). Zero duration: a clef does not advance time.
1040
+ const vNum = lastVoiceOnStaff.get(staff);
1041
+ if (vNum !== undefined) {
1042
+ // Tag the clef context with its staff explicitly. In a
1043
+ // cross-staff voice (where notes interleave staves) a bare
1044
+ // clef is ambiguous and is dropped/misplaced by the serializer;
1045
+ // the explicit staff makes it self-describing (serializer emits
1046
+ // `\staff "N" \clef ...`). addEvent still inserts a leading
1047
+ // staff switch when the voice is currently on another staff.
1048
+ voiceTracker.addEvent(vNum, { type: 'context', staff, clef }, 0, staff);
1049
+ }
1050
+ else {
1051
+ clefs.set(staff, { type: 'context', clef });
1052
+ }
1053
+ }
1054
+ else {
1055
+ clefs.set(staff, { type: 'context', clef });
1056
+ }
972
1057
  }
973
1058
  }
974
1059
  }
@@ -978,6 +1063,10 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
978
1063
  const voiceNum = note.voice;
979
1064
  const staffNum = note.staff || 1;
980
1065
  currentVoice = voiceNum;
1066
+ // Record that this staff now has notes this measure and which voice is
1067
+ // active on it, so a subsequent mid-measure <clef> attaches inline here.
1068
+ staffHasNotes.add(staffNum);
1069
+ lastVoiceOnStaff.set(staffNum, voiceNum);
981
1070
  // Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
982
1071
  // where notes go to tupletTracker but voice must be initialized for staff detection)
983
1072
  voiceTracker.getOrCreateVoice(voiceNum, staffNum);
@@ -1159,7 +1248,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1159
1248
  const barlineData = parseBarline(child);
1160
1249
  const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
1161
1250
  if (style && style !== '|') {
1162
- barline = { type: 'barline', style };
1251
+ // A `location="left"` barline (forward repeat at the measure front)
1252
+ // is carried back onto the previous measure's end bar by convertPart;
1253
+ // everything else is this measure's own trailing end bar.
1254
+ if (barlineData.location === 'left') {
1255
+ leadingBarline = { type: 'barline', style };
1256
+ }
1257
+ else {
1258
+ barline = { type: 'barline', style };
1259
+ }
1163
1260
  }
1164
1261
  }
1165
1262
  else if (tagName === 'harmony') {
@@ -1252,7 +1349,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1252
1349
  staff: voiceState.staff,
1253
1350
  });
1254
1351
  }
1255
- return { voiceMap, key, timeSig, barline, harmonies, clefs };
1352
+ return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
1256
1353
  };
1257
1354
  /**
1258
1355
  * Convert a MusicXML part to Lilylet measures
@@ -1268,10 +1365,27 @@ const convertPart = (partEl) => {
1268
1365
  let isFirstMeasure = true;
1269
1366
  let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
1270
1367
  const lastClefs = new Map(); // Track last clef per staff
1368
+ // Initial clef per staff declared in the FIRST <attributes> block. A staff whose
1369
+ // first voice does not appear until a later measure (e.g. an accompaniment staff
1370
+ // silent for the opening bars) would otherwise lose its initial clef — it is only
1371
+ // declared in measure 1's attributes, where that staff has no voice to attach it
1372
+ // to. We hold these and flush each onto the staff's FIRST appearing voice.
1373
+ const pendingInitialClefs = new Map();
1271
1374
  const measureEls = getDirectChildren(partEl, 'measure');
1272
1375
  for (const measureEl of measureEls) {
1273
1376
  voiceTracker.reset();
1274
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1377
+ const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1378
+ // A forward-repeat (or any `location="left"`) barline belongs at the END of
1379
+ // the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
1380
+ // Append it to the previous measure's first voice; if this is the first
1381
+ // measure there is no previous one, so a leading repeat at the very start of
1382
+ // the piece is conventionally dropped.
1383
+ if (leadingBarline && measures.length > 0) {
1384
+ const prevVoices = measures[measures.length - 1].parts[0]?.voices;
1385
+ if (prevVoices && prevVoices.length > 0) {
1386
+ prevVoices[0].events.push(leadingBarline);
1387
+ }
1388
+ }
1275
1389
  // Update running key/time
1276
1390
  if (key)
1277
1391
  lastKey = key;
@@ -1306,11 +1420,45 @@ const convertPart = (partEl) => {
1306
1420
  lastClefs.set(voiceData.staff, clef);
1307
1421
  }
1308
1422
  }
1423
+ pendingInitialClefs.delete(voiceData.staff);
1424
+ }
1425
+ else if (pendingInitialClefs.has(voiceData.staff)) {
1426
+ // This staff's clef was declared in an earlier measure where the staff
1427
+ // had no voice to carry it (its first appearance is delayed, or it is
1428
+ // an intermittently-empty cross-staff passage). This measure declares
1429
+ // no clef of its own — emit the carried-forward pending clef if it
1430
+ // differs from the clef last in effect for this staff.
1431
+ const pending = pendingInitialClefs.get(voiceData.staff);
1432
+ const lastClef = lastClefs.get(voiceData.staff);
1433
+ const isSameClef = lastClef &&
1434
+ lastClef.clef === pending.clef;
1435
+ if (!isSameClef) {
1436
+ events.push(pending);
1437
+ lastClefs.set(voiceData.staff, pending);
1438
+ }
1439
+ pendingInitialClefs.delete(voiceData.staff);
1309
1440
  }
1310
1441
  staffsWithClef.add(voiceData.staff);
1311
1442
  }
1312
1443
  // Add voice events
1313
1444
  events.push(...voiceData.events);
1445
+ // A mid-measure clef change was appended inline to this voice's events (it
1446
+ // does not pass through the measure-start `clefs` map). Scan for the LAST
1447
+ // clef per staff in this voice and update lastClefs, so the next measure's
1448
+ // dedup compares against the clef actually in effect — otherwise a later
1449
+ // measure restating the pre-change clef would be wrongly suppressed.
1450
+ {
1451
+ let scanStaff = voiceData.staff;
1452
+ for (const ev of voiceData.events) {
1453
+ if (ev.type === 'context') {
1454
+ const c = ev;
1455
+ if (c.staff != null)
1456
+ scanStaff = c.staff;
1457
+ if (c.clef)
1458
+ lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
1459
+ }
1460
+ }
1461
+ }
1314
1462
  // Add harmonies and barline to first voice only
1315
1463
  if (voiceNum === voiceNumbers[0]) {
1316
1464
  for (const h of harmonies) {
@@ -1332,6 +1480,16 @@ const convertPart = (partEl) => {
1332
1480
  else {
1333
1481
  lastVoiceStaff = voices[0].staff || 1;
1334
1482
  }
1483
+ // Carry forward any clef declared this measure for a staff that had NO voice to
1484
+ // attach it to (an intermittently-empty staff — e.g. cross-staff passages where
1485
+ // the LH is written entirely on the upper staff for several bars). Remember it
1486
+ // as pending so it is emitted when that staff next reappears; otherwise the clef
1487
+ // (and any change it represents) is lost when this measure's `clefs` map is
1488
+ // discarded. Generalizes the first-measure seeding above to every measure.
1489
+ for (const [staff, clef] of clefs) {
1490
+ if (!staffsWithClef.has(staff))
1491
+ pendingInitialClefs.set(staff, clef);
1492
+ }
1335
1493
  const measure = {
1336
1494
  parts: [{
1337
1495
  voices,
@@ -1450,6 +1608,14 @@ export const decode = (input) => {
1450
1608
  const result = {
1451
1609
  measures: mergedMeasures,
1452
1610
  };
1611
+ // Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
1612
+ // from the first part's barline + <sound> navigation markup → measure-layout
1613
+ // string. The MEI encoder turns this into an <expansion> that verovio unfolds
1614
+ // for MIDI. Repeat markup is part-global; the first part carries it. Never
1615
+ // throws (returns undefined when there is nothing to unfold).
1616
+ const measureLayout = measureLayoutFromPart(partEls[0]);
1617
+ if (measureLayout)
1618
+ metadata.measureLayout = measureLayout;
1453
1619
  if (Object.keys(metadata).length > 0) {
1454
1620
  result.metadata = metadata;
1455
1621
  }
@@ -120,12 +120,21 @@ export interface MusicXmlDirection {
120
120
  octaveShift?: MusicXmlOctaveShift;
121
121
  coda?: boolean;
122
122
  segno?: boolean;
123
+ sound?: {
124
+ dacapo?: boolean;
125
+ dalsegno?: string;
126
+ segno?: string;
127
+ coda?: string;
128
+ tocoda?: string;
129
+ fine?: boolean;
130
+ };
123
131
  }
124
132
  export interface MusicXmlBarline {
125
133
  location?: 'left' | 'right' | 'middle';
126
134
  barStyle?: string;
127
135
  repeat?: {
128
136
  direction: 'forward' | 'backward';
137
+ times?: number;
129
138
  };
130
139
  ending?: {
131
140
  type: 'start' | 'stop' | 'discontinue';
@@ -5,6 +5,44 @@
5
5
  * Uses relative pitch mode matching the parser's behavior.
6
6
  */
7
7
  import { StemDirection, } from "./types.js";
8
+ import { parseMeasureLayout } from "./measureLayout.js";
9
+ // Walk a measure-layout AST, recording the FIRST measure index of each alternate
10
+ // ending (1st/2nd house) → its ending number, for the `% volta N` serializer
11
+ // comment convention. Only the alternates of a VoltaMLayout produce labels.
12
+ const collectVoltaLabels = (layout, out) => {
13
+ const firstIndexOf = (node) => {
14
+ switch (node.kind) {
15
+ case "single": return node.measure;
16
+ case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
17
+ case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
18
+ case "aba": return firstIndexOf(node.main);
19
+ }
20
+ };
21
+ const walk = (node) => {
22
+ switch (node.kind) {
23
+ case "block":
24
+ node.seq.forEach(walk);
25
+ break;
26
+ case "aba":
27
+ walk(node.main);
28
+ node.rest.forEach(walk);
29
+ break;
30
+ case "volta":
31
+ node.body.forEach(walk);
32
+ if (node.alternates) {
33
+ node.alternates.forEach((alt, ai) => {
34
+ const first = alt.length ? firstIndexOf(alt[0]) : undefined;
35
+ if (first !== undefined && !out.has(first))
36
+ out.set(first, ai + 1);
37
+ alt.forEach(walk);
38
+ });
39
+ }
40
+ break;
41
+ default: break;
42
+ }
43
+ };
44
+ walk(layout);
45
+ };
8
46
  const PHONETS = "cdefgab";
9
47
  /**
10
48
  * Calculate the octave markers needed to serialize a pitch in relative mode.
@@ -369,10 +407,20 @@ const serializeTupletEvent = (event, env) => {
369
407
  }
370
408
  else if (e.type === 'context') {
371
409
  const ctx = e;
410
+ // A context event inside a tuplet may carry several components at once
411
+ // (e.g. staff + clef). Emit each present one — previously only staff and
412
+ // stemDirection were handled, so a clef/ottava change inside a tuplet was
413
+ // silently dropped on serialization.
372
414
  if (ctx.staff != null) {
373
415
  parts.push(' \\staff "' + ctx.staff + '"');
374
416
  }
375
- else if (ctx.stemDirection != null) {
417
+ if (ctx.clef != null) {
418
+ parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
419
+ }
420
+ if (ctx.ottava != null) {
421
+ parts.push(' \\ottava #' + ctx.ottava);
422
+ }
423
+ if (ctx.stemDirection != null) {
376
424
  if (ctx.stemDirection === StemDirection.up)
377
425
  parts.push(' \\stemUp');
378
426
  else if (ctx.stemDirection === StemDirection.down)
@@ -459,7 +507,7 @@ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) =
459
507
  case 'markup': {
460
508
  const mk = event;
461
509
  const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
462
- return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
510
+ return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
463
511
  }
464
512
  case 'dynamic': {
465
513
  const dynStr = DYNAMIC_MAP[event.dynamicType];
@@ -511,6 +559,32 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
511
559
  // context{staff} events before this index are skipped in the main loop.
512
560
  let effectiveInitialStaff = voice.staff;
513
561
  let leadStaffScanEnd = 0;
562
+ // Staff of the first MUSICAL event (note/rest/...), honouring an explicit
563
+ // note.staff. If it differs from the home staff, the first inline staff switch
564
+ // would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
565
+ // binds home to the first `\staff` before any music), relocating the voice and
566
+ // any leading clef. We then force an explicit home-staff anchor up front. This
567
+ // scan is independent of leadStaffScanEnd because a leading clef/key stops that
568
+ // scan before the first note is reached.
569
+ let firstMusicalStaff = null;
570
+ {
571
+ let scanStaff = voice.staff;
572
+ for (const e of voice.events) {
573
+ if (e.type === 'context') {
574
+ const ctx = e;
575
+ if (ctx.staff != null)
576
+ scanStaff = ctx.staff;
577
+ continue;
578
+ }
579
+ if (e.type === 'pitchReset')
580
+ continue;
581
+ if (e.type === 'note')
582
+ firstMusicalStaff = e.staff ?? scanStaff;
583
+ else
584
+ firstMusicalStaff = scanStaff;
585
+ break;
586
+ }
587
+ }
514
588
  for (let i = 0; i < voice.events.length; i++) {
515
589
  const e = voice.events[i];
516
590
  if (e.type === 'pitchReset') {
@@ -538,10 +612,19 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
538
612
  // visible position; a staff switch after it must not be hoisted ahead of it.
539
613
  break;
540
614
  }
541
- // Output staff command if voice staff differs from current parser staff,
542
- // or always output if it's a grand staff score for clarity.
543
- // Use effectiveInitialStaff so carry-over replaces the default emission.
544
- if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
615
+ // Output staff command if the voice's initial staff is not what the PARSER will
616
+ // assume. The parser (lilylet.jison voice()) assigns every voice with no leading
617
+ // `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
618
+ // voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
619
+ // is not 1, even if the cross-measure carry (currentStaff) happens to match it
620
+ // (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
621
+ // this, the round-trip would silently relocate that voice, and its leading clef,
622
+ // to staff 1). Also emit when it differs from the carry (resets after grand staff)
623
+ // or always in a grand-staff part for clarity. Finally, force an anchor when the
624
+ // first note switches staff via note.staff (otherwise that inline `\staff` would
625
+ // be read as the leading staff and rebind the voice's home).
626
+ const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
627
+ if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
545
628
  parts.push('\\staff "' + effectiveInitialStaff + '"');
546
629
  }
547
630
  // Output key/time signatures after \staff (for first voice only)
@@ -568,7 +651,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
568
651
  // Prefer this voice's leading clef (a clef before any music); fall back to the
569
652
  // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
570
653
  // hoisted here — it is emitted inline at its position.
571
- const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
654
+ //
655
+ // The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
656
+ // the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
657
+ // with no clef of its own must only use the fallback to ESTABLISH a staff that has
658
+ // not been emitted yet — never to "restate" it once any sibling voice has already
659
+ // emitted/changed this staff's clef this measure, because the fallback would then be
660
+ // stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
661
+ // treble, then v2 with no clef must not re-emit the carried-in bass).
662
+ const ownClef = findVoiceClef(voice);
663
+ const carryClef = allStaffClefs?.[voice.staff];
664
+ const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
572
665
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
573
666
  if (voiceClef && !clefAlreadyEmitted) {
574
667
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
@@ -591,7 +684,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
591
684
  activeStaff = ctx.staff;
592
685
  parts.push('\\staff "' + activeStaff + '"');
593
686
  // Emit target staff clef if the event carries one or allStaffClefs knows it
594
- const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
687
+ const ctxClef = ctx.clef ||
688
+ (emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
595
689
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
596
690
  parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
597
691
  if (emittedClefs)
@@ -618,7 +712,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
618
712
  activeStaff = effectiveStaff;
619
713
  parts.push('\\staff "' + activeStaff + '"');
620
714
  // Emit the target staff's clef if it differs from what was last emitted for this staff
621
- const targetClef = allStaffClefs?.[activeStaff];
715
+ const targetClef = emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
622
716
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
623
717
  parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
624
718
  if (emittedClefs)
@@ -747,7 +841,12 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
747
841
  };
748
842
  // Escape string for serialization (quotes and backslashes)
749
843
  const escapeString = (str) => {
750
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
844
+ return str
845
+ .replace(/\\/g, '\\\\')
846
+ .replace(/"/g, '\\"')
847
+ .replace(/\n/g, '\\n')
848
+ .replace(/\r/g, '\\r')
849
+ .replace(/\t/g, '\\t');
751
850
  };
752
851
  // Serialize metadata
753
852
  const serializeMetadata = (metadata) => {
@@ -776,6 +875,9 @@ const serializeMetadata = (metadata) => {
776
875
  if (metadata.staves) {
777
876
  lines.push('[staves "' + escapeString(metadata.staves) + '"]');
778
877
  }
878
+ if (metadata.measureLayout) {
879
+ lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
880
+ }
779
881
  if (metadata.instruments) {
780
882
  for (const [key, instr] of Object.entries(metadata.instruments)) {
781
883
  let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
@@ -856,9 +958,26 @@ export const serializeLilyletDoc = (doc) => {
856
958
  measureStrs.push(measureStr || 's1');
857
959
  currentStaff = newStaff;
858
960
  }
859
- // Join measures with bar, measure number comment, and double newline
961
+ // Volta house-label comments (non-binding convention, like the `% n` measure
962
+ // index): if a measure-layout directive is present, mark the first measure of
963
+ // each alternate ending with a `% volta N` comment. Derived from the layout
964
+ // AST; purely cosmetic, never parsed back.
965
+ const voltaLabels = new Map(); // 1-based measure index → ending number
966
+ if (doc.metadata?.measureLayout) {
967
+ try {
968
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
969
+ collectVoltaLabels(layout, voltaLabels);
970
+ }
971
+ catch { /* ignore malformed layout */ }
972
+ }
973
+ // Join measures with bar, measure number comment, and double newline. A
974
+ // `% volta N` label (if any) is prepended on its own line before the measure.
860
975
  const measuresOutput = measureStrs
861
- .map((m, i) => m + ' | %' + (i + 1))
976
+ .map((m, i) => {
977
+ const label = voltaLabels.get(i + 1);
978
+ const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
979
+ return prefix + m + ' | %' + (i + 1);
980
+ })
862
981
  .join('\n\n');
863
982
  parts.push(measuresOutput);
864
983
  return parts.join('\n');
@@ -242,6 +242,7 @@ export interface Metadata {
242
242
  instrument?: string;
243
243
  genre?: string;
244
244
  staves?: string;
245
+ measureLayout?: string;
245
246
  instruments?: {
246
247
  [key: string]: InstrumentName;
247
248
  };
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayout.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayout.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayoutFromXml.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/measureLayoutFromXml.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",
@@ -40,11 +40,14 @@
40
40
  "test:decoder": "tsx ./tests/lilypondDecoder.ts",
41
41
  "test:abc": "tsx ./tests/abc-decoder.ts",
42
42
  "test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
43
+ "test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
43
44
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
44
45
  "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
45
46
  "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
46
47
  "build:browser-bundle": "esbuild tools/browserBundle.ts --bundle --format=iife --platform=browser --minify --outfile=dist/lilylet.bundle.js",
47
48
  "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
49
+ "test:measures": "tsx ./tests/unit/measureLayout.test.ts",
50
+ "test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
48
51
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
49
52
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
50
53
  "ts": "tsx"
@@ -70,7 +73,7 @@
70
73
  "ts-node": "^10.9.2",
71
74
  "tsx": "^4.21.0",
72
75
  "typescript": "^5.3.3",
73
- "verovio": "^5.7.0",
76
+ "verovio": "^6.2.0",
74
77
  "yargs": "^17.7.2"
75
78
  },
76
79
  "dependencies": {