@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.
@@ -1,6 +1,7 @@
1
1
  import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
2
  import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
3
3
  import { gmProgramOf } from "./gmInstruments.js";
4
+ import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout.js";
4
5
  // MEI key signatures: positive = sharps, negative = flats
5
6
  const KEY_SIGS = {
6
7
  0: "0",
@@ -641,6 +642,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
641
642
  const effectiveStaff = currentStaff ?? layerStaff;
642
643
  // Collect control event info from notes inside tuplet
643
644
  let firstNoteId = null;
645
+ let lastNoteId = null; // last note/rest id — anchors an ottava span end
644
646
  const slurStarts = [];
645
647
  const slurEnds = [];
646
648
  const dynamics = [];
@@ -675,6 +677,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
675
677
  xml += result.xml;
676
678
  if (!firstNoteId)
677
679
  firstNoteId = result.elementId;
680
+ lastNoteId = result.elementId;
678
681
  // Collect slur info
679
682
  if (result.slurStart)
680
683
  slurStarts.push(result.elementId);
@@ -714,6 +717,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
714
717
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
715
718
  const restResult = restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
716
719
  xml += restResult.xml;
720
+ lastNoteId = restResult.elementId;
717
721
  if (restResult.fermata)
718
722
  fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
719
723
  }
@@ -736,7 +740,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
736
740
  xml += `${baseIndent}</beam>\n`;
737
741
  }
738
742
  xml += `${indent}</tuplet>\n`;
739
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
743
+ return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
740
744
  };
741
745
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
742
746
  const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
@@ -866,6 +870,43 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
866
870
  let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
867
871
  let lastNoteId = null; // Track last note id for ending ottava spans
868
872
  let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
873
+ // ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
874
+ // Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
875
+ // ottava span running over a tuplet run (ubiquitous in etudes) or over rests
876
+ // was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
877
+ // helpers let any event type anchor a span. Mirror the note-case ordering:
878
+ // the caller updates lastNoteId to the event's first emitted id, THEN calls
879
+ // these.
880
+ // Apply a pending ottava's pitch shift before the event's notes are encoded.
881
+ const applyPendingOttavaShift = () => {
882
+ if (pendingOttava !== null && pendingOttava !== 0)
883
+ currentOttavaShift = pendingOttava;
884
+ };
885
+ // Extend a carried (endToken) span's end to this event's last emitted id.
886
+ const extendOttavaEnd = (lastId) => {
887
+ if (currentOctave?.endToken)
888
+ octaveEndReplacements[currentOctave.endToken] = lastId;
889
+ };
890
+ // Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
891
+ // closing any open span of a different value first; or resolve a continued span.
892
+ const applyOttavaAnchor = (anchorId) => {
893
+ if (pendingOttava !== null && pendingOttava !== 0) {
894
+ const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
895
+ const disPlace = pendingOttava > 0 ? 'above' : 'below';
896
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
897
+ octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
898
+ currentOctave = null;
899
+ }
900
+ if (!currentOctave)
901
+ currentOctave = { dis, disPlace, startId: anchorId };
902
+ pendingOttava = null;
903
+ }
904
+ else if (currentOctave?.continued) {
905
+ if (!currentOctave.endToken)
906
+ currentOctave.startId = anchorId;
907
+ currentOctave.continued = false;
908
+ }
909
+ };
869
910
  // Track slur spans - slurs must be encoded as control events in MEI.
870
911
  // A single slot can't hold the overlapping/concurrent slurs that piano writing
871
912
  // produces (measured up to 3 open at once per voice); a new start while one was
@@ -957,9 +998,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
957
998
  // Check if this note should have tie="t" (matches pending tie)
958
999
  const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
959
1000
  // If there's a pending ottava, apply it BEFORE encoding the note
960
- if (pendingOttava !== null && pendingOttava !== 0) {
961
- currentOttavaShift = pendingOttava; // Apply the shift for this note
962
- }
1001
+ applyPendingOttavaShift();
963
1002
  // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
964
1003
  const effectiveNoteEvent = currentStaff !== voice.staff
965
1004
  ? { ...noteEvent, staff: currentStaff }
@@ -967,41 +1006,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
967
1006
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
968
1007
  xml += result.xml;
969
1008
  lastNoteId = result.elementId;
