@k-l-lambda/lilylet 0.1.68 → 0.1.70

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,281 @@
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
+
15
+ export enum StaffGroupType {
16
+ Default = 0,
17
+ Brace = 1, // {}
18
+ Bracket = 2, // <>
19
+ Square = 3, // []
20
+ }
21
+
22
+ export enum StaffConjunctionType {
23
+ Blank = 0,
24
+ Dashed = 1,
25
+ Solid = 2,
26
+ }
27
+
28
+ export interface RawItem {
29
+ id: string | null;
30
+ leftBounds: string[];
31
+ rightBounds: string[];
32
+ conjunction: string | null;
33
+ }
34
+
35
+ export interface StaffGroup {
36
+ type: StaffGroupType;
37
+ subs?: StaffGroup[];
38
+ staff?: string;
39
+ level?: number;
40
+ grand?: boolean;
41
+ key?: string;
42
+ bar?: number;
43
+ }
44
+
45
+ export interface StaffGroupTrait {
46
+ group: StaffGroup;
47
+ range: [number, number];
48
+ key: string;
49
+ }
50
+
51
+ const singleGroup = (id: string): StaffGroup => ({ type: StaffGroupType.Default, staff: id });
52
+
53
+ const BOUNDS_TO_GROUPTYPE: { [bound: string]: StaffGroupType } = {
54
+ "{": StaffGroupType.Brace,
55
+ "}": StaffGroupType.Brace,
56
+ "<": StaffGroupType.Bracket,
57
+ ">": StaffGroupType.Bracket,
58
+ "[": StaffGroupType.Square,
59
+ "]": StaffGroupType.Square,
60
+ };
61
+
62
+ const OPEN_BOUNDS = "{<[";
63
+ const CLOSE_BOUNDS = "}>]";
64
+
65
+ const CONJUNCTIONS_MAP: { [conj: string]: StaffConjunctionType } = {
66
+ ",": StaffConjunctionType.Blank,
67
+ "-": StaffConjunctionType.Solid,
68
+ ".": StaffConjunctionType.Dashed,
69
+ };
70
+
71
+ // MEI staffGrp @symbol by StaffGroupType (Default → none). MEI's allowed values
72
+ // are brace | bracket | bracketsq | line | none — note the square variant is
73
+ // "bracketsq", NOT "square" (the latter is MusicXML's <group-symbol> value).
74
+ const GROUP_SYMBOLS_MEI: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
75
+
76
+ const randomB64 = (): string => {
77
+ const code = Buffer.from(Math.random().toString().slice(2)).toString("base64").replace(/=/g, "");
78
+ return code.split("").reverse().slice(0, 6).join("");
79
+ };
80
+
81
+ const makeUniqueName = (set: Set<string>, index: number, prefix?: string): string => {
82
+ let name = prefix;
83
+ if (!name) name = index.toString();
84
+ else if (set.has(name)) name += "_" + index.toString();
85
+
86
+ while (set.has(name)) name = (prefix ? prefix + "_" : "") + randomB64();
87
+
88
+ return name;
89
+ };
90
+
91
+ // Tokenize a layout string into RawItem[] (one per staff slot). Lexer: whitespace
92
+ // skipped, single chars [-,.{}<>[]] are bounds/conjunctions, [a-zA-Z_0-9]+ is an id.
93
+ // An item accumulates leading open-bounds (leftBounds), an optional id, trailing
94
+ // close-bounds (rightBounds); a conjunction terminates the item and starts the next.
95
+ const tokenize = (code: string): RawItem[] => {
96
+ const tokens = code.match(/[A-Za-z0-9_]+|[-,.{}<>\[\]]/g) || [];
97
+ const items: RawItem[] = [];
98
+ let cur: RawItem = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
99
+ let seenId = false; // id slot filled for the current item
100
+ let seenRight = false; // started collecting right bounds / closing
101
+
102
+ const pushItem = () => {
103
+ items.push(cur);
104
+ cur = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
105
+ seenId = false;
106
+ seenRight = false;
107
+ };
108
+
109
+ for (const tok of tokens) {
110
+ if (tok in CONJUNCTIONS_MAP) {
111
+ cur.conjunction = tok;
112
+ pushItem();
113
+ continue;
114
+ }
115
+ if (OPEN_BOUNDS.includes(tok)) {
116
+ // An open bound after the id/closing starts a fresh item's left bounds.
117
+ if (seenId || seenRight) pushItem();
118
+ cur.leftBounds.push(tok);
119
+ continue;
120
+ }
121
+ if (CLOSE_BOUNDS.includes(tok)) {
122
+ cur.rightBounds.push(tok);
123
+ seenRight = true;
124
+ continue;
125
+ }
126
+ // id token
127
+ if (seenId || seenRight) pushItem();
128
+ cur.id = tok;
129
+ seenId = true;
130
+ }
131
+ // Flush the final item if it carries any content.
132
+ if (cur.id !== null || cur.leftBounds.length || cur.rightBounds.length) pushItem();
133
+
134
+ return items;
135
+ };
136
+
137
+ const makeGroupsFromRaw = (parent: StaffGroup, seq: string[]): string[] => {
138
+ let remains = seq;
139
+ while (remains.length) {
140
+ const word = remains.shift() as string;
141
+ const bound = BOUNDS_TO_GROUPTYPE[word];
142
+ if (bound !== undefined) {
143
+ if (CLOSE_BOUNDS.includes(word) && bound === parent.type) break;
144
+
145
+ if (OPEN_BOUNDS.includes(word)) {
146
+ const group: StaffGroup = { type: bound, level: Number.isFinite(parent.level as number) ? (parent.level as number) + 1 : 0 };
147
+ remains = makeGroupsFromRaw(group, remains);
148
+
149
+ parent.subs = parent.subs || [];
150
+ parent.subs.push(group);
151
+ }
152
+ } else {
153
+ parent.subs = parent.subs || [];
154
+ parent.subs.push(singleGroup(word));
155
+ }
156
+ }
157
+
158
+ while (parent.type === StaffGroupType.Default && parent.subs && parent.subs.length === 1) {
159
+ const sub = parent.subs[0];
160
+ parent.type = sub.type;
161
+ parent.subs = sub.subs;
162
+ parent.staff = sub.staff;
163
+ parent.level = sub.level;
164
+ }
165
+
166
+ while (parent.subs && parent.subs.length === 1 && parent.subs[0].type === StaffGroupType.Default) {
167
+ const sub = parent.subs[0];
168
+ parent.subs = sub.subs;
169
+ parent.staff = sub.staff;
170
+ }
171
+
172
+ parent.grand = parent.type === StaffGroupType.Brace && !!parent.subs && parent.subs.every(sub => !!sub.staff);
173
+
174
+ return remains;
175
+ };
176
+
177
+ const groupHead = (group: StaffGroup): string | undefined => {
178
+ if (group.staff) return group.staff;
179
+ else if (group.subs) return groupHead(group.subs[0]);
180
+ };
181
+
182
+ const groupTail = (group: StaffGroup): string | undefined => {
183
+ if (group.staff) return group.staff;
184
+ else if (group.subs) return groupTail(group.subs[group.subs.length - 1]);
185
+ };
186
+
187
+ export const groupKey = (group: StaffGroup): string | undefined => {
188
+ if (group.staff) return group.staff;
189
+ else if (group.subs) return `${groupHead(group)}-${groupTail(group)}`;
190
+ };
191
+
192
+ const groupDict = (group: StaffGroup, dict: { [key: string]: StaffGroup }): void => {
193
+ const key = groupKey(group);
194
+ if (key !== undefined) dict[key] = group;
195
+ if (group.subs) group.subs.forEach(sub => groupDict(sub, dict));
196
+ };
197
+
198
+ export class StaffLayout {
199
+ staffIds: string[];
200
+ conjunctions: StaffConjunctionType[];
201
+ group: StaffGroup;
202
+ groups: StaffGroupTrait[];
203
+
204
+ constructor(raw: RawItem[]) {
205
+ // make unique ids (anonymous slots get "1","2",… ; named collisions disambiguated)
206
+ const ids = new Set<string>();
207
+ raw.forEach((item, i) => {
208
+ item.id = makeUniqueName(ids, i + 1, item.id || undefined);
209
+ ids.add(item.id);
210
+ });
211
+ this.staffIds = raw.map(item => item.id as string);
212
+ this.conjunctions = raw.slice(0, raw.length - 1).map(item => item.conjunction ? CONJUNCTIONS_MAP[item.conjunction] : StaffConjunctionType.Blank);
213
+
214
+ // make groups
215
+ const seq = ([] as string[]).concat(...raw.map(item => [...item.leftBounds, item.id as string, ...item.rightBounds]));
216
+ this.group = { type: StaffGroupType.Default };
217
+ makeGroupsFromRaw(this.group, seq);
218
+
219
+ const dict: { [key: string]: StaffGroup } = {};
220
+ groupDict(this.group, dict);
221
+ this.groups = Object.entries(dict).map(([key, group]) => {
222
+ let ids = key.split("-");
223
+ if (ids.length === 1) ids = [ids[0], ids[0]];
224
+ const range = ids.map(id => this.staffIds.indexOf(id)) as [number, number];
225
+
226
+ const cons = this.conjunctions.slice(range[0], range[1]);
227
+ const bar = cons.length ? Math.min(...cons) : 0;
228
+
229
+ group.key = key;
230
+ group.bar = bar;
231
+
232
+ return { group, range, key };
233
+ });
234
+ }
235
+
236
+ get stavesCount(): number {
237
+ return this.staffIds.length;
238
+ }
239
+ }
240
+
241
+ export const parseStaffLayout = (code: string): StaffLayout => new StaffLayout(tokenize(code));
242
+
243
+ // ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
244
+ // Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
245
+ // with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
246
+ // label. Returns the inner XML (no <scoreDef> wrapper); the caller positions it.
247
+
248
+ const bool = (x: boolean): string => (x ? "true" : "false");
249
+
250
+ const stateMEIGroup = (
251
+ statements: string[],
252
+ group: StaffGroup,
253
+ nameDict: { [key: string]: string },
254
+ ids: string[],
255
+ indent: number,
256
+ tab: string,
257
+ ): void => {
258
+ const pad = tab.repeat(indent);
259
+ const name = group.key !== undefined ? nameDict[group.key] : undefined;
260
+
261
+ if (group.subs) {
262
+ const symbol = GROUP_SYMBOLS_MEI[group.type] ? ` symbol="${GROUP_SYMBOLS_MEI[group.type]}"` : "";
263
+ statements.push(`${pad}<staffGrp bar.thru="${bool((group.bar ?? 0) > 1)}"${symbol}>`);
264
+ if (name) statements.push(`${pad}${tab}<label>${name}</label>`);
265
+ group.subs.forEach(sub => stateMEIGroup(statements, sub, nameDict, ids, indent + 1, tab));
266
+ statements.push(`${pad}</staffGrp>`);
267
+ }
268
+
269
+ if (group.staff) statements.push(`${pad}<staffDef n="${ids.indexOf(group.staff) + 1}">`);
270
+ };
271
+
272
+ export const encodeStaffLayoutMEI = (
273
+ layout: StaffLayout,
274
+ nameDict: { [key: string]: string } = {},
275
+ indent = 0,
276
+ tab = "\t",
277
+ ): string => {
278
+ const statements: string[] = [];
279
+ stateMEIGroup(statements, layout.group, nameDict, layout.staffIds, indent, tab);
280
+ return statements.join("\n");
281
+ };
@@ -301,9 +301,19 @@ export interface Metadata {
301
301
  opus?: string;
302
302
  instrument?: string;
303
303
  genre?: string;
304
+ staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
305
+ // Per-staff / per-group instrument names, keyed by staff-layout group key (a single
306
+ // staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
307
+ // [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.
308
+ instruments?: { [key: string]: InstrumentName };
304
309
  autoBeam?: 'auto' | 'on' | 'off';
305
310
  }
306
311
 
312
+ export interface InstrumentName {
313
+ name: string;
314
+ shortName?: string;
315
+ }
316
+
307
317
  // Part within a measure: can be a single staff or grand staff (multiple staves)
308
318
  // When voices have staff > 1, it's a grand staff
309
319
  export interface Part {