@k-l-lambda/lilylet 0.1.81 → 0.1.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/lilylet/abcDecoder.js +143 -2
- package/lib/lilylet/grammar.jison.js +275 -213
- package/lib/lilylet/highlight.d.ts +1 -1
- package/lib/lilylet/highlight.js +8 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +185 -70
- package/lib/lilylet/meiEncoder.js +50 -8
- package/lib/lilylet/onsets.d.ts +53 -0
- package/lib/lilylet/onsets.js +144 -0
- package/lib/lilylet/serializer.js +36 -0
- package/lib/lilylet/types.d.ts +11 -0
- package/lib/onsets.d.ts +1 -0
- package/lib/onsets.js +1 -0
- package/package.json +7 -1
- package/source/lilylet/abcDecoder.ts +138 -2
- package/source/lilylet/grammar.jison.js +275 -213
- package/source/lilylet/highlight.ts +9 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +67 -5
- package/source/lilylet/measureLayoutFromXml.ts +190 -69
- package/source/lilylet/meiEncoder.ts +62 -9
- package/source/lilylet/onsets.ts +158 -0
- package/source/lilylet/serializer.ts +37 -0
- package/source/lilylet/types.ts +24 -0
|
@@ -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
|
+
};
|
|
@@ -788,6 +788,14 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
788
788
|
if (graceGroupOpen) {
|
|
789
789
|
parts.push('}');
|
|
790
790
|
}
|
|
791
|
+
// \addlyrics lanes are scoped to this voice; emit them inside the voice so
|
|
792
|
+
// they bind to THIS voice's notes (e.g. lyrics under staff 1 serialize
|
|
793
|
+
// within staff 1's voice, not as a trailing measure-level block).
|
|
794
|
+
if (voice.lyrics && voice.lyrics.length) {
|
|
795
|
+
for (const lane of voice.lyrics) {
|
|
796
|
+
parts.push(serializeLyricLane(lane));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
791
799
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
792
800
|
};
|
|
793
801
|
// Serialize a part, tracking staff state across voices
|
|
@@ -813,6 +821,34 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
|
|
|
813
821
|
// Multiple voices: separated by \\ with newline
|
|
814
822
|
return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
|
|
815
823
|
};
|
|
824
|
+
// Serialize a \addlyrics lane: syllables separated by `--` (hyphen), `_` (skip),
|
|
825
|
+
// `__` (extender). Bare words are emitted unquoted when they contain no
|
|
826
|
+
// reserved characters, otherwise they are quoted and escaped. A syllable may
|
|
827
|
+
// carry text AND a connector: the text is emitted first, then `--`/`__`, so
|
|
828
|
+
// melismas (`text __`) and hyphenation (`text --`) never lose the syllable text.
|
|
829
|
+
const serializeLyricLane = (lane) => {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const s of lane.syllables) {
|
|
832
|
+
if (s.skip) {
|
|
833
|
+
out.push('_');
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const t = s.text ?? '';
|
|
837
|
+
// An extender with no preceding text is an orphan (e.g. a leading `__`);
|
|
838
|
+
// emit it alone rather than an empty quoted string.
|
|
839
|
+
if (!t && s.extend) {
|
|
840
|
+
out.push('__');
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const bare = /^[^ \t\r\n{}"\\]+$/.test(t);
|
|
844
|
+
out.push(bare ? t : `"${escapeString(t)}"`);
|
|
845
|
+
if (s.hyphen)
|
|
846
|
+
out.push('--');
|
|
847
|
+
if (s.extend)
|
|
848
|
+
out.push('__');
|
|
849
|
+
}
|
|
850
|
+
return `\\addlyrics { ${out.join(' ')} }`;
|
|
851
|
+
};
|
|
816
852
|
// Serialize a measure, tracking staff state across parts
|
|
817
853
|
// Always output key/time at start of each measure
|
|
818
854
|
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, partStaffClefs, partEmittedClefs) => {
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -165,6 +165,16 @@ export interface Tempo {
|
|
|
165
165
|
beat?: Duration;
|
|
166
166
|
bpm?: number;
|
|
167
167
|
}
|
|
168
|
+
export interface LyricSyllable {
|
|
169
|
+
text?: string;
|
|
170
|
+
hyphen?: boolean;
|
|
171
|
+
skip?: boolean;
|
|
172
|
+
extend?: boolean;
|
|
173
|
+
}
|
|
174
|
+
export interface LyricLane {
|
|
175
|
+
verse?: number;
|
|
176
|
+
syllables: LyricSyllable[];
|
|
177
|
+
}
|
|
168
178
|
export interface NoteEvent {
|
|
169
179
|
type: 'note';
|
|
170
180
|
pitches: Pitch[];
|
|
@@ -235,6 +245,7 @@ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | Tuple
|
|
|
235
245
|
export interface Voice {
|
|
236
246
|
staff: number;
|
|
237
247
|
events: Event[];
|
|
248
|
+
lyrics?: LyricLane[];
|
|
238
249
|
}
|
|
239
250
|
export interface Metadata {
|
|
240
251
|
title?: string;
|
package/lib/onsets.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.83",
|
|
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",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
42
42
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
43
43
|
"test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
|
|
44
|
+
"test:lyrics": "tsx ./tests/lyrics-roundtrip.ts",
|
|
44
45
|
"test:xml-roundtrip": "tsx ./tests/musicxml-roundtrip.ts",
|
|
45
46
|
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
46
47
|
"train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
|
|
@@ -49,7 +50,12 @@
|
|
|
49
50
|
"test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
|
|
50
51
|
"test:measures": "tsx ./tests/unit/measureLayout.test.ts",
|
|
51
52
|
"test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
|
|
53
|
+
"test:measures-builder": "tsx ./tests/unit/measureLayoutBuilder.test.ts",
|
|
52
54
|
"test:abc-repeats": "tsx ./tests/unit/abcRepeatLayout.test.ts",
|
|
55
|
+
"test:abc-instrument": "tsx ./tests/unit/abcInstrumentName.test.ts",
|
|
56
|
+
"test:abc-partial": "tsx ./tests/unit/abcPartialPickup.test.ts",
|
|
57
|
+
"test:clef-onset": "tsx ./tests/unit/clefOnsetTranspose.test.ts",
|
|
58
|
+
"test:fine-rhythm-onset": "tsx ./tests/unit/fineRhythmOnset.test.ts",
|
|
53
59
|
"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
60
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
55
61
|
"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
|
|