970
- if (currentOctave?.endToken) {
971
- octaveEndReplacements[currentOctave.endToken] = result.elementId;
972
- }
1009
+ extendOttavaEnd(result.elementId);
973
1010
  // Flush any pending markups onto this note
974
1011
  flushPendingMarkups(result.elementId);
975
1012
  // Flush any pending leading dynamics onto this note
976
1013
  flushPendingDynamics(result.elementId);
977
1014
  // If there's a pending ottava, start the span on this note
978
- if (pendingOttava !== null && pendingOttava !== 0) {
979
- const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
980
- const disPlace = pendingOttava > 0 ? 'above' : 'below';
981
- // Close existing span first if it has a different value
982
- if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
983
- if (lastNoteId) {
984
- octaves.push({
985
- dis: currentOctave.dis,
986
- disPlace: currentOctave.disPlace,
987
- startId: currentOctave.startId,
988
- endId: lastNoteId,
989
- });
990
- }
991
- currentOctave = null;
992
- }
993
- // Start new span if we don't already have one with the same value
994
- if (!currentOctave) {
995
- currentOctave = { dis, disPlace, startId: result.elementId };
996
- }
997
- pendingOttava = null;
998
- }
999
- else if (currentOctave?.continued) {
1000
- if (!currentOctave.endToken) {
1001
- currentOctave.startId = result.elementId;
1002
- }
1003
- currentOctave.continued = false;
1004
- }
1015
+ applyOttavaAnchor(result.elementId);
1005
1016
  // Update pending tie pitches
