@k-l-lambda/lilylet 0.1.81 → 0.1.82

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.
@@ -20,7 +20,7 @@
20
20
  * always-valid fallback.
21
21
  */
22
22
 
23
- import { parseMeasureLayout, expandMeasureLayout } from "./measureLayout";
23
+ import { parseMeasureLayout, expandMeasureLayout, LayoutType } from "./measureLayout";
24
24
  import { getDirectChildren, getAttribute } from "./musicXmlUtils";
25
25
 
26
26
  // One decoded measure's repeat-relevant markup, in source order.
@@ -79,15 +79,9 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
79
79
  const segnoIdx = infos.find(i => i.segno)?.index ?? 1;
80
80
  const codaIdx = infos.find(i => i.coda)?.index;
81
81
 
82
- // Ending (volta) spans, sorted by start index. On pass P of the enclosing
83
- // repeat, the ending whose number === P is played; others are skipped by
84
- // jumping to the next ending's start (the last ending is the fallthrough).
85
- const endingSpans = infos
86
- .filter(i => i.endingStart !== undefined)
87
- .map(i => ({ number: i.endingStart!, start: i.index }))
88
- .sort((a, b) => a.start - b.start);
89
- const nextEndingStartAfter = (idx: number): number | undefined =>
90
- endingSpans.find(e => e.start > idx)?.start;
82
+ // Classify repeat/volta sections once so ending-skip decisions are scoped to
83
+ // the current repeat section, not a later independent section's endings.
84
+ const sections = classifyRepeatSections(infos, 1, n);
91
85
 
92
86
  const order: number[] = [];
93
87
  const passCount = new Map<number, number>(); // forward-repeat start index → completed passes
@@ -108,12 +102,14 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
108
102
  if (info?.endingStart !== undefined) {
109
103
  const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
110
104
  const currentPass = (passCount.get(start) ?? 0) + 1;
105
+ const section = findSectionForIndex(sections, i);
106
+ const endingSpans = section ? endingSpansForSection(infos, section) : [];
111
107
  const isLastEnding = endingSpans[endingSpans.length - 1]?.start === i;
112
108
  if (info.endingStart !== currentPass && !isLastEnding) {
113
- const next = nextEndingStartAfter(i);
109
+ const next = endingSpans.find(e => e.start > i)?.start;
114
110
  if (next !== undefined) { i = next; continue; }
115
- // no further ending: fall past all endings
116
- i = i + 1; continue;
111
+ i = section ? section.hi + 1 : i + 1;
112
+ continue;
117
113
  }
118
114
  }
119
115
 
@@ -125,7 +121,10 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
125
121
  // Fine stop (only after a D.C./D.S. jump has occurred)
126
122
  if (info?.fine && (jumpedDaCapo || jumpedDalSegno)) break;
127
123
 
128
- // Backward repeat → jump to current repeat-start, counting the pass
124
+ // Backward repeat → jump to current repeat-start, counting the pass.
125
+ // If this same measure ALSO carries a D.C./D.S. (e.g. ABC "!D.C.!:|"), the
126
+ // repeat is resolved FIRST (all passes), then we fall through to the
127
+ // navigation jump below — the da-capo wraps the repeat, not the reverse.
129
128
  if (info?.repeatEnd) {
130
129
  const start = repeatStartStack[repeatStartStack.length - 1] ?? 1;
131
130
  const times = info.repeatTimes ?? 2;
@@ -133,7 +132,10 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
133
132
  passCount.set(start, done);
134
133
  if (done < times) { i = start; continue; }
135
134
  repeatStartStack = repeatStartStack.filter(s => s !== start);
136
- i++; continue;
135
+ // repeat exhausted: only advance past it when there is no pending
136
+ // navigation on this measure (otherwise fall through to D.C./D.S.).
137
+ const hasPendingNav = (info.dacapo && !jumpedDaCapo) || (info.dalsegno && !jumpedDalSegno);
138
+ if (!hasPendingNav) { i++; continue; }
137
139
  }
138
140
 
139
141
  // D.S. (dal segno) — jump back to segno once, arm To-Coda
