@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.
@@ -0,0 +1,144 @@
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
+ import { calculateDuration } from "./musicXmlUtils.js";
12
+ import { Accidental } from "./types.js";
13
+ // Onset resolution in ticks per quarter note. calculateDuration() rounds its result to an
14
+ // integer, so it must run at a resolution fine enough to represent every note value exactly:
15
+ // the shared DIVISIONS=4 (MusicXML export) rounds a 32nd (0.5) and a dotted 16th (1.5) to
16
+ // integers, drifting the onset cursor. 480 is divisible by all common note/dot/tuplet values,
17
+ // so durations stay exact. Onsets are reported in these units and also normalized per measure.
18
+ const RES = 480;
19
+ const PHONET_SEMITONE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
20
+ const ACCIDENTAL_SEMITONE = {
21
+ [Accidental.natural]: 0, [Accidental.sharp]: 1, [Accidental.flat]: -1,
22
+ [Accidental.doubleSharp]: 2, [Accidental.doubleFlat]: -2,
23
+ };
24
+ // Diatonic-step -> semitones within an octave (unison..seventh), for "_N"/"^N" clef suffixes.
25
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
26
+ /**
27
+ * The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
28
+ * "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
29
+ * (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
30
+ * A plain clef (no suffix) declares 0.
31
+ */
32
+ export const clefShift = (clefStr) => {
33
+ const m = /^.*?([_^])(\d+)$/.exec(clefStr || "");
34
+ if (!m)
35
+ return 0;
36
+ const k = parseInt(m[2], 10) - 1; // diatonic steps above unison
37
+ const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
38
+ return (m[1] === "^" ? 1 : -1) * semis;
39
+ };
40
+ /**
41
+ * Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C
42
+ * octave = MIDI 60 (see parser.resolveRelativePitch). `shift` is the active clef's
43
+ * written->sounding semitone transposition.
44
+ */
45
+ export const pitchToMidi = (pitch, shift = 0) => {
46
+ const semi = PHONET_SEMITONE[pitch.phonet] ?? 0;
47
+ const acc = pitch.accidental ? (ACCIDENTAL_SEMITONE[pitch.accidental] ?? 0) : 0;
48
+ return 60 + (pitch.octave || 0) * 12 + semi + acc + shift;
49
+ };
50
+ // Walk a voice's events, accumulating onset in duration units. Tuplet/times scale their inner
51
+ // durations by ratio (num/den). Grace notes take no time. `clefRef.shift` is the active clef's
52
+ // written->sounding shift, updated by \clef contextChanges (persisted by the caller across
53
+ // measures). Returns the total consumed duration (the voice's played length).
54
+ const walkVoice = (events, staff, voice, scale, startCursor, clefRef, out) => {
55
+ let cursor = startCursor;
56
+ for (const ev of events) {
57
+ if (ev.type === "context" && typeof ev.clef === "string") {
58
+ clefRef.shift = clefShift(ev.clef);
59
+ }
60
+ else if (ev.type === "note") {
61
+ const dur = calculateDuration(ev.duration, RES) * scale;
62
+ const midi = (ev.pitches || []).map((p) => pitchToMidi(p, clefRef.shift));
63
+ if (ev.grace) {
64
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: 0, midi, staff: ev.staff ?? staff, voice, grace: true });
65
+ continue; // grace steals no measure time
66
+ }
67
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: dur, midi, staff: ev.staff ?? staff, voice, grace: false });
68
+ cursor += dur;
69
+ }
70
+ else if (ev.type === "rest") {
71
+ cursor += calculateDuration(ev.duration, RES) * scale;
72
+ }
73
+ else if (ev.type === "tuplet" || ev.type === "times") {
74
+ const r = ev.ratio || { numerator: 1, denominator: 1 };
75
+ cursor = walkVoice(ev.events || [], staff, voice, scale * (r.numerator / r.denominator), cursor, clefRef, out);
76
+ }
77
+ else if (ev.type === "tremolo") {
78
+ const each = (RES * 4 / ev.division) * scale;
79
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
80
+ midi: (ev.pitchA || []).map((p) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
81
+ cursor += each;
82
+ out.push({ onset: cursor, onsetNorm: 0, durationDiv: each,
83
+ midi: (ev.pitchB || []).map((p) => pitchToMidi(p, clefRef.shift)), staff, voice, grace: false });
84
+ cursor += each;
85
+ }
86
+ // barline / markup / dynamic / harmony / pitchReset: no time
87
+ }
88
+ return cursor;
89
+ };
90
+ /**
91
+ * Per-measure note onsets for a parsed LilyletDoc. The active clef transposition is tracked
92
+ * per (positional) voice index and PERSISTS across measures — a \clef stays in force until the
93
+ * next one, so a "treble_8" set in measure 1 still lowers measure 2 by an octave.
94
+ *
95
+ * Measure length (`measureDivisions`, used to normalize onsets to [0,1)) comes from the time
96
+ * signature for a full bar, but an INCOMPLETE bar carrying a `\partial` (a pickup, or a bar
97
+ * split by a repeat barline) is only as long as its `\partial` value. Honoring `\partial` makes
98
+ * the lilylet bar length match the corresponding MIDI measure's tick span (MuseScore emits a
99
+ * short 1/8 or 3/8 measure for exactly these), so the two onset grids align.
100
+ */
101
+ export const measureOnsets = (doc) => {
102
+ let curTime = null;
103
+ const clefByVoice = [];
104
+ return (doc.measures || []).map((m, mi) => {
105
+ if (m.timeSig)
106
+ curTime = m.timeSig;
107
+ const notes = [];
108
+ let maxCursor = 0;
109
+ let vIndex = 0;
110
+ // a \partial anywhere in the measure (any voice's leading contextChange) shortens the bar.
111
+ let partial = null;
112
+ for (const part of m.parts || []) {
113
+ for (const voice of part.voices || []) {
114
+ if (!clefByVoice[vIndex])
115
+ clefByVoice[vIndex] = { shift: 0 };
116
+ for (const ev of voice.events || []) {
117
+ if (ev.type === "context" && ev.partial) {
118
+ partial = ev.partial;
119
+ break;
120
+ }
121
+ if (ev.type === "note" || ev.type === "rest" || ev.type === "tuplet"
122
+ || ev.type === "times" || ev.type === "tremolo")
123
+ break; // partial must precede notes
124
+ }
125
+ const end = walkVoice(voice.events || [], voice.staff ?? 1, vIndex, 1, 0, clefByVoice[vIndex], notes);
126
+ maxCursor = Math.max(maxCursor, end);
127
+ vIndex += 1;
128
+ }
129
+ }
130
+ // bar length: \partial value if the bar is incomplete, else the full time-signature bar,
131
+ // else (no time sig) the longest voice cursor.
132
+ const fullBar = curTime ? RES * 4 * curTime.numerator / curTime.denominator : maxCursor;
133
+ const barDiv = partial ? calculateDuration(partial, RES) : fullBar;
134
+ const span = barDiv > 0 ? barDiv : (maxCursor > 0 ? maxCursor : 1);
135
+ for (const n of notes)
136
+ n.onsetNorm = n.onset / span;
137
+ return {
138
+ index: mi + 1,
139
+ timeSig: curTime ? { numerator: curTime.numerator, denominator: curTime.denominator } : null,
140
+ measureDivisions: span,
141
+ notes,
142
+ };
143
+ });
144
+ };
@@ -0,0 +1 @@
1
+ export * from "./lilylet/onsets.js";
package/lib/onsets.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./lilylet/onsets.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
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",
@@ -49,7 +49,12 @@
49
49
  "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