1006
1017
  if (result.hasTieStart) {
1007
1018
  pendingTiePitches = result.pitches;
@@ -1101,23 +1112,39 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1101
1112
  case 'rest': {
1102
1113
  // For cross-staff notation: pass staff number if different from voice's home staff
1103
1114
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1115
+ // A pending ottava shift applies to a rest's gestural octave too.
1116
+ applyPendingOttavaShift();
1104
1117
  const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1105
1118
  xml += restResult.xml;
1119
+ lastNoteId = restResult.elementId;
1120
+ // An ottava span may open on, run through, or close at a rest (e.g. a
1121
+ // span that starts just before LH rests). Anchor it like a note.
1122
+ extendOttavaEnd(restResult.elementId);
1123
+ applyOttavaAnchor(restResult.elementId);
1106
1124
  // Fermata over a rest (held silence) — emit as a control event on the rest.
1107
1125
  if (restResult.fermata)
1108
1126
  fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
1109
1127
  // A leading dynamic/markup attaches to the next event, which may be this rest
1110
1128
  flushPendingMarkups(restResult.elementId);
1111
1129
  flushPendingDynamics(restResult.elementId);
1112
- lastNoteId = restResult.elementId;
1113
1130
  break;
1114
1131
  }
1115
1132
  case 'tuplet':
1116
1133
  case 'times': {
1117
1134
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1118
1135
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1136
+ // A pending ottava shift applies to the tuplet's notes' written octave.
1137
+ applyPendingOttavaShift();
1119
1138
  const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1120
1139
  xml += tupletResult.xml;
1140
+ // An ottava span can open on, run through, or close at a tuplet (common
1141
+ // in etudes where the 8va covers a whole tuplet run). Anchor the span on
1142
+ // the tuplet's FIRST note and let its LAST note close/extend it — without
1143
+ // this the entire span was dropped (start/close lived only in case 'note').
1144
+ if (tupletResult.firstNoteId)
1145
+ applyOttavaAnchor(tupletResult.firstNoteId);
1146
+ if (tupletResult.lastNoteId)
1147
+ extendOttavaEnd(tupletResult.lastNoteId);
1121
1148
  // Propagate clef change from inside the tuplet to the parent tracker
1122
1149
  if (tupletResult.endingClef) {
1123
1150
  currentClef = tupletResult.endingClef;
@@ -1126,7 +1153,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1126
1153
  if (tupletResult.firstNoteId) {
1127
1154
  flushPendingMarkups(tupletResult.firstNoteId);
1128
1155
  flushPendingDynamics(tupletResult.firstNoteId);
1129
- lastNoteId = tupletResult.firstNoteId;
1156
+ lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
1130
1157
  }
1131
1158
  // Process slur ends first (to close open slurs, LIFO), then starts.
1132
1159
  for (const endId of tupletResult.slurEnds) {
@@ -1185,8 +1212,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1185
1212
  currentOctave = null;
1186
1213
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1187
1214
  }
1188
- // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
1189
- // It may be continued by a subsequent ottava command with the same value
1215
+ else if (currentOctave && currentOctave.emitted) {
1216
+ // Stop landed at measure START (no note yet in this layer) but the
1217
+ // span was carried+emitted in a PREVIOUS measure: it genuinely ends
1218
+ // here. Its deferred endToken was already resolved to the correct
1219
+ // last note by extendOttavaEnd as the span ran through prior measures
1220
+ // (or by the staff-level carry's endFallbackId), so DON'T rewrite it
1221
+ // here — just clear the slot and mark closed, so a later same-value
1222
+ // ottava opens a NEW span instead of folding into this one.
1223
+ // Without this, sequential cross-measure spans collapse into one
1224
+ // (No.57: 18 spans → 8).
1225
+ currentOctave = null;
1226
+ ottavaExplicitlyClosed = true;
1227
+ }
1228
+ // Note: if no lastNoteId and not yet emitted (e.g., open+close at
1229
+ // measure start with no notes), keep currentOctave alive — it may be
1230
+ // continued by a subsequent ottava command with the same value.
1190
1231
  currentOttavaShift = 0; // Reset the shift (will be restored if continued)
1191
1232
  }
1192
1233
  else {
@@ -1525,8 +1566,10 @@ const BARLINE_TO_MEI = {
1525
1566
  };
1526
1567
  // Encode a measure
1527
1568
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1528
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}) => {
1569
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
1529
1570
  const measureId = generateId("measure");
1571
+ if (measureIds)
1572
+ measureIds.push(measureId);
1530
1573
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1531
1574
  const allHairpins = [];
1532
1575
  const allPedals = [];
@@ -2246,6 +2289,75 @@ const encode = (doc, options = {}) => {
2246
2289
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
2247
2290
  mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
2248
2291
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
2292
+ // Splice point for an <expansion> element (must be the FIRST child of
2293
+ // <section>, but its plist needs the measure ids collected during the loop
2294
+ // below — so remember where to insert it and build it afterwards).
2295
+ const expansionInsertPos = mei.length;
2296
+ const measureIds = [];
2297
+ // Volta playback + house brackets. A flat measure-level <expansion> conflicts
2298
+ // with <ending> wrappers in verovio (it then drops the volta measures). Verovio
2299
+ // instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
2300
+ // whose plist references those segment ids. So when the layout decomposes into
2301
+ // volta segments, emit that structure; otherwise keep the simpler flat path.
2302
+ // See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
2303
+ // Helper to check if a measure has any musical content
2304
+ const measureHasContent = (measure) => {
2305
+ for (const part of measure.parts) {
2306
+ for (const voice of part.voices) {
2307
+ for (const event of voice.events) {
2308
+ // Check for actual musical content (not just context changes or pitch resets)
2309
+ if (event.type === 'note' || event.type === 'rest' ||
2310
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2311
+ return true;
2312
+ }
2313
+ }
2314
+ }
2315
+ }
2316
+ return false;
2317
+ };
2318
+ // Filter out trailing empty measures (done before segment setup so the segment
2319
+ // guard below knows the true emitted measure count).
2320
+ let measures = doc.measures;
2321
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2322
+ measures = measures.slice(0, -1);
2323
+ }
2324
+ let segDecomp = null;
2325
+ const segOpenAt = new Map(); // measure index → opening tag for its segment
2326
+ const segCloseAt = new Map(); // measure index → closing tag after it
2327
+ const segIdById = new Map(); // layout segment id → emitted xml:id
2328
+ if (doc.metadata?.measureLayout) {
2329
+ try {
2330
+ segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
2331
+ }
2332
+ catch {
2333
+ segDecomp = null;
2334
+ }
2335
+ }
2336
+ // A layout that references a measure beyond the emitted range (e.g. a volta
2337
+ // alternate whose bar was a trailing empty measure that got trimmed) cannot be
2338
+ // emitted as nested sections without a dangling <ending>/<section> id or an
2339
+ // unbalanced wrapper. Drop to the flat measure-level expansion path, which
2340
+ // already warns-and-skips out-of-range refs.
2341
+ if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
2342
+ segDecomp = null;
2343
+ }
2344
+ if (segDecomp) {
2345
+ const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2346
+ for (const seg of segDecomp.segments) {
2347
+ const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
2348
+ segIdById.set(seg.id, xmlId);
2349
+ const lo = seg.measures[0];
2350
+ const hi = seg.measures[seg.measures.length - 1];
2351
+ if (seg.kind === "ending") {
2352
+ const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
2353
+ segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
2354
+ }
2355
+ else {
2356
+ segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
2357
+ }
2358
+ segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
2359
+ }
2360
+ }
2249
2361
  // Track tie state across measures for cross-measure ties
2250
2362
  const tieState = {};
2251
2363
  // Track slur state across measures for cross-measure slurs
@@ -2271,26 +2383,6 @@ const encode = (doc, options = {}) => {
2271
2383
  transState[globalStaff] = `${t.diat},${t.semi}`;
2272
2384
  }
2273
2385
  }
