@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,226 @@
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
+ // ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
204
+ // Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
205
+ // with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
206
+ // label. Returns the inner XML (no <scoreDef> wrapper); the caller positions it.
207
+ const bool = (x) => (x ? "true" : "false");
208
+ const stateMEIGroup = (statements, group, nameDict, ids, indent, tab) => {
209
+ const pad = tab.repeat(indent);
210
+ const name = group.key !== undefined ? nameDict[group.key] : undefined;
211
+ if (group.subs) {
212
+ const symbol = GROUP_SYMBOLS_MEI[group.type] ? ` symbol="${GROUP_SYMBOLS_MEI[group.type]}"` : "";
213
+ statements.push(`${pad}<staffGrp bar.thru="${bool((group.bar ?? 0) > 1)}"${symbol}>`);
214
+ if (name)
215
+ statements.push(`${pad}${tab}<label>${name}</label>`);
216
+ group.subs.forEach(sub => stateMEIGroup(statements, sub, nameDict, ids, indent + 1, tab));
217
+ statements.push(`${pad}</staffGrp>`);
218
+ }
219
+ if (group.staff)
220
+ statements.push(`${pad}<staffDef n="${ids.indexOf(group.staff) + 1}">`);
221
+ };
222
+ export const encodeStaffLayoutMEI = (layout, nameDict = {}, indent = 0, tab = "\t") => {
223
+ const statements = [];
224
+ stateMEIGroup(statements, layout.group, nameDict, layout.staffIds, indent, tab);
225
+ return statements.join("\n");
226
+ };
@@ -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.68",
3
+ "version": "0.1.70",
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",
@@ -37,14 +37,14 @@
37
37
 