@@ -157,6 +159,119 @@ const simulate = (infos: MeasureRepeatInfo[]): number[] => {
157
159
 
158
160
  const rangeCode = (a: number, b: number): string => a === b ? `${a}` : flatToCode(Array.from({ length: b - a + 1 }, (_, k) => a + k));
159
161
 
162
+ const isAscendingRun = (seq: number[]): boolean => seq.every((v, i) => i === 0 || v === seq[i - 1] + 1);
163
+
164
+ const compactFlatToCode = (seq: number[]): string => {
165
+ const parts: string[] = [];
166
+ let plain: number[] = [];
167
+ const flushPlain = (): void => {
168
+ if (plain.length > 0) {
169
+ parts.push(flatToCode(plain));
170
+ plain = [];
171
+ }
172
+ };
173
+
174
+ let i = 0;
175
+ while (i < seq.length) {
176
+ let repeatLen = 0;
177
+ for (let len = Math.floor((seq.length - i) / 2); len >= 1; len--) {
178
+ const chunk = seq.slice(i, i + len);
179
+ if (!isAscendingRun(chunk)) continue;
180
+ const next = seq.slice(i + len, i + 2 * len);
181
+ if (JSON.stringify(chunk) === JSON.stringify(next)) { repeatLen = len; break; }
182
+ }
183
+
184
+ if (repeatLen > 0) {
185
+ flushPlain();
186
+ parts.push(`2*[${flatToCode(seq.slice(i, i + repeatLen))}]`);
187
+ i += repeatLen * 2;
188
+ }
189
+ else plain.push(seq[i++]);
190
+ }
191
+ flushPlain();
192
+ return parts.join(", ");
193
+ };
194
+
195
+ const layoutExpandsTo = (code: string, seq: number[], type: LayoutType = LayoutType.Full): boolean => {
196
+ try {
197
+ const got = expandMeasureLayout(parseMeasureLayout(code), type);
198
+ return JSON.stringify(got) === JSON.stringify(seq);
199
+ } catch { return false; }
200
+ };
201
+
202
+ interface RepeatSection {
203
+ lo: number;
204
+ hi: number;
205
+ startIdx: number;
206
+ endIdx?: number;
207
+ times: number;
208
+ endingStarts: number[];
209
+ spurious: boolean;
210
+ }
211
+
212
+ const classifyRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): RepeatSection[] => {
213
+ const inSpan = (idx: number): boolean => idx >= lo && idx <= hi;
214
+ const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
215
+ const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
216
+ const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
217
+
218
+ if (repeatEnds.length === 0) {
219
+ return [{ lo, hi, startIdx: lo, times: 1, endingStarts: [], spurious: false }];
220
+ }
221
+
222
+ const sections: RepeatSection[] = [];
223
+ let prevHi = lo - 1;
224
+ for (const e of repeatEnds) {
225
+ if (e <= prevHi) continue;
226
+ const sectionLo = prevHi + 1;
227
+ const nextStart = repeatStarts.find(s => s > e);
228
+ const sectionEndingStarts = infos
229
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
230
+ .map(i => i.index)
231
+ .sort((a, b) => a - b);
232
+
233
+ let sectionHi: number;
234
+ if (repeatEnds.length === 1) sectionHi = hi;
235
+ else if (sectionEndingStarts.length) {
236
+ const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
237
+ const stop = endingStops.find(s => s >= lastStart);
238
+ sectionHi = stop ?? lastStart;
239
+ }
240
+ else sectionHi = e;
241
+
242
+ const explicitStartInSection = repeatStarts.some(s => s >= sectionLo && s <= e);
243
+ const implicitFromStart = sectionLo === lo && lo === 1;
244
+ const spurious = sectionEndingStarts.length === 0 && !explicitStartInSection && !implicitFromStart;
245
+ const startIdx = repeatStarts.filter(s => s >= sectionLo && s <= e).sort((a, b) => b - a)[0] ?? sectionLo;
246
+ const endInfo = infos.find(i => i.index === e);
247
+ sections.push({ lo: sectionLo, hi: sectionHi, startIdx, endIdx: e, times: endInfo?.repeatTimes ?? 2, endingStarts: sectionEndingStarts, spurious });
248
+ prevHi = sectionHi;
249
+ }
250
+
251
+ if (prevHi < hi) sections.push({ lo: prevHi + 1, hi, startIdx: prevHi + 1, times: 1, endingStarts: [], spurious: false });
252
+ return sections;
253
+ };
254
+
255
+ const findSectionForIndex = (sections: RepeatSection[], idx: number): RepeatSection | undefined =>
256
+ sections.find(s => idx >= s.lo && idx <= s.hi);
257
+
258
+ const endingSpansForSection = (infos: MeasureRepeatInfo[], section: RepeatSection): Array<{ number: number; start: number; stop: number }> => {
259
+ const spans: Array<{ number: number; start: number; stop: number }> = [];
260
+ for (let e = 0; e < section.endingStarts.length; e++) {
261
+ const start = section.endingStarts[e];
262
+ const nextStart = section.endingStarts[e + 1];
263
+ let stop: number;
264
+ if (nextStart !== undefined) stop = nextStart - 1;
265
+ else {
266
+ const stopInfo = infos.find(info => info.index >= start && info.index <= section.hi && info.endingStop !== undefined);
267
+ stop = stopInfo ? stopInfo.index : start;
268
+ }
269
+ const number = infos.find(info => info.index === start)?.endingStart ?? e + 1;
270
+ spans.push({ number, start, stop });
271
+ }
272
+ return spans;
273
+ };
274
+
160
275
  // Render the repeat/volta structure over measures [lo..hi] (no navigation),