2274
- // Helper to check if a measure has any musical content
2275
- const measureHasContent = (measure) => {
2276
- for (const part of measure.parts) {
2277
- for (const voice of part.voices) {
2278
- for (const event of voice.events) {
2279
- // Check for actual musical content (not just context changes or pitch resets)
2280
- if (event.type === 'note' || event.type === 'rest' ||
2281
- event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2282
- return true;
2283
- }
2284
- }
2285
- }
2286
- }
2287
- return false;
2288
- };
2289
- // Filter out trailing empty measures
2290
- let measures = doc.measures;
2291
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2292
- measures = measures.slice(0, -1);
2293
- }
2294
2386
  // Encode measures
2295
2387
  measures.forEach((measure, mi) => {
2296
2388
  // Check for key signature change and output scoreDef if needed
@@ -2352,8 +2444,57 @@ const encode = (doc, options = {}) => {
2352
2444
  mei += `${sd}</scoreDef>\n`;
2353
2445
  }
2354
2446
  }
2355
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2447
+ const mIndex = mi + 1;
2448
+ const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2449
+ // When the layout decomposes into volta segments, wrap each measure run in
2450
+ // its <section>/<ending> container (opened/closed by measure index).
2451
+ const openTag = segOpenAt.get(mIndex);
2452
+ if (openTag)
2453
+ mei += openTag;
2454
+ const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2455
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2456
+ const closeTag = segCloseAt.get(mIndex);
2457
+ if (closeTag)
2458
+ mei += closeTag;
2356
2459
  });
2460
+ // Emit an <expansion> describing playback order from the measure-layout
2461
+ // directive ([measures "..."]). Verovio consumes its plist to unfold repeats
2462
+ // for MIDI/time-map output. Inserted as the first child of <section>.
2463
+ // - voltas present (segDecomp): plist references the SECTION/ENDING segment
2464
+ // ids in performed order (a body segment id repeats per pass). This is the
2465
+ // only form verovio plays correctly alongside <ending> house brackets.
2466
+ // - otherwise: the simpler flat measure-level plist (one ref per played bar).
2467
+ if (segDecomp) {
2468
+ const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x) => x !== undefined).map(id => `#${id}`);
2469
+ if (refs.length > 0) {
2470
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2471
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2472
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2473
+ }
2474
+ }
2475
+ else if (doc.metadata?.measureLayout) {
2476
+ try {
2477
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
2478
+ const order = expandMeasureLayout(layout);
2479
+ const refs = [];
2480
+ for (const idx of order) {
2481
+ const id = measureIds[idx - 1]; // 1-based index into notated measures
2482
+ if (id === undefined) {
2483
+ console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
2484
+ continue;
2485
+ }
2486
+ refs.push(`#${id}`);
2487
+ }
2488
+ if (refs.length > 0) {
2489
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2490
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2491
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2492
+ }
2493
+ }
2494
+ catch (err) {
2495
+ console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${err.message}`);
2496
+ }
2497
+ }
2357
2498
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
2358
2499
  mei += `${indent}${indent}${indent}${indent}</score>\n`;
2359
2500
  mei += `${indent}${indent}${indent}</mdiv>\n`;