38
38
  const patch = (terms, bar) => {
39
39
  const control = {};
40
- terms.forEach(term => {
40
+ (terms || []).forEach(term => {
41
41
  if (term.control)
42
42
  control[term.control.name] = term.control.value;
43
43
  });
44
44
 
45
45
  return {
46
46
  control,
47
- terms,
47
+ terms: terms || [],
48
48
  bar,
49
49
  };
50
50
  };
@@ -195,10 +195,10 @@ Am \b[A-G](?=[m][a][j]|[m][i][n]\b)
195
195
  a \b[a-g](?=[\W\d\sA-Ga-g_yzHJLMOPRSTuv]*\b)
196
196
  z \b[z]
197
197
  Z \b[Z]
198
- x \b[x](?=[\W\d\s])
198
+ x \b[x](?=[\W\d\s]|[_^=]*[A-Ga-g])
199
199
  y \b[y]
200
200
  N [0-9]
201
- P \b[HJLMOPRSTuv](?=[A-Ga-g][A-Ga-g0-9]*\b)
201
+ P \b[HJLMOPRSTuv](?=[_^=]*[A-Ga-g][A-Ga-g0-9_^=,']*)
202
202
  PP \b[HJLMOPRSTuv](?=[xz!\[^_=\s"])
203
203
 
204
204
  SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
@@ -211,8 +211,8 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
211
211
  <string>\\\" return 'STR_CONTENT'
212
212
  <string>[^"]+ return 'STR_CONTENT'
213
213
 
214
- ^[T][:][\s]* { this.pushState('title_string'); return 'T:'; }
215
- ^[C][:][\s]* { this.pushState('title_string'); return 'C:'; }
214
+ ^[T][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'T:'; } this.unput(yytext.slice(1)); yytext = 'T'; return 'T'; }
215
+ ^[C][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'C:'; } this.unput(yytext.slice(1)); yytext = 'C'; return 'A'; }
216
216
  <title_string>\n { this.popState(); }
217
217
  <title_string>[^\n]+ return 'STR_CONTENT'
218
218
 
@@ -226,10 +226,10 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
226
226
  <voice_header>[ \t]+ {}
227
227
  <voice_header>\n { this.popState(); }
228
228
  <voice_header>\] { this.popState(); return ']'; }
229
- <key_signature>"treble" return 'TREBLE';
230
- <key_signature>"bass" return 'BASS';
231
- <key_signature>"tenor" return 'TENOR';
232
- <key_signature>"alto" return 'ALTO';
229
+ <key_signature>"treble"[0-9]* return 'TREBLE';
230
+ <key_signature>"bass"[0-9]* return 'BASS';
231
+ <key_signature>"tenor"[0-9]* return 'TENOR';
232
+ <key_signature>"alto"[0-9]* return 'ALTO';
233
233
  <key_signature>"none" return 'NAME';
234
234
  <key_signature>"Dor" return 'NAME';
235
235
  <key_signature>"Phr" return 'NAME';
@@ -262,9 +262,10 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
262
262
  <spec_comment_name>[\w]+ { this.popState(); this.pushState('spec_comment_skip'); }
263
263
  <spec_comment_name>\n { this.popState(); this.popState(); }
264
264
  <spec_comment>[ \t]+ {}
265
- <spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
265
+ <spec_comment>[([{] { this._scoreDepth = (this._scoreDepth || 0) + 1; return yytext; }
266
266
  <spec_comment>[)\]}] { this._scoreDepth = (this._scoreDepth || 0) - 1; return yytext; }
267
267
  <spec_comment>[|] return yytext
268
+ <spec_comment>[A-Z][:] { this.popState(); this.popState(); this.unput(yytext); return 'LAYOUT_END'; }
268
269
  <spec_comment>[\w]+ return 'NN'
269
270
  <spec_comment>\n { if (this._scoreDepth > 0) { /* layout continues on next line */ } else { this.popState(); this.popState(); return 'LAYOUT_END'; } }
270
271
  <spec_comment_skip>[^\n]+ {}
@@ -360,7 +361,7 @@ staff_layout
360
361
  staff_layout_items
361
362
  : staff_layout_item -> [$1]
362
363
  | staff_layout_items staff_layout_item -> [...$1, $2]
363
- | staff_layout_items '|' -> $1
364
+ | staff_layout_items '|' { if ($1.length) $1[$1.length - 1].barThruAfter = true; $$ = $1; }
364
365
  ;
365
366
 
366
367
  staff_layout_item
@@ -467,6 +468,7 @@ numeric_tempo
467
468
 
468
469
  voice_exp
469
470
  : number -> voice($1)
471
+ | number assigns -> voice($1, null, $2)
470
472
  | number NAME -> voice($1, $2)
471
473
  | number NAME assigns -> voice($1, $2, $3)
472
474
  | number NAME plus_minus_number -> voice($1, $2 + ($3 < 0 ? '-' : '+') + Math.abs($3))
@@ -539,10 +541,18 @@ bar
539
541
  | '|' ']' ':' -> '|]'
540
542
  | ':' '|' ']' -> ':|]'
541
543
  | '|' N -> '|' + $2
544
+ | '|' N volta_rest -> '|' + $2 + $3
542
545
  | ':' '|' N -> ':|' + $2
546
+ | ':' '|' N volta_rest -> ':|' + $2 + $3
543
547
  | '&' -> '&'
544
548
  ;
545
549
 
550
+ // Additional volta endings after the first number, comma-separated: "1,2", "1,2,3".
551
+ volta_rest
552
+ : ',' N -> ',' + $2
553
+ | volta_rest ',' N -> $1 + ',' + $3
554
+ ;
555
+
546
556
  music
547
557
  : %empty -> []
548
558
  | music expressive_mark -> $1 ? [...$1, $2] : [$2]
@@ -555,9 +565,17 @@ music
555
565
  | music N -> $1
556
566
  | music NAME -> $1
557
567
  | music '^' NAME -> $1
568
+ | music '^' articulation_letter -> $1
558
569
  | music '[' N -> $1
559
570
  ;
560
571
 
572
+ // Articulation-class letters (P macro: HJLMOPRSTuv). After a stray '^' inside a
573
+ // text annotation, a word like "Menuetto"/"Largo" starts with one of these and the
574
+ // lexer emits it as the articulation token, not NAME — swallow it like '^' NAME.
575
+ articulation_letter
576
+ : 'P' | 'T' | 'H' | 'J' | 'L' | 'M' | 'R' | 'O' | 'S' | 'u' | 'v'
577
+ ;
578
+
561
579
  control
562
580
  : '[' H ':' header_value ']' -> ({control: header($2, $4)})
563
581
  | '[' 'K:' header_value ']' -> ({control: header("K", $3)})
@@ -756,7 +774,14 @@ duration
756
774
  : number '/' number -> frac(Number($1), Number($3))
757
775
  | '/' number -> frac(1, Number($2))
758
776
  | number -> frac(Number($1))
759
- | '/' -> frac(1, 2)
777
+ | slashes -> frac(1, Math.pow(2, $1))
778
+ | number slashes -> frac(Number($1), Math.pow(2, $2))
779
+ ;
780
+
781
+ // One or more '/' halve the unit length each: '/'=1/2, '//'=1/4, '///'=1/8.
782
+ slashes
783
+ : '/' -> 1
784
+ | slashes '/' -> $1 + 1
760
785
  ;
761
786
 
762
787
  broken_rhythm
package/source/abc/abc.ts CHANGED
@@ -130,6 +130,7 @@ namespace ABC {
130
130
  export interface StaffGroup {
131
131
  items: (StaffGroup | string)[];
132
132
  bound?: 'arc' | 'square' | 'curly';
133
+ barThruAfter?: boolean; // a '|' followed this sibling in %%score (barlines run through)
133
134
  };
134
135
 
135
136