50
50
  "test:measures": "tsx ./tests/unit/measureLayout.test.ts",
51
51
  "test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
52
+ "test:measures-builder": "tsx ./tests/unit/measureLayoutBuilder.test.ts",
52
53
  "test:abc-repeats": "tsx ./tests/unit/abcRepeatLayout.test.ts",
54
+ "test:abc-instrument": "tsx ./tests/unit/abcInstrumentName.test.ts",
55
+ "test:abc-partial": "tsx ./tests/unit/abcPartialPickup.test.ts",
56
+ "test:clef-onset": "tsx ./tests/unit/clefOnsetTranspose.test.ts",
57
+ "test:fine-rhythm-onset": "tsx ./tests/unit/fineRhythmOnset.test.ts",
53
58
  "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",
54
59
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
55
60
  "ts": "tsx"
@@ -16,6 +16,7 @@ import {
16
16
  RestEvent,
17
17
  ContextChange,
18
18
  TupletEvent,
19
+ TremoloEvent,
19
20
  Pitch,
20
21
  Duration,
21
22
  Mark,
@@ -249,6 +250,65 @@ const convertDuration = (abcDuration: { numerator: number; denominator: number }
249
250
  return { division: Math.pow(2, Math.max(0, log2)), dots: 0 };
250
251
  };
251
252
 
253
+ // A whole note is WHOLE_TICKS ticks. 2^7·3^3·5·7 = 120960 divides by every dot (·3/2, ·7/4),
254
+ // tuplet (÷3, ÷5, ÷7), and common division, so a voice's played length is an exact integer —
255
+ // no float rounding when we compare it to the bar length (unlike calculateDuration's Math.round).
256
+ const WHOLE_TICKS = 120960;
257
+
258
+ // A resolved Duration's tick length (dots + optional lilylet tuplet ratio num/den applied).
259
+ const durationTicks = (d: Duration): number => {
260
+ let t = WHOLE_TICKS / d.division;
261
+ if (d.dots) {
262
+ let dot = t / 2;
263
+ for (let i = 0; i < d.dots; i++) { t += dot; dot /= 2; }
264
+ }
265
+ if (d.tuplet) t = t * d.tuplet.numerator / d.tuplet.denominator;
266
+ return t;
267
+ };
268
+
269
+ // Sum the played tick length of an event list. Context / barline / markup / dynamic events
270
+ // take no time, and GRACE notes steal none (they carry a nominal duration but do not advance
271
+ // the beat). A tuplet/times group scales its inner notes by ratio num/den (e.g. \tuplet 3/2 of
272
+ // eighths = a quarter). This mirrors the parser's own \partial check so the emitted \partial
273
+ // never trips a partial-mismatch warning. Used to measure a voice against its bar length.
274
+ const eventsTicks = (events: Event[]): number => {
275
+ let total = 0;
276
+ for (const ev of events) {
277
+ if (ev.type === "note" || ev.type === "rest") {
278
+ if ((ev as NoteEvent).grace) continue;
279
+ total += durationTicks((ev as NoteEvent | RestEvent).duration);
280
+ }
281
+ else if (ev.type === "tuplet" || ev.type === "times") {
282
+ const t = ev as TupletEvent;
283
+ const factor = t.ratio ? t.ratio.numerator / t.ratio.denominator : 1;
284
+ for (const inner of t.events || []) {
285
+ if ((inner.type === "note" || inner.type === "rest") && !(inner as NoteEvent).grace)
286
+ total += durationTicks(inner.duration) * factor;
287
+ }
288
+ }
289
+ else if (ev.type === "tremolo") {
290
+ const tr = ev as TremoloEvent;
291
+ total += 2 * (WHOLE_TICKS / tr.division);
292
+ }
293
+ }
294
+ return total;
295
+ };
296
+
297
+ // Build a \partial Duration (single {division, dots}) for a pickup of `ticks` whole-note ticks.
298
+ // LilyPond's \partial takes one duration token, so only power-of-2 divisions with up to 2 dots
299
+ // are expressible; a length that isn't (e.g. a 5/8 pickup) returns null and no \partial is added
300
+ // (the bar simply renders short) rather than emitting a wrong duration.
301
+ const ticksToPartialDuration = (ticks: number): Duration | null => {
302
+ if (ticks <= 0) return null;
303
+ for (let division = 1; division <= 128; division *= 2) {
304
+ const base = WHOLE_TICKS / division;
305
+ if (ticks === base) return { division, dots: 0 };
306
+ if (ticks === base * 1.5) return { division, dots: 1 };
307
+ if (ticks === base * 1.75) return { division, dots: 2 };
308
+ }
309
+ return null;
310
+ };
311
+
252
312
  /**
253
313
  * Apply broken rhythm adjustment.
254
314
  * broken > 0 (A>B): current note gets dotted, next gets halved
@@ -564,6 +624,11 @@ const collectAbcRepeatInfo = (bars: AbcBarInfo[]): MeasureRepeatInfo[] => {
564
624
  // ("|:" forward, ":|:"/"::"/":||:" end-and-start).
565
625
  const startsNext = trailBar.includes("|:") || isEndStart;
566
626
  if (startsNext && idx < n) {
627
+ // A new repeat section opening here also CLOSES any still-open ending
628
+ // (a volta cannot span into the next repeat section). abc2xml back-patches
629
+ // the ending-stop onto this measure; without this a final volta (e.g.
630
+ // ":|2 …|:") would overshoot to the next volta/section bar far ahead.
631
+ if (openEndingStart >= 1 && openEndingStart <= idx) closeOpenEnding(idx);
567
632
  infos[idx].repeatStart = true;
568
633
  }
569
634
 
@@ -769,6 +834,16 @@ const abcLayoutToStaves = (layout: ABC.StaffGroup[]): string | null => {
769
834
  * `voiceInstr` maps an ABC voice number to its {name, shortName}. The layout's staff ids
770
835
  * are arc-first voice numbers, so a staff id "5" looks up voice 5's name.
771
836
  */
837
+
838
+ // ABC quoted strings carry literal escape sequences (e.g. `nm="Violin\nsolo"` is the 11
839
+ // characters V-i-o-l-i-n-\-n-s-o-l-o). The lilylet doc model holds DECODED strings — the
840
+ // serializer's escapeString re-encodes a real newline back to `\n`. Without decoding here,
841
+ // the stored backslash would be escaped a second time (`\n` → `\\n`). Mirror the lilylet
842
+ // grammar's `unq` unescape table so both import paths agree.
843
+ const ABC_UNESCAPE: { [c: string]: string } = { n: "\n", r: "\r", t: "\t", '"': '"', "\\": "\\" };
844
+ const unescapeAbcString = (s: string): string =>
845
+ s.replace(/\\(.)/g, (m, c) => (c in ABC_UNESCAPE ? ABC_UNESCAPE[c] : m));
846
+
772
847
  const abcInstrumentsToLilylet = (
773
848
  stavesCode: string,
774
849
  voiceInstr: Map<number, InstrumentName>,
@@ -1484,8 +1559,8 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1484
1559
  if (isNaN(v)) continue;
1485
1560
  const short = props.snm ?? props.sname;
1486
1561
  voiceInstr.set(v, typeof short === "string" && short.length
1487
- ? { name, shortName: short }
1488
- : { name });
1562
+ ? { name: unescapeAbcString(name), shortName: unescapeAbcString(short) }
1563
+ : { name: unescapeAbcString(name) });
1489
1564
  }