161
276
  // or null if it isn't a single clean repeat/volta span. Shared by the plain
162
277
  // path and the da-capo ABA wrapper.
@@ -229,64 +344,38 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
229
344
  // Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
230
345
  // halves of a da-capo ABA.
231
346
  const renderRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): string | null => {
232
- const inSpan = (idx: number) => idx >= lo && idx <= hi;
233
- const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
234
- const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
235
- if (repeatEnds.length <= 1) return renderRepeatSpan(infos, lo, hi);
236
-
237
- const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
238
-
347
+ const sections = classifyRepeatSections(infos, lo, hi);
239
348
  const parts: string[] = [];
240
- let prevHi = lo - 1;
241
- for (const e of repeatEnds) {
242
- const sectionLo = prevHi + 1;
243
- // The next section's repeat-start bounds this section's voltas (2nd/3rd
244
- // endings sit AFTER the repeat-end but BEFORE the next section starts).
245
- const nextStart = repeatStarts.find(s => s > e);
246
- const sectionEndingStarts = infos
247
- .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
248
- .map(i => i.index)
249
- .sort((a, b) => a - b);
250
-
251
- let sectionHi: number;
252
- if (sectionEndingStarts.length) {
253
- const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
254
- // the last ending's stop closes the section; else the ending start itself
255
- const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
256
- sectionHi = stop ?? lastStart;
349
+ for (const section of sections) {
350
+ if (section.spurious) {
351
+ parts.push(rangeCode(section.lo, section.hi));
352
+ continue;
257
353
  }
258
- else sectionHi = e;
259
-
260
- const code = renderRepeatSpan(infos, sectionLo, sectionHi);
354
+ const code = renderRepeatSpan(infos, section.lo, section.hi);
261
355
  if (code === null) return null;
262
356
  parts.push(code);
263
- prevHi = sectionHi;
264
357
  }
265
- // trailing non-repeated tail
266
- if (prevHi < hi) parts.push(rangeCode(prevHi + 1, hi));
267
358
  return parts.join(", ");
268
359
  };
269
360
 
270
- const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null => {
361
+ const tryStructured = (infos: MeasureRepeatInfo[], total: number, performed: number[]): string | null => {
271
362
  const dacapo = infos.find(i => i.dacapo);
272
363
  const dalsegno = infos.some(i => i.dalsegno);
273
364
  const tocoda = infos.some(i => i.tocoda);
274
365
 
275
- // D.C. (da capo → return to measure 1) over an inner repeat/volta maps cleanly
276
- // to ABA <main, rest>: the trailing A' re-expansion uses LayoutType.Once (body
277
- // once + the LAST alternate), which is exactly the "over-volta" convention —
278
- // on the da-capo pass internal volta repeats are dropped, only the final
279
- // pass-through plays, stopping at Fine. D.S. (segno ≠ measure 1) and To-Coda
280
- // truncation can't be expressed this way → flat fallback.
281
- if (dacapo && !dalsegno && !tocoda) {
282
- // D.C. over a volta → ABA <main, rest>; Once re-expansion gives the
283
- // over-volta replay (body + last ending, no inner repeat). Plain D.C. al
284
- // Fine without a volta can't bound A' at an arbitrary Fine → flat fallback.
285
- return buildDaCapoABA(infos, dacapo.index);
366
+ // D.C. / D.S. jumps that replay from the start can use the ABA form. A real
367
+ // Segno (not measure 1) and To-Coda still fall back to flat because the DSL has
368
+ // no "replay from arbitrary middle marker" construct. Bare Fine/Segno/Coda marks
369
+ // without a jump are visual landmarks only; they must not block repeat structuring.
370
+ const hasExplicitSegno = infos.some(i => i.segno);
371
+ if (!tocoda && dacapo) return buildDaCapoABA(infos, dacapo.index, performed);
372
+ if (!tocoda && dalsegno && !hasExplicitSegno) {
373
+ const ds = infos.find(i => i.dalsegno);
374
+ if (ds) return buildDaCapoABA(infos, ds.index, performed);
286
375
  }
287
- if (dalsegno || tocoda || infos.some(i => i.fine || i.segno || i.coda)) return null;
376
+ if (dalsegno || tocoda) return null;
288
377
 
289
- // No navigation: one or more plain repeat/volta sections in sequence. Most
378
+ // No playback-changing navigation: one or more plain repeat/volta sections in sequence. Most
290
379
  // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
291
380
  // render each independently and join with commas.
292
381
  return renderRepeatSections(infos, 1, total);
@@ -301,20 +390,19 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
301
390
  // via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
302
391
  // bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
303
392
  // re-expand check still validates the result against the simulated order.
304
- const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
393
+ const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number, performed: number[]): string | null => {
305
394
  const fineIdx = infos.find(i => i.fine)?.index;
306
395
  if (fineIdx === undefined) {
307
396
  // No explicit Fine: fall back to the legacy single-volta ABA shape, where
308
397
  // A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
309
398
  return buildDaCapoABALegacy(infos, dcIdx);
310
399
  }
311
- if (fineIdx >= dcIdx) return null; // Fine must precede the D.C. tail
400
+ if (fineIdx >= dcIdx) return null; // Fine must precede the D.C./D.S. tail
312
401
  const restLo = fineIdx + 1;
313
- if (restLo > dcIdx) return null; // no B section
314
402
 
315
403
  const mainCode = renderRepeatSections(infos, 1, fineIdx);
316
- const restCode = renderRepeatSections(infos, restLo, dcIdx);
317
- if (mainCode === null || restCode === null) return null;
404
+ const restCode = restLo > dcIdx ? "" : renderRepeatSections(infos, restLo, dcIdx);
405
+ if (mainCode === null || restCode === null) return buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
318
406
 
319
407
  // `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
320
408
  // in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
@@ -325,7 +413,38 @@ const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | nul
325
413
  const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
326
414
  const main = isSingleToken ? mainCode : `[${mainCode}]`;
327
415
  const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
328
- return `<${main}, ${rest}>`;
416
+ const candidate = `<${main}, ${rest}>`;
417
+ return layoutExpandsTo(candidate, performed) ? candidate : buildDaCapoABAFromReplay(infos, dcIdx, fineIdx, performed);
418
+ };
419
+
420
+ const wrapAbaMain = (code: string): string => /^\d+$/.test(code) || /^\d+\*\[/.test(code) || /^\[/.test(code) ? code : `[${code}]`;
421
+ const wrapAbaRest = (code: string): string => code === "" ? "" : (/,/.test(code) ? `[${code}]` : code);
422
+
423
+ const buildDaCapoABAFromReplay = (infos: MeasureRepeatInfo[], dcIdx: number, fineIdx: number, performed: number[]): string | null => {
424
+ // Some ABC/MusicXML D.C. exports place repeat-end/start boundaries so the
425
+ // notated pre-Fine span (1..Fine) is not enough to render main via source flags,
426
+ // even though the simulated order is a clean ABA: A(full) B A(once). Detect that
427
+ // shape from the final da-capo replay, then reuse the section renderer for the
428
+ // longer A candidate and derive B from the performed middle slice.
429
+ for (let k = Math.min(fineIdx, performed.length - 1); k >= 1; k--) {
430
+ const replay = Array.from({ length: k }, (_, i) => i + 1);
431
+ if (JSON.stringify(performed.slice(-k)) !== JSON.stringify(replay)) continue;
432
+
433
+ for (let mainHi = k; mainHi < dcIdx; mainHi++) {
434
+ const mainCode = renderRepeatSections(infos, 1, mainHi);
435
+ if (mainCode === null) continue;
436
+ const mainFull = expandMeasureLayout(parseMeasureLayout(mainCode));
437
+ const mainOnce = expandMeasureLayout(parseMeasureLayout(mainCode), LayoutType.Once);
438
+ if (JSON.stringify(mainOnce) !== JSON.stringify(replay)) continue;
439
+ if (JSON.stringify(performed.slice(0, mainFull.length)) !== JSON.stringify(mainFull)) continue;
440
+
441
+ const restSeq = performed.slice(mainFull.length, performed.length - mainOnce.length);
442
+ const restCode = compactFlatToCode(restSeq);
443
+ const candidate = `<${wrapAbaMain(mainCode)}, ${wrapAbaRest(restCode)}>`;
444
+ if (layoutExpandsTo(candidate, performed)) return candidate;
445
+ }
446
+ }
447
+ return null;
329
448
  };
330
449
 
331
450
  // Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
@@ -359,9 +478,9 @@ const buildDaCapoABALegacy = (infos: MeasureRepeatInfo[], dcIdx: number): string
359
478
 
360
479
  const pre = startIdx > 1 ? rangeCode(1, startIdx - 1) + ", " : "";
361
480
  const main = pre ? `[${pre}${voltaCode}]` : voltaCode;
362
- // rest = the tail from after the last ending to the D.C. measure
363
- if (lastStop >= dcIdx) return null; // no tail to form the ABA "rest"
364
- const rest = rangeCode(lastStop + 1, dcIdx);
481
+ // rest = the tail from after the last ending to the D.C. measure. It may be
482
+ // empty when the navigation jump follows immediately after the main repeat.
483
+ const rest = lastStop >= dcIdx ? "" : rangeCode(lastStop + 1, dcIdx);
365
484
  return `<${main}, ${rest}>`;
366
485
  };
367
486
 
@@ -382,7 +501,7 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
382
501
  // No actual unfolding happened (e.g. a lone start-repeat with no end): skip.
383
502
  if (performed.length === totalMeasures && performed.every((v, k) => v === k + 1)) return undefined;
384
503
 
385
- const structured = tryStructured(infos, totalMeasures);
504
+ const structured = tryStructured(infos, totalMeasures, performed);
386
505
  if (structured) {
387
506
  // Trust the structured form only if it reproduces the simulated order.
388
507
  try {
@@ -390,6 +509,8 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
390
509
  if (JSON.stringify(got) === JSON.stringify(performed)) return structured;
391
510
  } catch { /* fall through to flat */ }
392
511
  }
512
+ const compacted = compactFlatToCode(performed);
513
+ if (compacted !== flatToCode(performed) && layoutExpandsTo(compacted, performed)) return compacted;
393
514
  return flatToCode(performed);
394
515
  };
395
516
 
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Note-onset extraction from a parsed Lilylet document.
3
+ *
4
+ * Walks measures -> parts -> voices -> events, accumulating each note's ONSET (position
5
+ * within its measure, in duration units of RES=480 per quarter) and its SOUNDING
6
+ * MIDI pitch. Sounding pitch honors octave-/interval-transposing clefs ("treble_8" sounds
7
+ * an octave lower than written, etc.), tracked per voice and PERSISTED across measures.
8
+ *
9
+ * Shared by tools/astServer.ts (the HTTP onset API) and the clef-onset unit test.
10
+ */
11
+
12
+ import { calculateDuration } from "./musicXmlUtils";
13
+ import { Accidental } from "./types";
14
+
15
+ // Onset resolution in ticks per quarter note. calculateDuration() rounds its result to an
16
+ // integer, so it must run at a resolution fine enough to represent every note value exactly:
17
+ // the shared DIVISIONS=4 (MusicXML export) rounds a 32nd (0.5) and a dotted 16th (1.5) to
18
+ // integers, drifting the onset cursor. 480 is divisible by all common note/dot/tuplet values,
19
+ // so durations stay exact. Onsets are reported in these units and also normalized per measure.
20
+ const RES = 480;
21
+
22
+ const PHONET_SEMITONE: Record<string, number> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
23
+ const ACCIDENTAL_SEMITONE: Record<string, number> = {
24
+ [Accidental.natural]: 0, [Accidental.sharp]: 1, [Accidental.flat]: -1,
25
+ [Accidental.doubleSharp]: 2, [Accidental.doubleFlat]: -2,
26
+ };
27
+
28
+ // Diatonic-step -> semitones within an octave (unison..seventh), for "_N"/"^N" clef suffixes.
29
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
30
+
31
+ /**
32
+ * The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
33
+ * "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
34
+ * (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
35
+ * A plain clef (no suffix) declares 0.
36
+ */
37
+ export const clefShift = (clefStr: string): number => {
38
+ const m = /^.*?([_^])(\d+)$/.exec(clefStr || "");
39
+ if (!m) return 0;
40
+ const k = parseInt(m[2], 10) - 1; // diatonic steps above unison
41
+ const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
42
+ return (m[1] === "^" ? 1 : -1) * semis;
43
+ };
44
+
45
+ /**
46
+ * Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C
47
+ * octave = MIDI 60 (see parser.resolveRelativePitch). `shift` is the active clef's
48
+ * written->sounding semitone transposition.
49
+ */
50
+ export const pitchToMidi = (pitch: any, shift = 0): number => {
51
+ const semi = PHONET_SEMITONE[pitch.phonet] ?? 0;
52
+ const acc = pitch.accidental ? (ACCIDENTAL_SEMITONE[pitch.accidental] ?? 0) : 0;
53
+ return 60 + (pitch.octave || 0) * 12 + semi + acc + shift;
54
+ };
55
+
56
+ export interface NoteOnset {
57
+ onset: number; onsetNorm: number; durationDiv: number;
58
+ midi: number[]; staff: number; voice: number; grace: boolean;
59
+ }
60
+
61
+ export interface MeasureOnsets {
62
+ index: number;
63
+ timeSig: { numerator: number; denominator: number } | null;
64
+ measureDivisions: number;
65
+ notes: NoteOnset[];
66
+ }
67
+
68
+ // Walk a voice's events, accumulating onset in duration units. Tuplet/times scale their inner
69
+ // durations by ratio (num/den). Grace notes take no time. `clefRef.shift` is the active clef's
70
+ // written->sounding shift, updated by \clef contextChanges (persisted by the caller across
71
+ // measures). Returns the total consumed duration (the voice's played length).
72
+ const walkVoice = (events: any[], staff: number, voice: number,
73
+ scale: number, startCursor: number, clefRef: { shift: number }, out: NoteOnset[]): number => {
74
+ let cursor = startCursor;
75
+ for (const ev of events) {
76
+ if (ev.type === "context" && typeof ev.clef === "string") {
77
+ clefRef.shift = clefShift(ev.clef);
78
+ }
79
+ else if (ev.type === "note") {
80
+ const dur = calculateDuration(ev.duration, RES) * scale;
81
+ const midi = (ev.pitches || []).map((p: any) => pitchToMidi(p, clefRef.shift));
82
+ if (ev.grace) {
83
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: 0, midi, staff: ev.staff ?? staff, voice, grace: true });
84
+ continue; // grace steals no measure time
85
+ }
86
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: dur, midi, staff: ev.staff ?? staff, voice, grace: false });
87
+ cursor += dur;
88
+ }
89
+ else if (ev.type === "rest") {
90
+ cursor += calculateDuration(ev.duration, RES) * scale;
91
+ }
92
+ else if (ev.type === "tuplet" || ev.type === "times") {
93
+ const r = ev.ratio || { numerator: 1, denominator: 1 };
94
+ cursor = walkVoice(ev.events || [], staff, voice, scale * (r.numerator / r.denominator), cursor, clefRef, out);
95
+ }
96
+ else if (ev.type === "tremolo") {
97
+ const each = (RES * 4 / ev.division) * scale;
98
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
99
+ midi: (ev.pitchA || []).map((p: any) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
100
+ cursor += each;
101
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
102
+ midi: (ev.pitchB || []).map((p: any) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
103
+ cursor += each;
104
+ }
105
+ // barline / markup / dynamic / harmony / pitchReset: no time
106
+ }
107
+ return cursor;
108
+ };
109
+
110
+ /**
111
+ * Per-measure note onsets for a parsed LilyletDoc. The active clef transposition is tracked
112
+ * per (positional) voice index and PERSISTS across measures — a \clef stays in force until the
113
+ * next one, so a "treble_8" set in measure 1 still lowers measure 2 by an octave.
114
+ *
115
+ * Measure length (`measureDivisions`, used to normalize onsets to [0,1)) comes from the time
116
+ * signature for a full bar, but an INCOMPLETE bar carrying a `\partial` (a pickup, or a bar
117
+ * split by a repeat barline) is only as long as its `\partial` value. Honoring `\partial` makes
118
+ * the lilylet bar length match the corresponding MIDI measure's tick span (MuseScore emits a
119
+ * short 1/8 or 3/8 measure for exactly these), so the two onset grids align.
120
+ */
121
+ export const measureOnsets = (doc: any): MeasureOnsets[] => {
122
+ let curTime: any = null;
123
+ const clefByVoice: { shift: number }[] = [];
124
+ return (doc.measures || []).map((m: any, mi: number): MeasureOnsets => {
125
+ if (m.timeSig) curTime = m.timeSig;
126
+ const notes: NoteOnset[] = [];
127
+ let maxCursor = 0;
128
+ let vIndex = 0;
129
+ // a \partial anywhere in the measure (any voice's leading contextChange) shortens the bar.
130
+ let partial: any = null;
131
+ for (const part of m.parts || []) {
132
+ for (const voice of part.voices || []) {
133
+ if (!clefByVoice[vIndex]) clefByVoice[vIndex] = { shift: 0 };
134
+ for (const ev of voice.events || []) {
135
+ if (ev.type === "context" && ev.partial) { partial = ev.partial; break; }
136
+ if (ev.type === "note" || ev.type === "rest" || ev.type === "tuplet"
137
+ || ev.type === "times" || ev.type === "tremolo") break; // partial must precede notes
138
+ }
139
+ const end = walkVoice(voice.events || [], voice.staff ?? 1, vIndex, 1, 0, clefByVoice[vIndex], notes);
140
+ maxCursor = Math.max(maxCursor, end);
141
+ vIndex += 1;
142
+ }
143
+ }
144
+ // bar length: \partial value if the bar is incomplete, else the full time-signature bar,
145
+ // else (no time sig) the longest voice cursor.
146
+ const fullBar = curTime ? RES * 4 * curTime.numerator / curTime.denominator : maxCursor;
147
+ const barDiv = partial ? calculateDuration(partial, RES) : fullBar;
148
+ const span = barDiv > 0 ? barDiv : (maxCursor > 0 ? maxCursor : 1);
149
+ for (const n of notes)
150
+ n.onsetNorm = n.onset / span;
151
+ return {
152
+ index: mi + 1,
153
+ timeSig: curTime ? { numerator: curTime.numerator, denominator: curTime.denominator } : null,
154
+ measureDivisions: span,
155
+ notes,
156
+ };
157
+ });
158
+ };