@k-l-lambda/lilylet 0.1.69 → 0.1.71
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/abc/abc.d.ts +1 -0
- package/lib/abc/grammar.jison.js +4 -1
- package/lib/gmInstruments.d.ts +1 -0
- package/lib/gmInstruments.js +1 -0
- package/lib/lilylet/abcDecoder.js +225 -1
- package/lib/lilylet/gmInstruments.d.ts +1 -0
- package/lib/lilylet/gmInstruments.js +295 -0
- package/lib/lilylet/grammar.jison.js +239 -207
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/meiEncoder.js +252 -35
- package/lib/lilylet/musicXmlDecoder.js +9 -0
- package/lib/lilylet/musicXmlEncoder.js +114 -0
- package/lib/lilylet/serializer.js +13 -0
- package/lib/lilylet/staffLayout.d.ts +62 -0
- package/lib/lilylet/staffLayout.js +288 -0
- package/lib/lilylet/types.d.ts +8 -0
- package/lib/staffLayout.d.ts +1 -0
- package/lib/staffLayout.js +1 -0
- package/package.json +1 -1
- package/source/abc/abc.jison +1 -1
- package/source/abc/abc.ts +1 -0
- package/source/abc/grammar.jison.js +4 -1
- package/source/lilylet/abcDecoder.ts +231 -1
- package/source/lilylet/gmInstruments.ts +305 -0
- package/source/lilylet/grammar.jison.js +239 -207
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +28 -2
- package/source/lilylet/meiEncoder.ts +290 -34
- package/source/lilylet/musicXmlDecoder.ts +9 -0
- package/source/lilylet/musicXmlEncoder.ts +134 -0
- package/source/lilylet/serializer.ts +13 -0
- package/source/lilylet/staffLayout.ts +357 -0
- package/source/lilylet/types.ts +10 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff-layout parser, ported from FindLab starry (app/staffLayout/).
|
|
3
|
+
*
|
|
4
|
+
* The layout string uses STAFF as the leaf unit (distinct from ABC's %%score,
|
|
5
|
+
* which is voice-leaf). Brackets group staves: `{}` = Brace (grand staff),
|
|
6
|
+
* `<>` = Bracket, `[]` = Square; conjunctions between consecutive staves:
|
|
7
|
+
* `,` = Blank, `-` = Solid, `.` = Dashed. Staff ids are [a-zA-Z_0-9]+; a slot
|
|
8
|
+
* with no id is an anonymous staff (auto-named "1","2",…).
|
|
9
|
+
*
|
|
10
|
+
* Example: "<[v1-v2].va> {pl-pr} <b>" → 6 staves v1,v2,va,pl,pr,b grouped as
|
|
11
|
+
* a Bracket over { a Square [v1-v2] dashed-joined to va }, a Brace {pl-pr},
|
|
12
|
+
* and a Bracket <b>.
|
|
13
|
+
*/
|
|
14
|
+
export var StaffGroupType;
|
|
15
|
+
(function (StaffGroupType) {
|
|
16
|
+
StaffGroupType[StaffGroupType["Default"] = 0] = "Default";
|
|
17
|
+
StaffGroupType[StaffGroupType["Brace"] = 1] = "Brace";
|
|
18
|
+
StaffGroupType[StaffGroupType["Bracket"] = 2] = "Bracket";
|
|
19
|
+
StaffGroupType[StaffGroupType["Square"] = 3] = "Square";
|
|
20
|
+
})(StaffGroupType || (StaffGroupType = {}));
|
|
21
|
+
export var StaffConjunctionType;
|
|
22
|
+
(function (StaffConjunctionType) {
|
|
23
|
+
StaffConjunctionType[StaffConjunctionType["Blank"] = 0] = "Blank";
|
|
24
|
+
StaffConjunctionType[StaffConjunctionType["Dashed"] = 1] = "Dashed";
|
|
25
|
+
StaffConjunctionType[StaffConjunctionType["Solid"] = 2] = "Solid";
|
|
26
|
+
})(StaffConjunctionType || (StaffConjunctionType = {}));
|
|
27
|
+
const singleGroup = (id) => ({ type: StaffGroupType.Default, staff: id });
|
|
28
|
+
const BOUNDS_TO_GROUPTYPE = {
|
|
29
|
+
"{": StaffGroupType.Brace,
|
|
30
|
+
"}": StaffGroupType.Brace,
|
|
31
|
+
"<": StaffGroupType.Bracket,
|
|
32
|
+
">": StaffGroupType.Bracket,
|
|
33
|
+
"[": StaffGroupType.Square,
|
|
34
|
+
"]": StaffGroupType.Square,
|
|
35
|
+
};
|
|
36
|
+
const OPEN_BOUNDS = "{<[";
|
|
37
|
+
const CLOSE_BOUNDS = "}>]";
|
|
38
|
+
const CONJUNCTIONS_MAP = {
|
|
39
|
+
",": StaffConjunctionType.Blank,
|
|
40
|
+
"-": StaffConjunctionType.Solid,
|
|
41
|
+
".": StaffConjunctionType.Dashed,
|
|
42
|
+
};
|
|
43
|
+
// MEI staffGrp @symbol by StaffGroupType (Default → none). MEI's allowed values
|
|
44
|
+
// are brace | bracket | bracketsq | line | none — note the square variant is
|
|
45
|
+
// "bracketsq", NOT "square" (the latter is MusicXML's <group-symbol> value).
|
|
46
|
+
const GROUP_SYMBOLS_MEI = [null, "brace", "bracket", "bracketsq"];
|
|
47
|
+
const randomB64 = () => {
|
|
48
|
+
const code = Buffer.from(Math.random().toString().slice(2)).toString("base64").replace(/=/g, "");
|
|
49
|
+
return code.split("").reverse().slice(0, 6).join("");
|
|
50
|
+
};
|
|
51
|
+
const makeUniqueName = (set, index, prefix) => {
|
|
52
|
+
let name = prefix;
|
|
53
|
+
if (!name)
|
|
54
|
+
name = index.toString();
|
|
55
|
+
else if (set.has(name))
|
|
56
|
+
name += "_" + index.toString();
|
|
57
|
+
while (set.has(name))
|
|
58
|
+
name = (prefix ? prefix + "_" : "") + randomB64();
|
|
59
|
+
return name;
|
|
60
|
+
};
|
|
61
|
+
// Tokenize a layout string into RawItem[] (one per staff slot). Lexer: whitespace
|
|
62
|
+
// skipped, single chars [-,.{}<>[]] are bounds/conjunctions, [a-zA-Z_0-9]+ is an id.
|
|
63
|
+
// An item accumulates leading open-bounds (leftBounds), an optional id, trailing
|
|
64
|
+
// close-bounds (rightBounds); a conjunction terminates the item and starts the next.
|
|
65
|
+
const tokenize = (code) => {
|
|
66
|
+
const tokens = code.match(/[A-Za-z0-9_]+|[-,.{}<>\[\]]/g) || [];
|
|
67
|
+
const items = [];
|
|
68
|
+
let cur = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
|
|
69
|
+
let seenId = false; // id slot filled for the current item
|
|
70
|
+
let seenRight = false; // started collecting right bounds / closing
|
|
71
|
+
const pushItem = () => {
|
|
72
|
+
items.push(cur);
|
|
73
|
+
cur = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
|
|
74
|
+
seenId = false;
|
|
75
|
+
seenRight = false;
|
|
76
|
+
};
|
|
77
|
+
for (const tok of tokens) {
|
|
78
|
+
if (tok in CONJUNCTIONS_MAP) {
|
|
79
|
+
cur.conjunction = tok;
|
|
80
|
+
pushItem();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (OPEN_BOUNDS.includes(tok)) {
|
|
84
|
+
// An open bound after the id/closing starts a fresh item's left bounds.
|
|
85
|
+
if (seenId || seenRight)
|
|
86
|
+
pushItem();
|
|
87
|
+
cur.leftBounds.push(tok);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (CLOSE_BOUNDS.includes(tok)) {
|
|
91
|
+
cur.rightBounds.push(tok);
|
|
92
|
+
seenRight = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// id token
|
|
96
|
+
if (seenId || seenRight)
|
|
97
|
+
pushItem();
|
|
98
|
+
cur.id = tok;
|
|
99
|
+
seenId = true;
|
|
100
|
+
}
|
|
101
|
+
// Flush the final item if it carries any content.
|
|
102
|
+
if (cur.id !== null || cur.leftBounds.length || cur.rightBounds.length)
|
|
103
|
+
pushItem();
|
|
104
|
+
return items;
|
|
105
|
+
};
|
|
106
|
+
const makeGroupsFromRaw = (parent, seq) => {
|
|
107
|
+
let remains = seq;
|
|
108
|
+
while (remains.length) {
|
|
109
|
+
const word = remains.shift();
|
|
110
|
+
const bound = BOUNDS_TO_GROUPTYPE[word];
|
|
111
|
+
if (bound !== undefined) {
|
|
112
|
+
if (CLOSE_BOUNDS.includes(word) && bound === parent.type)
|
|
113
|
+
break;
|
|
114
|
+
if (OPEN_BOUNDS.includes(word)) {
|
|
115
|
+
const group = { type: bound, level: Number.isFinite(parent.level) ? parent.level + 1 : 0 };
|
|
116
|
+
remains = makeGroupsFromRaw(group, remains);
|
|
117
|
+
parent.subs = parent.subs || [];
|
|
118
|
+
parent.subs.push(group);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
parent.subs = parent.subs || [];
|
|
123
|
+
parent.subs.push(singleGroup(word));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
while (parent.type === StaffGroupType.Default && parent.subs && parent.subs.length === 1) {
|
|
127
|
+
const sub = parent.subs[0];
|
|
128
|
+
parent.type = sub.type;
|
|
129
|
+
parent.subs = sub.subs;
|
|
130
|
+
parent.staff = sub.staff;
|
|
131
|
+
parent.level = sub.level;
|
|
132
|
+
}
|
|
133
|
+
while (parent.subs && parent.subs.length === 1 && parent.subs[0].type === StaffGroupType.Default) {
|
|
134
|
+
const sub = parent.subs[0];
|
|
135
|
+
parent.subs = sub.subs;
|
|
136
|
+
parent.staff = sub.staff;
|
|
137
|
+
}
|
|
138
|
+
parent.grand = parent.type === StaffGroupType.Brace && !!parent.subs && parent.subs.every(sub => !!sub.staff);
|
|
139
|
+
return remains;
|
|
140
|
+
};
|
|
141
|
+
const groupHead = (group) => {
|
|
142
|
+
if (group.staff)
|
|
143
|
+
return group.staff;
|
|
144
|
+
else if (group.subs)
|
|
145
|
+
return groupHead(group.subs[0]);
|
|
146
|
+
};
|
|
147
|
+
const groupTail = (group) => {
|
|
148
|
+
if (group.staff)
|
|
149
|
+
return group.staff;
|
|
150
|
+
else if (group.subs)
|
|
151
|
+
return groupTail(group.subs[group.subs.length - 1]);
|
|
152
|
+
};
|
|
153
|
+
export const groupKey = (group) => {
|
|
154
|
+
if (group.staff)
|
|
155
|
+
return group.staff;
|
|
156
|
+
else if (group.subs)
|
|
157
|
+
return `${groupHead(group)}-${groupTail(group)}`;
|
|
158
|
+
};
|
|
159
|
+
const groupDict = (group, dict) => {
|
|
160
|
+
const key = groupKey(group);
|
|
161
|
+
if (key !== undefined)
|
|
162
|
+
dict[key] = group;
|
|
163
|
+
if (group.subs)
|
|
164
|
+
group.subs.forEach(sub => groupDict(sub, dict));
|
|
165
|
+
};
|
|
166
|
+
export class StaffLayout {
|
|
167
|
+
staffIds;
|
|
168
|
+
conjunctions;
|
|
169
|
+
group;
|
|
170
|
+
groups;
|
|
171
|
+
constructor(raw) {
|
|
172
|
+
// make unique ids (anonymous slots get "1","2",… ; named collisions disambiguated)
|
|
173
|
+
const ids = new Set();
|
|
174
|
+
raw.forEach((item, i) => {
|
|
175
|
+
item.id = makeUniqueName(ids, i + 1, item.id || undefined);
|
|
176
|
+
ids.add(item.id);
|
|
177
|
+
});
|
|
178
|
+
this.staffIds = raw.map(item => item.id);
|
|
179
|
+
this.conjunctions = raw.slice(0, raw.length - 1).map(item => item.conjunction ? CONJUNCTIONS_MAP[item.conjunction] : StaffConjunctionType.Blank);
|
|
180
|
+
// make groups
|
|
181
|
+
const seq = [].concat(...raw.map(item => [...item.leftBounds, item.id, ...item.rightBounds]));
|
|
182
|
+
this.group = { type: StaffGroupType.Default };
|
|
183
|
+
makeGroupsFromRaw(this.group, seq);
|
|
184
|
+
const dict = {};
|
|
185
|
+
groupDict(this.group, dict);
|
|
186
|
+
this.groups = Object.entries(dict).map(([key, group]) => {
|
|
187
|
+
let ids = key.split("-");
|
|
188
|
+
if (ids.length === 1)
|
|
189
|
+
ids = [ids[0], ids[0]];
|
|
190
|
+
const range = ids.map(id => this.staffIds.indexOf(id));
|
|
191
|
+
const cons = this.conjunctions.slice(range[0], range[1]);
|
|
192
|
+
const bar = cons.length ? Math.min(...cons) : 0;
|
|
193
|
+
group.key = key;
|
|
194
|
+
group.bar = bar;
|
|
195
|
+
return { group, range, key };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
get stavesCount() {
|
|
199
|
+
return this.staffIds.length;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export const parseStaffLayout = (code) => new StaffLayout(tokenize(code));
|
|
203
|
+
// ── Staff-layout serialization (inverse of parseStaffLayout) ──
|
|
204
|
+
// Reconstruct a layout string from a parsed StaffLayout by walking the group tree,
|
|
205
|
+
// so every staff slot and conjunction is preserved structurally (a regex strip of the
|
|
206
|
+
// ids would drop a BARE anonymous leaf — its empty token gets swallowed by whitespace).
|
|
207
|
+
//
|
|
208
|
+
// `anonymous` emits empty ids (the parser re-auto-names slots "1","2",… by position).
|
|
209
|
+
// `idMap` optionally overrides individual staff ids by their original id.
|
|
210
|
+
//
|
|
211
|
+
// Conjunction rendering: Solid → "-", Dashed → ".", Blank → " " ONLY when both sides
|
|
212
|
+
// are bracketed groups (the brackets self-delimit the slots); otherwise Blank → ","
|
|
213
|
+
// so an adjacent empty/bare leaf still tokenizes as its own slot.
|
|
214
|
+
const CONJ_CHAR = {
|
|
215
|
+
[StaffConjunctionType.Solid]: "-",
|
|
216
|
+
[StaffConjunctionType.Dashed]: ".",
|
|
217
|
+
[StaffConjunctionType.Blank]: ",",
|
|
218
|
+
};
|
|
219
|
+
export const serializeStaffLayout = (layout, options = {}) => {
|
|
220
|
+
const { anonymous = false, idMap } = options;
|
|
221
|
+
const isGrouped = (group) => group.type !== StaffGroupType.Default && !!group.subs;
|
|
222
|
+
const leafText = (id) => (anonymous ? "" : idMap ? idMap(id) : id);
|
|
223
|
+
// flat leaf index of a group's first / last staff (for the inter-child conjunction).
|
|
224
|
+
const firstLeafIndex = (group) => layout.staffIds.indexOf(groupHead(group));
|
|
225
|
+
const lastLeafIndex = (group) => layout.staffIds.indexOf(groupTail(group));
|
|
226
|
+
const sep = (conj, left, right) => {
|
|
227
|
+
if (conj !== StaffConjunctionType.Blank)
|
|
228
|
+
return CONJ_CHAR[conj];
|
|
229
|
+
// Blank: a space is safe only when both neighbours are bracketed (self-delimiting).
|
|
230
|
+
return isGrouped(left) && isGrouped(right) ? " " : ",";
|
|
231
|
+
};
|
|
232
|
+
const emit = (group) => {
|
|
233
|
+
if (!group.subs)
|
|
234
|
+
return leafText(group.staff); // Default leaf
|
|
235
|
+
const open = group.type === StaffGroupType.Brace ? "{" : group.type === StaffGroupType.Bracket ? "<" : group.type === StaffGroupType.Square ? "[" : "";
|
|
236
|
+
const close = group.type === StaffGroupType.Brace ? "}" : group.type === StaffGroupType.Bracket ? ">" : group.type === StaffGroupType.Square ? "]" : "";
|
|
237
|
+
let inner = "";
|
|
238
|
+
group.subs.forEach((sub, i) => {
|
|
239
|
+
inner += emit(sub);
|
|
240
|
+
if (i < group.subs.length - 1) {
|
|
241
|
+
const next = group.subs[i + 1];
|
|
242
|
+
const conj = layout.conjunctions[lastLeafIndex(sub)] ?? StaffConjunctionType.Blank;
|
|
243
|
+
inner += sep(conj, sub, next);
|
|
244
|
+
void firstLeafIndex; // (lastLeafIndex(sub) === firstLeafIndex(next) - 1)
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
return open + inner + close;
|
|
248
|
+
};
|
|
249
|
+
let out = emit(layout.group);
|
|
250
|
+
// A TRAILING bare anonymous leaf emits "" with nothing after it to delimit the slot
|
|
251
|
+
// (a leaf before a closing bracket is fine — the bracket gives it bounds; an internal
|
|
252
|
+
// one is flushed by the next separator). The tokenizer only flushes a final empty item
|
|
253
|
+
// if it carries bounds, so append one "," to materialize that last empty slot. This only
|
|
254
|
+
// arises when the OUTERMOST container is the Default sequence (no enclosing bracket) and
|
|
255
|
+
// its last child is a bare leaf; if the whole layout is wrapped in a bracket, the closing
|
|
256
|
+
// bracket already delimits the final leaf. The trailing conjunction is dropped on re-parse
|
|
257
|
+
// (conjunctions = items[0..n-1]), so it is harmless. Anonymous output only.
|
|
258
|
+
if (anonymous && layout.group.type === StaffGroupType.Default && layout.group.subs) {
|
|
259
|
+
const lastTop = layout.group.subs[layout.group.subs.length - 1];
|
|
260
|
+
if (!lastTop.subs && lastTop.staff !== undefined)
|
|
261
|
+
out += ",";
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
};
|
|
265
|
+
// ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
|
|
266
|
+
// Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
|
|
267
|
+
// with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
|
|
268
|
+
// label. Returns the inner XML (no <scoreDef> wrapper); the caller positions it.
|
|
269
|
+
const bool = (x) => (x ? "true" : "false");
|
|
270
|
+
const stateMEIGroup = (statements, group, nameDict, ids, indent, tab) => {
|
|
271
|
+
const pad = tab.repeat(indent);
|
|
272
|
+
const name = group.key !== undefined ? nameDict[group.key] : undefined;
|
|
273
|
+
if (group.subs) {
|
|
274
|
+
const symbol = GROUP_SYMBOLS_MEI[group.type] ? ` symbol="${GROUP_SYMBOLS_MEI[group.type]}"` : "";
|
|
275
|
+
statements.push(`${pad}<staffGrp bar.thru="${bool((group.bar ?? 0) > 1)}"${symbol}>`);
|
|
276
|
+
if (name)
|
|
277
|
+
statements.push(`${pad}${tab}<label>${name}</label>`);
|
|
278
|
+
group.subs.forEach(sub => stateMEIGroup(statements, sub, nameDict, ids, indent + 1, tab));
|
|
279
|
+
statements.push(`${pad}</staffGrp>`);
|
|
280
|
+
}
|
|
281
|
+
if (group.staff)
|
|
282
|
+
statements.push(`${pad}<staffDef n="${ids.indexOf(group.staff) + 1}">`);
|
|
283
|
+
};
|
|
284
|
+
export const encodeStaffLayoutMEI = (layout, nameDict = {}, indent = 0, tab = "\t") => {
|
|
285
|
+
const statements = [];
|
|
286
|
+
stateMEIGroup(statements, layout.group, nameDict, layout.staffIds, indent, tab);
|
|
287
|
+
return statements.join("\n");
|
|
288
|
+
};
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -239,8 +239,16 @@ export interface Metadata {
|
|
|
239
239
|
opus?: string;
|
|
240
240
|
instrument?: string;
|
|
241
241
|
genre?: string;
|
|
242
|
+
staves?: string;
|
|
243
|
+
instruments?: {
|
|
244
|
+
[key: string]: InstrumentName;
|
|
245
|
+
};
|
|
242
246
|
autoBeam?: 'auto' | 'on' | 'off';
|
|
243
247
|
}
|
|
248
|
+
export interface InstrumentName {
|
|
249
|
+
name: string;
|
|
250
|
+
shortName?: string;
|
|
251
|
+
}
|
|
244
252
|
export interface Part {
|
|
245
253
|
name?: string;
|
|
246
254
|
voices: Voice[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/staffLayout.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/staffLayout.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.71",
|
|
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",
|
package/source/abc/abc.jison
CHANGED
|
@@ -361,7 +361,7 @@ staff_layout
|
|
|
361
361
|
staff_layout_items
|
|
362
362
|
: staff_layout_item -> [$1]
|
|
363
363
|
| staff_layout_items staff_layout_item -> [...$1, $2]
|
|
364
|
-
| staff_layout_items '|'
|
|
364
|
+
| staff_layout_items '|' { if ($1.length) $1[$1.length - 1].barThruAfter = true; $$ = $1; }
|
|
365
365
|
;
|
|
366
366
|
|
|
367
367
|
staff_layout_item
|
package/source/abc/abc.ts
CHANGED
|
@@ -95,7 +95,7 @@ break;
|
|
|
95
95
|
case 4:
|
|
96
96
|
this.$ = tune($$[$0-1], $$[$0]);
|
|
97
97
|
break;
|
|
98
|
-
case 11: case 12: case 13: case 14: case 16: case
|
|
98
|
+
case 11: case 12: case 13: case 14: case 16: case 67: case 101: case 104: case 105: case 106: case 107: case 136: case 137: case 163: case 164: case 222:
|
|
99
99
|
this.$ = $$[$0-1];
|
|
100
100
|
break;
|
|
101
101
|
case 15:
|
|
@@ -104,6 +104,9 @@ break;
|
|
|
104
104
|
case 17:
|
|
105
105
|
this.$ = ({staffLayout: $$[$0]});
|
|
106
106
|
break;
|
|
107
|
+
case 20:
|
|
108
|
+
if ($$[$0-1].length) $$[$0-1][$$[$0-1].length - 1].barThruAfter = true; this.$ = $$[$0-1];
|
|
109
|
+
break;
|
|
107
110
|
case 21:
|
|
108
111
|
this.$ = staffGroup([$$[$0]]);
|
|
109
112
|
break;
|
|
@@ -37,7 +37,9 @@ import {
|
|
|
37
37
|
MarkupEvent,
|
|
38
38
|
DynamicEvent,
|
|
39
39
|
Placement,
|
|
40
|
+
InstrumentName,
|
|
40
41
|
} from "./types";
|
|
42
|
+
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
// ============ Constants ============
|
|
@@ -395,6 +397,9 @@ const convertClef = (clefStr: string): Clef | undefined => {
|
|
|
395
397
|
// Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
|
|
396
398
|
// ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
|
|
397
399
|
// LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
|
|
400
|
+
// (Only octave amounts 8/15 are handled here; the Lilylet "_N"/"^N" clef suffix
|
|
401
|
+
// itself accepts arbitrary diatonic intervals — see meiEncoder.resolveClef — and a
|
|
402
|
+
// voice's transpose= property is folded into the same suffix via transposeClefSuffix.)
|
|
398
403
|
const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
|
|
399
404
|
const base = shift ? shift[1] : clefStr;
|
|
400
405
|
let resolved: string | undefined;
|
|
@@ -408,6 +413,29 @@ const convertClef = (clefStr: string): Clef | undefined => {
|
|
|
408
413
|
return resolved as Clef;
|
|
409
414
|
};
|
|
410
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Fold an ABC voice `transpose=N` property (a written→sounding shift in
|
|
418
|
+
* SEMITONES) into the Lilylet clef-suffix form `_M` / `^M`, where M is a
|
|
419
|
+
* diatonic interval number. ABC carries only semitones, so the diatonic
|
|
420
|
+
* interval is approximated by the nearest scale-step count
|
|
421
|
+
* (steps = round(semi * 7/12), interval number = |steps| + 1); `_` lowers,
|
|
422
|
+
* `^` raises. The Lilylet→MEI encoder later expands the suffix back into
|
|
423
|
+
* trans.diat / trans.semi (see meiEncoder.resolveClef). This is exact when the
|
|
424
|
+
* semitone count is a major/perfect interval (e.g. -2, -9, +2, ±12) and a
|
|
425
|
+
* nearest-interval approximation otherwise.
|
|
426
|
+
*/
|
|
427
|
+
const transposeClefSuffix = (clef: Clef | undefined, semitones: number): Clef | undefined => {
|
|
428
|
+
if (!clef || !semitones) return clef;
|
|
429
|
+
// A clef that already carries an octave suffix (treble_8 etc.) is left as-is;
|
|
430
|
+
// stacking another shift on top is not meaningful for ABC sources.
|
|
431
|
+
if (/[_^]\d+$/.test(clef as string)) return clef;
|
|
432
|
+
const steps = Math.round((semitones * 7) / 12);
|
|
433
|
+
if (steps === 0) return clef;
|
|
434
|
+
const num = Math.abs(steps) + 1;
|
|
435
|
+
const suffix = (steps < 0 ? "_" : "^") + num;
|
|
436
|
+
return (clef + suffix) as Clef;
|
|
437
|
+
};
|
|
438
|
+
|
|
411
439
|
/**
|
|
412
440
|
* Convert ABC barline to Lilylet barline style
|
|
413
441
|
*/
|
|
@@ -516,6 +544,168 @@ const parseScoreLayout = (
|
|
|
516
544
|
};
|
|
517
545
|
|
|
518
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Translate an ABC %%score layout into the equivalent lilylet staves expression.
|
|
549
|
+
*
|
|
550
|
+
* The two models differ in their leaf unit: ABC %%score is VOICE-leaf (an arc `( … )`
|
|
551
|
+
* collapses several voices onto one staff), whereas lilylet staves is STAFF-leaf. So
|
|
552
|
+
* each ABC staff becomes exactly one lilylet staff id, named (per spec) by the FIRST
|
|
553
|
+
* voice token inside its arc.
|
|
554
|
+
*
|
|
555
|
+
* Bracket mapping (ABC → lilylet):
|
|
556
|
+
* ( … ) arc → one staff leaf (id = first voice) e.g. (1 3) → "1"
|
|
557
|
+
* bare leaf → one staff leaf (id = that voice) e.g. 7 → "7"
|
|
558
|
+
* { … } curly → brace (grand staff) → { … }
|
|
559
|
+
* [ … ] square → bracket (orchestral) → < … >
|
|
560
|
+
*
|
|
561
|
+
* Conjunction between two siblings: a trailing `|` in %%score means barlines run through
|
|
562
|
+
* the two staves, mapped to the solid conjunction `-`; otherwise the blank `,` is used.
|
|
563
|
+
*/
|
|
564
|
+
const abcLayoutToStaves = (layout: ABC.StaffGroup[]): string | null => {
|
|
565
|
+
// First voice token under a node (the staff's lilylet id).
|
|
566
|
+
const firstVoice = (node: ABC.StaffGroup | string): string | null => {
|
|
567
|
+
if (typeof node === "string") return node;
|
|
568
|
+
for (const item of node.items || []) {
|
|
569
|
+
const v = firstVoice(item);
|
|
570
|
+
if (v !== null) return v;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// A node is a single staff (an arc, or a bare leaf) iff it has no curly/square nesting.
|
|
576
|
+
const isStaffLeaf = (node: ABC.StaffGroup | string): boolean => {
|
|
577
|
+
if (typeof node === "string") return true;
|
|
578
|
+
if (node.bound === "curly" || node.bound === "square") return false;
|
|
579
|
+
// arc or unbounded: a staff only if every descendant is too (no nested groups)
|
|
580
|
+
return (node.items || []).every(isStaffLeaf);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// A square group maps to lilylet Bracket `<>` at the TOP level, but to lilylet Square
|
|
584
|
+
// `[]` when nested inside another group — e.g. ABC `[[1 2] 3 | 4]` → `<[1,2]3-4>`.
|
|
585
|
+
// A curly group always maps to Brace `{}`. `nested` is false for a top-level entry.
|
|
586
|
+
const emit = (node: ABC.StaffGroup | string, nested: boolean): string => {
|
|
587
|
+
if (isStaffLeaf(node)) return firstVoice(node) || "";
|
|
588
|
+
|
|
589
|
+
const group = node as ABC.StaffGroup;
|
|
590
|
+
const open = group.bound === "curly" ? "{" : (group.bound === "square" && nested) ? "[" : "<";
|
|
591
|
+
const close = group.bound === "curly" ? "}" : (group.bound === "square" && nested) ? "]" : ">";
|
|
592
|
+
|
|
593
|
+
const items = group.items || [];
|
|
594
|
+
let inner = "";
|
|
595
|
+
items.forEach((item, i) => {
|
|
596
|
+
inner += emit(item, true);
|
|
597
|
+
if (i < items.length - 1) {
|
|
598
|
+
// A Blank separator (',') is only needed between two bare staff leaves; a
|
|
599
|
+
// grouped neighbour's bracket already delimits the slot, so suppress it there
|
|
600
|
+
// (giving `[1,2]3` not `[1,2],3`). A Solid join ('-', barThru) is always kept.
|
|
601
|
+
const next = items[i + 1];
|
|
602
|
+
if ((item as ABC.StaffGroup).barThruAfter) inner += "-";
|
|
603
|
+
else if (isStaffLeaf(item) && isStaffLeaf(next)) inner += ",";
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
return `${open}${inner}${close}`;
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const tops = layout.map((top, i) => {
|
|
610
|
+
let s = emit(top, false);
|
|
611
|
+
// A bare top-level staff leaf (e.g. the `9` in `[ … ] 9 [ … ]`) still occupies a slot;
|
|
612
|
+
// emit() already yields its id with no wrapper, which is the desired output.
|
|
613
|
+
return { s, barThru: !!top.barThruAfter, isLast: i === layout.length - 1 };
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
let out = "";
|
|
617
|
+
tops.forEach((t, i) => {
|
|
618
|
+
out += t.s;
|
|
619
|
+
if (i < tops.length - 1) out += t.barThru ? " - " : " ";
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
out = out.trim();
|
|
623
|
+
return out.length > 0 ? out : null;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Translate ABC voice instrument names (V:n nm="…" snm="…") into a lilylet `instruments`
|
|
629
|
+
* map keyed by staff-layout group key.
|
|
630
|
+
*
|
|
631
|
+
* ABC names sit on individual voices; lilylet staves are staff-leaf (an arc's first voice
|
|
632
|
+
* is the staff id). So we first collect a per-staff name from the staff-id voice. Then,
|
|
633
|
+
* per the user's rule: if a GROUP carries an instrument only on its first staff and the
|
|
634
|
+
* rest of the group is unnamed, the name belongs to the whole group — hoist it to the
|
|
635
|
+
* group key and drop it from the leaf. This matches engraving convention (one bracketed
|
|
636
|
+
* section, e.g. "Violini", named once for the group rather than on its top staff).
|
|
637
|
+
*
|
|
638
|
+
* `voiceInstr` maps an ABC voice number to its {name, shortName}. The layout's staff ids
|
|
639
|
+
* are arc-first voice numbers, so a staff id "5" looks up voice 5's name.
|
|
640
|
+
*/
|
|
641
|
+
const abcInstrumentsToLilylet = (
|
|
642
|
+
stavesCode: string,
|
|
643
|
+
voiceInstr: Map<number, InstrumentName>,
|
|
644
|
+
): { [key: string]: InstrumentName } | null => {
|
|
645
|
+
const layout = parseStaffLayout(stavesCode);
|
|
646
|
+
const result: { [key: string]: InstrumentName } = {};
|
|
647
|
+
|
|
648
|
+
// Per-staff instrument, keyed by staff id (the arc-first voice number as a string).
|
|
649
|
+
const staffInstr = new Map<string, InstrumentName>();
|
|
650
|
+
for (const id of layout.staffIds) {
|
|
651
|
+
const v = parseInt(id, 10);
|
|
652
|
+
if (!isNaN(v) && voiceInstr.has(v)) staffInstr.set(id, voiceInstr.get(v)!);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// First staff id under a group (in layout order).
|
|
656
|
+
const firstStaffId = (group: StaffGroup): string | undefined => {
|
|
657
|
+
if (group.staff) return group.staff;
|
|
658
|
+
for (const sub of group.subs || []) {
|
|
659
|
+
const id = firstStaffId(sub);
|
|
660
|
+
if (id !== undefined) return id;
|
|
661
|
+
}
|
|
662
|
+
return undefined;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// All staff ids under a group except the first.
|
|
666
|
+
const restStaffIds = (group: StaffGroup): string[] => {
|
|
667
|
+
const all: string[] = [];
|
|
668
|
+
const collect = (g: StaffGroup) => {
|
|
669
|
+
if (g.staff) all.push(g.staff);
|
|
670
|
+
(g.subs || []).forEach(collect);
|
|
671
|
+
};
|
|
672
|
+
collect(group);
|
|
673
|
+
return all.slice(1);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Walk groups top-down; hoist a first-staff-only name onto the group, else keep leaves.
|
|
677
|
+
const walk = (group: StaffGroup) => {
|
|
678
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
679
|
+
if (isLeaf) {
|
|
680
|
+
// A plain leaf keeps its own name (assigned in the flush pass below).
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const first = firstStaffId(group);
|
|
685
|
+
const firstInstr = first !== undefined ? staffInstr.get(first) : undefined;
|
|
686
|
+
const rest = restStaffIds(group);
|
|
687
|
+
const restNamed = rest.some(id => staffInstr.has(id));
|
|
688
|
+
|
|
689
|
+
// Group with a real grouping symbol, named only on its first staff → hoist.
|
|
690
|
+
const hasSymbol = group.type !== StaffGroupType.Default;
|
|
691
|
+
if (hasSymbol && firstInstr && !restNamed && group.key !== undefined) {
|
|
692
|
+
result[group.key] = firstInstr;
|
|
693
|
+
staffInstr.delete(first!); // consumed by the group
|
|
694
|
+
return; // children below the hoisted name carry nothing
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
for (const sub of group.subs || []) walk(sub);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
walk(layout.group);
|
|
701
|
+
|
|
702
|
+
// Flush any per-staff names that weren't hoisted onto a group.
|
|
703
|
+
for (const [id, instr] of staffInstr) result[id] = instr;
|
|
704
|
+
|
|
705
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
|
|
519
709
|
// ============ Marks/Decorations Conversion ============
|
|
520
710
|
|
|
521
711
|
const convertArticulationMark = (artName: string): Mark | undefined => {
|
|
@@ -1103,7 +1293,17 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
|
|
|
1103
1293
|
properties: voiceValue?.properties,
|
|
1104
1294
|
});
|
|
1105
1295
|
if (clefStr) {
|
|
1106
|
-
|
|
1296
|
+
let clef = convertClef(clefStr);
|
|
1297
|
+
// Fold a voice transpose= (semitones) into the clef suffix so it
|
|
1298
|
+
// survives to MEI as trans.diat/trans.semi (transposing instruments
|
|
1299
|
+
// like "Clarinet transpose=-2", "Horn transpose=-9").
|
|
1300
|
+
const transposeRaw = (voiceValue as any)?.properties?.transpose;
|
|
1301
|
+
const transposeSemi = typeof transposeRaw === "number"
|
|
1302
|
+
? transposeRaw
|
|
1303
|
+
: (transposeRaw != null ? Number(transposeRaw) : NaN);
|
|
1304
|
+
if (clef && Number.isFinite(transposeSemi) && transposeSemi !== 0) {
|
|
1305
|
+
clef = transposeClefSuffix(clef, transposeSemi);
|
|
1306
|
+
}
|
|
1107
1307
|
if (clef) voiceClefs.set(voiceId, clef);
|
|
1108
1308
|
}
|
|
1109
1309
|
}
|
|
@@ -1115,6 +1315,36 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
|
|
|
1115
1315
|
// Parse score layout
|
|
1116
1316
|
const scoreLayout = parseScoreLayout(headers);
|
|
1117
1317
|
|
|
1318
|
+
// Translate the ABC %%score layout into a lilylet staves expression (staff-leaf model).
|
|
1319
|
+
const layoutHeader = headers.find((h: any) => h.staffLayout);
|
|
1320
|
+
if (layoutHeader) {
|
|
1321
|
+
const staves = abcLayoutToStaves((layoutHeader as any).staffLayout);
|
|
1322
|
+
if (staves) metadata.staves = staves;
|
|
1323
|
+
|
|
1324
|
+
// Translate per-voice nm/snm into a lilylet instruments map. Collect names by ABC
|
|
1325
|
+
// voice number (the staff id is the arc-first voice), then let abcInstrumentsToLilylet
|
|
1326
|
+
// apply the first-staff-only → whole-group hoisting rule.
|
|
1327
|
+
if (metadata.staves) {
|
|
1328
|
+
const voiceInstr = new Map<number, InstrumentName>();
|
|
1329
|
+
for (const [vid, config] of voiceConfigs) {
|
|
1330
|
+
const props = config.properties;
|
|
1331
|
+
if (!props) continue;
|
|
1332
|
+
const name = props.nm ?? props.name;
|
|
1333
|
+
if (typeof name !== "string" || !name.length) continue;
|
|
1334
|
+
const v = typeof vid === "number" ? vid : parseInt(String(vid), 10);
|
|
1335
|
+
if (isNaN(v)) continue;
|
|
1336
|
+
const short = props.snm ?? props.sname;
|
|
1337
|
+
voiceInstr.set(v, typeof short === "string" && short.length
|
|
1338
|
+
? { name, shortName: short }
|
|
1339
|
+
: { name });
|
|
1340
|
+
}
|
|
1341
|
+
if (voiceInstr.size > 0) {
|
|
1342
|
+
const instruments = abcInstrumentsToLilylet(metadata.staves, voiceInstr);
|
|
1343
|
+
if (instruments) metadata.instruments = instruments;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1118
1348
|
// Group measures by voice
|
|
1119
1349
|
// ABC measures contain BarPatches, each with a voice control V:n
|
|
1120
1350
|
const measures = body.measures;
|