1490
1565
  if (voiceInstr.size > 0) {
1491
1566
  const instruments = abcInstrumentsToLilylet(metadata.staves, voiceInstr);
@@ -1519,6 +1594,11 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1519
1594
  // parts are assembled (mirrors the MusicXML path attaching nav to a note).
1520
1595
  const navMarksByMeasure = new Map<number, Mark[]>();
1521
1596
 
1597
+ // Running effective time signature (seeded from the header M:, updated by any inline
1598
+ // [M:...] change). A bar whose played length is SHORTER than this gets a \partial (see
1599
+ // the pickup/anacrusis handling below the per-measure assembly).
1600
+ let curTime: TimeSig | undefined = timeSig;
1601
+
1522
1602
  for (let mi = 0; mi < measures.length; mi++) {
1523
1603
  const abcMeasure = measures[mi];
1524
1604
 
@@ -1726,6 +1806,62 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1726
1806
  }
1727
1807
  }
1728
1808
 
1809
+ // Pickup / short bar → \partial. Update the running time signature from any inline
1810
+ // [M:...] this bar carried (the LAST \time in the bar wins — a bar may restate then
1811
+ // change meter, e.g. \time 3/4 \time 9/8), then compare the bar's played length (the
1812
+ // longest voice, so a bar that is complete in ANY voice is not treated as short)
1813
+ // against the meter's full-bar length. A strictly shorter bar is an anacrusis (bar 1)
1814
+ // or a truncated final bar; emit \partial <bar-length> on the first voice so LilyPond
1815
+ // renders the incomplete bar. A full-measure rest (Z/R) voice is skipped — it fills the
1816
+ // bar by definition, so it must not drag the measured length down.
1817
+ {
1818
+ for (const part of parts) {
1819
+ for (const voice of part.voices) {
1820
+ for (const e of voice.events) {
1821
+ if (e.type === "context" && (e as ContextChange).time) {
1822
+ const t = (e as ContextChange).time!;
1823
+ curTime = { numerator: t.numerator, denominator: t.denominator };
1824
+ }
1825
+ }
1826
+ }
1827
+ }
1828
+ if (curTime) {
1829
+ const barTicks = WHOLE_TICKS * curTime.numerator / curTime.denominator;
1830
+ // \partial is a GLOBAL timing directive — it shortens the bar for every voice at
1831
+ // once. So it is only correct when every sounding voice has the SAME short length.
1832
+ // Collect each voice's played ticks; a full-measure-rest (Z/R) voice fills the bar
1833
+ // and is ignored. If the voices are ragged (different lengths — e.g. a messy meter
1834
+ // change where some voices are 6/8 and others 8/8), no single \partial fits them
1835
+ // all, so we emit none rather than one that mis-sizes some voices.
1836
+ const lengths: number[] = [];
1837
+ let anyFull = false;
1838
+ for (const part of parts) {
1839
+ for (const voice of part.voices) {
1840
+ if (voice.events.some(e => e.type === "rest" && (e as RestEvent).fullMeasure)) { anyFull = true; continue; }
1841
+ const t = eventsTicks(voice.events);
1842
+ if (t > 0) lengths.push(t);
1843
+ }
1844
+ }
1845
+ const uniform = lengths.length > 0 && lengths.every(t => t === lengths[0]);
1846
+ const played = uniform ? lengths[0] : 0;
1847
+ if (!anyFull && uniform && played < barTicks) {
1848
+ const partial = ticksToPartialDuration(played);
1849
+ if (partial) {
1850
+ // Insert after any leading key/time context so the emitted order is
1851
+ // \key … \time … \partial … (LilyPond wants \partial after \time).
1852
+ const target = measure.parts[0]?.voices[0];
1853
+ if (target) {
1854
+ let at = 0;
1855
+ while (at < target.events.length && target.events[at].type === "context"
1856
+ && ((target.events[at] as ContextChange).key || (target.events[at] as ContextChange).time)
1857
+ && !(target.events[at] as ContextChange).clef) at++;
1858
+ target.events.splice(at, 0, { type: "context", partial } as ContextChange);
1859
+ }
1860
+ }
1861
+ }
1862
+ }
1863
+ }
1864
+
1729
1865
  lilyletMeasures.push(measure);
1730
1866
  }
1731
1867
 
@@ -4,6 +4,7 @@ export * from "./parser";
4
4
  export * from "./serializer";
5
5
  export * from "./staffLayout";
6
6
  export * from "./measureLayout";
7
+ export * from "./onsets";
7
8
 
8
9
  import * as meiEncoder from "./meiEncoder";
9
10
  import * as musicXmlDecoder from "./musicXmlDecoder";