@k-l-lambda/lilylet 0.1.70 → 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/gmInstruments.d.ts +1 -0
- package/lib/gmInstruments.js +1 -0
- package/lib/lilylet/abcDecoder.js +16 -7
- package/lib/lilylet/gmInstruments.d.ts +1 -0
- package/lib/lilylet/gmInstruments.js +295 -0
- package/lib/lilylet/meiEncoder.js +126 -14
- package/lib/lilylet/staffLayout.d.ts +5 -0
- package/lib/lilylet/staffLayout.js +62 -0
- package/package.json +1 -1
- package/source/lilylet/abcDecoder.ts +14 -7
- package/source/lilylet/gmInstruments.ts +305 -0
- package/source/lilylet/meiEncoder.ts +135 -11
- package/source/lilylet/staffLayout.ts +76 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/gmInstruments.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/gmInstruments.js";
|
|
@@ -498,25 +498,34 @@ const abcLayoutToStaves = (layout) => {
|
|
|
498
498
|
// arc or unbounded: a staff only if every descendant is too (no nested groups)
|
|
499
499
|
return (node.items || []).every(isStaffLeaf);
|
|
500
500
|
};
|
|
501
|
-
|
|
501
|
+
// A square group maps to lilylet Bracket `<>` at the TOP level, but to lilylet Square
|
|
502
|
+
// `[]` when nested inside another group — e.g. ABC `[[1 2] 3 | 4]` → `<[1,2]3-4>`.
|
|
503
|
+
// A curly group always maps to Brace `{}`. `nested` is false for a top-level entry.
|
|
504
|
+
const emit = (node, nested) => {
|
|
502
505
|
if (isStaffLeaf(node))
|
|
503
506
|
return firstVoice(node) || "";
|
|
504
507
|
const group = node;
|
|
505
|
-
const open = group.bound === "curly" ? "{" :
|
|
506
|
-
const close = group.bound === "curly" ? "}" : ">";
|
|
508
|
+
const open = group.bound === "curly" ? "{" : (group.bound === "square" && nested) ? "[" : "<";
|
|
509
|
+
const close = group.bound === "curly" ? "}" : (group.bound === "square" && nested) ? "]" : ">";
|
|
507
510
|
const items = group.items || [];
|
|
508
511
|
let inner = "";
|
|
509
512
|
items.forEach((item, i) => {
|
|
510
|
-
inner += emit(item);
|
|
513
|
+
inner += emit(item, true);
|
|
511
514
|
if (i < items.length - 1) {
|
|
512
|
-
|
|
513
|
-
|
|
515
|
+
// A Blank separator (',') is only needed between two bare staff leaves; a
|
|
516
|
+
// grouped neighbour's bracket already delimits the slot, so suppress it there
|
|
517
|
+
// (giving `[1,2]3` not `[1,2],3`). A Solid join ('-', barThru) is always kept.
|
|
518
|
+
const next = items[i + 1];
|
|
519
|
+
if (item.barThruAfter)
|
|
520
|
+
inner += "-";
|
|
521
|
+
else if (isStaffLeaf(item) && isStaffLeaf(next))
|
|
522
|
+
inner += ",";
|
|
514
523
|
}
|
|
515
524
|
});
|
|
516
525
|
return `${open}${inner}${close}`;
|
|
517
526
|
};
|
|
518
527
|
const tops = layout.map((top, i) => {
|
|
519
|
-
let s = emit(top);
|
|
528
|
+
let s = emit(top, false);
|
|
520
529
|
// A bare top-level staff leaf (e.g. the `9` in `[ … ] 9 [ … ]`) still occupies a slot;
|
|
521
530
|
// emit() already yields its id with no wrapper, which is the desired output.
|
|
522
531
|
return { s, barThru: !!top.barThruAfter, isLast: i === layout.length - 1 };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const gmProgramOf: (name: string | undefined | null) => number | undefined;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// General MIDI program lookup: instrument name → GM program number (0–127).
|
|
2
|
+
//
|
|
3
|
+
// Verovio's MIDI export honors ONLY the numeric `@midi.instrnum` on an MEI
|
|
4
|
+
// <instrDef> (the GM-name attribute `@midi.instrname` is parsed but never used
|
|
5
|
+
// for MIDI). lilylet already carries human instrument names (from ABC voice
|
|
6
|
+
// names, MusicXML part names, etc.) in metadata.instruments; this table maps
|
|
7
|
+
// those names to GM programs so the MEI encoder can emit <instrDef midi.instrnum>
|
|
8
|
+
// and multi-instrument scores get distinct timbres instead of all-piano.
|
|
9
|
+
//
|
|
10
|
+
// The name set is seeded from the notagen dataset (Piano, Violins, Viola,
|
|
11
|
+
// Violoncellos, Oboe, Horn, Flute, Clarinet, Bassoon, Violin, Trombone,
|
|
12
|
+
// Timpani, Voice, Bass, Trumpet, Harp, Contrabasses, Vocal, Organ, …) plus
|
|
13
|
+
// common GM aliases, then matched through a normalizer that handles plurals
|
|
14
|
+
// ("Violins" → violin) and trailing part numbers ("Violin I", "Horn 2").
|
|
15
|
+
// Normalized name → GM program (0-based). Keys are lowercase, singular,
|
|
16
|
+
// whitespace-collapsed. Plural/number variants are resolved by the normalizer.
|
|
17
|
+
const GM_PROGRAMS = {
|
|
18
|
+
// Piano (0–7)
|
|
19
|
+
"piano": 0,
|
|
20
|
+
"acoustic grand piano": 0,
|
|
21
|
+
"grand piano": 0,
|
|
22
|
+
"bright acoustic piano": 1,
|
|
23
|
+
"electric piano": 4,
|
|
24
|
+
"harpsichord": 6,
|
|
25
|
+
"clavichord": 7,
|
|
26
|
+
"clavi": 7,
|
|
27
|
+
// Chromatic percussion (8–15)
|
|
28
|
+
"celesta": 8,
|
|
29
|
+
"glockenspiel": 9,
|
|
30
|
+
"music box": 10,
|
|
31
|
+
"vibraphone": 11,
|
|
32
|
+
"marimba": 12,
|
|
33
|
+
"xylophone": 13,
|
|
34
|
+
"tubular bells": 14,
|
|
35
|
+
"dulcimer": 15,
|
|
36
|
+
// Organ (16–23)
|
|
37
|
+
"organ": 19,
|
|
38
|
+
"hammond organ": 16,
|
|
39
|
+
"percussive organ": 17,
|
|
40
|
+
"rock organ": 18,
|
|
41
|
+
"church organ": 19,
|
|
42
|
+
"pipe organ": 19,
|
|
43
|
+
"reed organ": 20,
|
|
44
|
+
"accordion": 21,
|
|
45
|
+
"harmonica": 22,
|
|
46
|
+
// Guitar (24–31)
|
|
47
|
+
"guitar": 24,
|
|
48
|
+
"acoustic guitar": 24,
|
|
49
|
+
"nylon guitar": 24,
|
|
50
|
+
"steel guitar": 25,
|
|
51
|
+
"electric guitar": 27,
|
|
52
|
+
"guitarre": 24, // fr./de. guitar
|
|
53
|
+
"gitarre": 24, // de.
|
|
54
|
+
"chitarra": 24, // it.
|
|
55
|
+
// Bass (32–39) — orchestral "Bass" means double bass (Contrabass, 43); the
|
|
56
|
+
// electric/acoustic bass-guitar programs live here but are not the default.
|
|
57
|
+
"acoustic bass": 32,
|
|
58
|
+
"electric bass": 33,
|
|
59
|
+
"fretless bass": 35,
|
|
60
|
+
"basso": 43, // it. bass → double bass
|
|
61
|
+
"basse": 43, // fr.
|
|
62
|
+
"bassi": 43, // it. pl.
|
|
63
|
+
"bas": 43, // de./nl. abbrev
|
|
64
|
+
// Strings (40–47)
|
|
65
|
+
"violin": 40,
|
|
66
|
+
"viola": 41,
|
|
67
|
+
"cello": 42,
|
|
68
|
+
"violoncello": 42,
|
|
69
|
+
"contrabass": 43,
|
|
70
|
+
"double bass": 43,
|
|
71
|
+
"bass": 43,
|
|
72
|
+
"tremolo strings": 44,
|
|
73
|
+
"pizzicato strings": 45,
|
|
74
|
+
"harp": 46,
|
|
75
|
+
"orchestral harp": 46,
|
|
76
|
+
"timpani": 47,
|
|
77
|
+
// Ensemble (48–55)
|
|
78
|
+
"strings": 48,
|
|
79
|
+
"string ensemble": 48,
|
|
80
|
+
"string orchestra": 48,
|
|
81
|
+
"synth strings": 50,
|
|
82
|
+
"voice": 52,
|
|
83
|
+
"vocal": 52,
|
|
84
|
+
"voices": 52,
|
|
85
|
+
"choir": 52,
|
|
86
|
+
"choir aahs": 52,
|
|
87
|
+
"soprano": 52,
|
|
88
|
+
"alto": 52,
|
|
89
|
+
"tenor": 52,
|
|
90
|
+
"bass voice": 52,
|
|
91
|
+
"orchestra hit": 55,
|
|
92
|
+
// Brass (56–63)
|
|
93
|
+
"trumpet": 56,
|
|
94
|
+
"trombone": 57,
|
|
95
|
+
"tuba": 58,
|
|
96
|
+
"muted trumpet": 59,
|
|
97
|
+
"horn": 60,
|
|
98
|
+
"french horn": 60,
|
|
99
|
+
"brass": 61,
|
|
100
|
+
"brass section": 61,
|
|
101
|
+
// Reed (64–71)
|
|
102
|
+
"soprano sax": 64,
|
|
103
|
+
"alto sax": 65,
|
|
104
|
+
"tenor sax": 66,
|
|
105
|
+
"baritone sax": 67,
|
|
106
|
+
"saxophone": 66,
|
|
107
|
+
"sax": 66,
|
|
108
|
+
"oboe": 68,
|
|
109
|
+
"english horn": 69,
|
|
110
|
+
"cor anglais": 69,
|
|
111
|
+
"bassoon": 70,
|
|
112
|
+
"clarinet": 71,
|
|
113
|
+
// Pipe (72–79)
|
|
114
|
+
"piccolo": 72,
|
|
115
|
+
"flute": 73,
|
|
116
|
+
"recorder": 74,
|
|
117
|
+
"pan flute": 75,
|
|
118
|
+
// --- Foreign-language names, abbreviations and common spelling variants,
|
|
119
|
+
// harvested from the notagen corpus. Mapped to the nearest GM program.
|
|
120
|
+
// Keyboard
|
|
121
|
+
"pianoforte": 0,
|
|
122
|
+
"fortepiano": 0,
|
|
123
|
+
"klavier": 0,
|
|
124
|
+
"keyboard": 0,
|
|
125
|
+
"cembalo": 6, // it. harpsichord
|
|
126
|
+
"clavicembalo": 6,
|
|
127
|
+
"harpichord": 6, // misspelling
|
|
128
|
+
"organo": 19, // it. organ
|
|
129
|
+
"orgel": 19, // de. organ
|
|
130
|
+
// Strings (it./de./fr./variants)
|
|
131
|
+
"violino": 40,
|
|
132
|
+
"violini": 40,
|
|
133
|
+
"violine": 40, // de.
|
|
134
|
+
"violinen": 40,
|
|
135
|
+
"violon": 40, // fr.
|
|
136
|
+
"violons": 40,
|
|
137
|
+
"violn": 40, // abbrev/OCR variant
|
|
138
|
+
"violno": 40, // OCR variant
|
|
139
|
+
"viole": 41, // it. violas (also fr. "viole")
|
|
140
|
+
"bratsche": 41, // de. viola
|
|
141
|
+
"celli": 42,
|
|
142
|
+
"violoncelli": 42,
|
|
143
|
+
"violoncelle": 42, // fr.
|
|
144
|
+
"violoncelles": 42,
|
|
145
|
+
"violonchelo": 42, // es.
|
|
146
|
+
"soloncello": 42, // OCR variant of violoncello
|
|
147
|
+
"gambe": 42, // fr. viola da gamba
|
|
148
|
+
"gamba": 42, // viola da gamba ≈ cello
|
|
149
|
+
"viola da gamba": 42,
|
|
150
|
+
"contrabasso": 43, // it.
|
|
151
|
+
"contrabassi": 43,
|
|
152
|
+
"contrabbasso": 43, // it.
|
|
153
|
+
"contra-basso": 43,
|
|
154
|
+
"contrabajo": 43, // es.
|
|
155
|
+
"kontrabass": 43, // de.
|
|
156
|
+
"kontrabasse": 43, // de. pl.
|
|
157
|
+
"kontrabasso": 43,
|
|
158
|
+
"contrebasse": 43, // fr.
|
|
159
|
+
"violone": 43, // large bass viol ≈ contrabass
|
|
160
|
+
"arpa": 46, // it./es. harp
|
|
161
|
+
"harfe": 46, // de. harp
|
|
162
|
+
"pauken": 47, // de. timpani
|
|
163
|
+
// Voice (it./de./fr.)
|
|
164
|
+
"canto": 52, // it.
|
|
165
|
+
"coro": 52, // it. choir
|
|
166
|
+
"chorus": 52,
|
|
167
|
+
"chorale": 52,
|
|
168
|
+
"sopran": 52, // de.
|
|
169
|
+
"contralto": 52, // it. alto
|
|
170
|
+
"tenore": 52, // it.
|
|
171
|
+
"tenori": 52,
|
|
172
|
+
"gesang": 52, // de. voice
|
|
173
|
+
"singstimme": 52, // de. voice
|
|
174
|
+
"voce": 52, // it.
|
|
175
|
+
"voix": 52, // fr.
|
|
176
|
+
"chanto": 52, // OCR variant of canto
|
|
177
|
+
"women": 52, // women's voices
|
|
178
|
+
"contra-fagotto": 70, // hyphenated contrabassoon ≈ bassoon
|
|
179
|
+
// Brass
|
|
180
|
+
"tromboni": 57, // it. trombones
|
|
181
|
+
"posaune": 57, // de. trombone
|
|
182
|
+
"posaunen": 57,
|
|
183
|
+
"trombe": 56, // it. trumpets
|
|
184
|
+
"tromba": 56, // it. trumpet
|
|
185
|
+
"trompete": 56, // de. trumpet
|
|
186
|
+
"trompeten": 56,
|
|
187
|
+
"trompette": 56, // fr. trumpet
|
|
188
|
+
"cornetto": 56, // historical cornett ≈ trumpet
|
|
189
|
+
"cornettino": 56,
|
|
190
|
+
"corno": 60, // it. horn
|
|
191
|
+
"corni": 60, // it. horns
|
|
192
|
+
// Reed (it./de./fr.)
|
|
193
|
+
"oboi": 68, // it. oboes
|
|
194
|
+
"oboen": 68, // de.
|
|
195
|
+
"hautbois": 68, // fr. oboe
|
|
196
|
+
"corno inglese": 69, // it. english horn
|
|
197
|
+
"inglese": 69, // "corno inglese" trailing word fallback also covers it
|
|
198
|
+
"ingles": 69, // es. variant
|
|
199
|
+
"fagotto": 70, // it. bassoon
|
|
200
|
+
"fagotti": 70,
|
|
201
|
+
"fagott": 70, // de.
|
|
202
|
+
"fagotte": 70, // de. pl.
|
|
203
|
+
"fagot": 70, // es.
|
|
204
|
+
"basson": 70, // fr. bassoon
|
|
205
|
+
"bassons": 70,
|
|
206
|
+
"contrafagotto": 70, // it. contrabassoon ≈ bassoon timbre
|
|
207
|
+
"contrabassoon": 70,
|
|
208
|
+
"klarinette": 71, // de. clarinet
|
|
209
|
+
"clarinetto": 71, // it.
|
|
210
|
+
"clarinetti": 71,
|
|
211
|
+
"clarinette": 71, // fr.
|
|
212
|
+
// Pipe (it./de.)
|
|
213
|
+
"flauto": 73, // it. flute
|
|
214
|
+
"flauti": 73, // it. flutes
|
|
215
|
+
"flote": 73, // de. Flöte (diacritics stripped by the normalizer)
|
|
216
|
+
"floten": 73, // de. Flöten
|
|
217
|
+
"traverso": 73, // baroque transverse flute
|
|
218
|
+
"flauto traverso": 73,
|
|
219
|
+
};
|
|
220
|
+
// Normalize an instrument name for lookup: lowercase, turn literal "\n" escapes
|
|
221
|
+
// and real newlines into spaces, strip diacritics (Flöte→flote, Hautböis→...),
|
|
222
|
+
// drop a trailing part designator (roman numeral or arabic number — "Violin I",
|
|
223
|
+
// "Horn 2", "Oboe II"), collapse whitespace.
|
|
224
|
+
const normalizeInstrumentName = (raw) => {
|
|
225
|
+
let s = raw.toLowerCase().trim();
|
|
226
|
+
s = s.replace(/\\n/g, " "); // literal backslash-n escape → space
|
|
227
|
+
s = s.normalize("NFD").replace(/[̀-ͯ]/g, ""); // strip diacritics
|
|
228
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
229
|
+
s = s.replace(/\s+(?:[ivx]+|\d+)\.?$/i, "").trim();
|
|
230
|
+
return s;
|
|
231
|
+
};
|
|
232
|
+
// Choral single-letter voice-part abbreviations → "Voice" (GM 52). Matched ONLY
|
|
233
|
+
// against the whole name, never per-word: a bare "S"/"A"/"T"/"B" staff label in a
|
|
234
|
+
// chorale means Soprano/Alto/Tenor/Bass, but the same letters appear as key
|
|
235
|
+
// designators in "Clarinet in B", "Horn in F", "Trumpet in C" — so these must not
|
|
236
|
+
// enter GM_PROGRAMS where the word-scan would misread them.
|
|
237
|
+
const SATB_VOICE = {
|
|
238
|
+
"s": 52,
|
|
239
|
+
"a": 52,
|
|
240
|
+
"t": 52,
|
|
241
|
+
"b": 52,
|
|
242
|
+
};
|
|
243
|
+
// Look up a single normalized name: exact match, else de-pluralized
|
|
244
|
+
// ("violins"→violin, "violoncellos"→violoncello, "contrabasses"→contrabass),
|
|
245
|
+
// else with a trailing attached part-number stripped ("violin1"→violin,
|
|
246
|
+
// "violino2"→violino).
|
|
247
|
+
const lookupNormalized = (norm) => {
|
|
248
|
+
if (norm in GM_PROGRAMS)
|
|
249
|
+
return GM_PROGRAMS[norm];
|
|
250
|
+
// Try "-es" before "-s".
|
|
251
|
+
if (norm.endsWith("es")) {
|
|
252
|
+
const sing = norm.slice(0, -2);
|
|
253
|
+
if (sing in GM_PROGRAMS)
|
|
254
|
+
return GM_PROGRAMS[sing];
|
|
255
|
+
}
|
|
256
|
+
if (norm.endsWith("s")) {
|
|
257
|
+
const sing = norm.slice(0, -1);
|
|
258
|
+
if (sing in GM_PROGRAMS)
|
|
259
|
+
return GM_PROGRAMS[sing];
|
|
260
|
+
}
|
|
261
|
+
// Attached trailing digits ("violin1", "violino2"): strip and retry.
|
|
262
|
+
const deNum = norm.replace(/\d+$/, "");
|
|
263
|
+
if (deNum !== norm && deNum in GM_PROGRAMS)
|
|
264
|
+
return GM_PROGRAMS[deNum];
|
|
265
|
+
return undefined;
|
|
266
|
+
};
|
|
267
|
+
// Resolve an instrument name to a GM program number (0–127), or undefined if no
|
|
268
|
+
// confident match (caller then omits <instrDef>, leaving Verovio's default).
|
|
269
|
+
//
|
|
270
|
+
// Match priority: the full normalized string first (including the SATB
|
|
271
|
+
// single-letter voice abbreviations), then individual words from the last toward
|
|
272
|
+
// the first. Multi-word names ("Singstimme Voice", "First Violins", "Solo Flute")
|
|
273
|
+
// usually put the instrument at the end, so the trailing word is tried before
|
|
274
|
+
// earlier qualifier words. Each word attempt runs through the de-plural path
|
|
275
|
+
// (lookupNormalized); SATB letters are intentionally NOT part of the word scan.
|
|
276
|
+
export const gmProgramOf = (name) => {
|
|
277
|
+
if (!name)
|
|
278
|
+
return undefined;
|
|
279
|
+
const norm = normalizeInstrumentName(name);
|
|
280
|
+
const direct = lookupNormalized(norm);
|
|
281
|
+
if (direct !== undefined)
|
|
282
|
+
return direct;
|
|
283
|
+
// Whole-name-only: a lone S/A/T/B is a chorale voice part.
|
|
284
|
+
if (norm in SATB_VOICE)
|
|
285
|
+
return SATB_VOICE[norm];
|
|
286
|
+
const words = norm.split(" ");
|
|
287
|
+
if (words.length > 1) {
|
|
288
|
+
for (let i = words.length - 1; i >= 0; i--) {
|
|
289
|
+
const hit = lookupNormalized(words[i]);
|
|
290
|
+
if (hit !== undefined)
|
|
291
|
+
return hit;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
295
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
2
|
import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
|
|
3
|
+
import { gmProgramOf } from "./gmInstruments.js";
|
|
3
4
|
// MEI key signatures: positive = sharps, negative = flats
|
|
4
5
|
const KEY_SIGS = {
|
|
5
6
|
0: "0",
|
|
@@ -81,12 +82,22 @@ const resolveClef = (clefStr) => {
|
|
|
81
82
|
};
|
|
82
83
|
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
83
84
|
// A mid-measure <clef> cannot carry att.transposition (that is a staff-level
|
|
84
|
-
// property
|
|
85
|
-
//
|
|
85
|
+
// property Verovio only reads from <staffDef>); the visible clef shape/line is
|
|
86
|
+
// emitted here, and a sounding-pitch transposition change is mirrored by a
|
|
87
|
+
// between-measure <scoreDef>/<staffDef trans.*> emitted in the measure loop
|
|
88
|
+
// (see emitClefTranspositionScoreDef). So only shape/line are emitted here.
|
|
86
89
|
const clefElementAttrs = (clefStr) => {
|
|
87
90
|
const c = resolveClef(clefStr);
|
|
88
91
|
return `shape="${c.shape}" line="${c.line}"`;
|
|
89
92
|
};
|
|
93
|
+
// The written→sounding transposition a clef declares, as {diat, semi}; {0,0}
|
|
94
|
+
// when the clef declares none. Used to detect mid-piece transposition changes
|
|
95
|
+
// and to emit the resetting <staffDef> when a transposing clef is replaced by a
|
|
96
|
+
// plain one (Verovio retains a prior trans.* until an explicit 0/0 overrides it).
|
|
97
|
+
const clefTransOf = (clefStr) => {
|
|
98
|
+
const c = resolveClef(clefStr);
|
|
99
|
+
return c.trans || { diat: 0, semi: 0 };
|
|
100
|
+
};
|
|
90
101
|
// Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
|
|
91
102
|
// (trans.diat / trans.semi) when the clef declares a transposition.
|
|
92
103
|
const staffDefClefAttrs = (clefStr) => {
|
|
@@ -1386,6 +1397,36 @@ const generateTempoElement = (tempo, indent, staff = 1) => {
|
|
|
1386
1397
|
}
|
|
1387
1398
|
return `${indent}<tempo ${attrs} />\n`;
|
|
1388
1399
|
};
|
|
1400
|
+
// The clef governing a measure's sounding pitch on one global staff: the clef
|
|
1401
|
+
// carried in from the previous measure, overridden by a leading clef change that
|
|
1402
|
+
// appears before the first note/rest in the measure (the normal boundary-change
|
|
1403
|
+
// case). A clef change occurring *after* notes does not retune this measure — it
|
|
1404
|
+
// becomes the carried clef for the next measure via clefState. This per-measure
|
|
1405
|
+
// granularity matches what Verovio honors for MIDI: a transposition change takes
|
|
1406
|
+
// effect only via a between-measure <scoreDef>/<staffDef trans.*>, never from a
|
|
1407
|
+
// mid-measure <clef> element.
|
|
1408
|
+
const measureStartClef = (measure, globalStaff, partInfos, carriedClef) => {
|
|
1409
|
+
let active = carriedClef;
|
|
1410
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
1411
|
+
const partOffset = partInfos[pi]?.staffOffset || 0;
|
|
1412
|
+
for (const voice of measure.parts[pi].voices) {
|
|
1413
|
+
if ((partOffset + (voice.staff || 1)) !== globalStaff)
|
|
1414
|
+
continue;
|
|
1415
|
+
for (const event of voice.events) {
|
|
1416
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
1417
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
1418
|
+
break; // notes have started; a later clef change governs the next measure
|
|
1419
|
+
}
|
|
1420
|
+
if (event.type === 'context') {
|
|
1421
|
+
const ctx = event;
|
|
1422
|
+
if (ctx.clef)
|
|
1423
|
+
active = ctx.clef;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return active;
|
|
1429
|
+
};
|
|
1389
1430
|
// Barline style to MEI @right attribute mapping
|
|
1390
1431
|
const BARLINE_TO_MEI = {
|
|
1391
1432
|
'|': 'single',
|
|
@@ -1642,14 +1683,40 @@ const analyzePartStructure = (doc) => {
|
|
|
1642
1683
|
};
|
|
1643
1684
|
// MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
|
|
1644
1685
|
const LAYOUT_SYMBOL = [null, "brace", "bracket", "bracketsq"];
|
|
1686
|
+
const newChannelAllocator = () => ({ byProgram: new Map(), nextChannel: 0 });
|
|
1687
|
+
// Channel for a program: reuse the channel already assigned to that program,
|
|
1688
|
+
// else take the next free channel (skipping 9). Wraps past 16 as graceful
|
|
1689
|
+
// degradation for scores with more than 15 distinct timbres.
|
|
1690
|
+
const allocChannel = (alloc, program) => {
|
|
1691
|
+
const existing = alloc.byProgram.get(program);
|
|
1692
|
+
if (existing !== undefined)
|
|
1693
|
+
return existing;
|
|
1694
|
+
let ch = alloc.nextChannel;
|
|
1695
|
+
if (ch % 16 === 9)
|
|
1696
|
+
ch++; // skip GM drum channel
|
|
1697
|
+
const assigned = ch % 16;
|
|
1698
|
+
alloc.byProgram.set(program, assigned);
|
|
1699
|
+
alloc.nextChannel = ch + 1;
|
|
1700
|
+
return assigned;
|
|
1701
|
+
};
|
|
1645
1702
|
// Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
|
|
1646
|
-
|
|
1703
|
+
// When the instrument name resolves to a General MIDI program, also emit an
|
|
1704
|
+
// <instrDef midi.instrnum midi.channel> sibling so Verovio's MIDI export assigns
|
|
1705
|
+
// that timbre (it honors only the numeric @midi.instrnum) on its own channel
|
|
1706
|
+
// (@midi.channel, else all instruments collide on channel 0). Unknown names emit
|
|
1707
|
+
// just the label, leaving Verovio's default program (0 = piano).
|
|
1708
|
+
const instrumentLabelXML = (instr, indent, channels) => {
|
|
1647
1709
|
if (!instr)
|
|
1648
1710
|
return "";
|
|
1649
1711
|
let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
|
|
1650
1712
|
if (instr.shortName !== undefined) {
|
|
1651
1713
|
xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
|
|
1652
1714
|
}
|
|
1715
|
+
const program = gmProgramOf(instr.name);
|
|
1716
|
+
if (program !== undefined) {
|
|
1717
|
+
const chanAttr = channels ? ` midi.channel="${allocChannel(channels, program)}"` : "";
|
|
1718
|
+
xml += `${indent}<instrDef xml:id="${generateId("instrdef")}" midi.instrnum="${program}"${chanAttr} />\n`;
|
|
1719
|
+
}
|
|
1653
1720
|
return xml;
|
|
1654
1721
|
};
|
|
1655
1722
|
// Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
|
|
@@ -1658,47 +1725,50 @@ const instrumentLabelXML = (instr, indent) => {
|
|
|
1658
1725
|
// key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
|
|
1659
1726
|
// names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
|
|
1660
1727
|
// bar.thru reflects the group's conjunction (Solid).
|
|
1661
|
-
const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments) => {
|
|
1728
|
+
const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments, channels) => {
|
|
1662
1729
|
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
1663
1730
|
const instr = group.key !== undefined ? instruments[group.key] : undefined;
|
|
1664
1731
|
// A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
|
|
1665
1732
|
if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
|
|
1666
|
-
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
|
|
1733
|
+
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
|
|
1667
1734
|
}
|
|
1668
1735
|
const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
|
|
1669
1736
|
const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
|
|
1670
1737
|
let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
|
|
1671
1738
|
// A multi-staff group's instrument name labels the whole group.
|
|
1672
1739
|
if (!isLeaf)
|
|
1673
|
-
xml += instrumentLabelXML(instr, indent + " ");
|
|
1740
|
+
xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
1674
1741
|
if (isLeaf) {
|
|
1675
1742
|
// A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
|
|
1676
1743
|
// a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
|
|
1677
1744
|
// label goes on the staffDef inside.
|
|
1678
|
-
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
|
|
1745
|
+
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
|
|
1679
1746
|
}
|
|
1680
1747
|
else {
|
|
1681
1748
|
for (const sub of group.subs || []) {
|
|
1682
|
-
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
|
|
1749
|
+
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
|
|
1683
1750
|
}
|
|
1684
1751
|
}
|
|
1685
1752
|
xml += `${indent}</staffGrp>\n`;
|
|
1686
1753
|
return xml;
|
|
1687
1754
|
};
|
|
1688
1755
|
// Emit a <staffDef> from its attribute string, with optional instrument <label> children.
|
|
1689
|
-
const staffDefWithLabel = (attrs, instr, indent) => {
|
|
1756
|
+
const staffDefWithLabel = (attrs, instr, indent, channels) => {
|
|
1690
1757
|
if (attrs === undefined)
|
|
1691
1758
|
return "";
|
|
1692
1759
|
if (!instr)
|
|
1693
1760
|
return `${indent}<staffDef ${attrs} />\n`;
|
|
1694
1761
|
let xml = `${indent}<staffDef ${attrs}>\n`;
|
|
1695
|
-
xml += instrumentLabelXML(instr, indent + " ");
|
|
1762
|
+
xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
1696
1763
|
xml += `${indent}</staffDef>\n`;
|
|
1697
1764
|
return xml;
|
|
1698
1765
|
};
|
|
1699
1766
|
// Encode scoreDef with part groups
|
|
1700
1767
|
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol, stavesCode, instruments = {}) => {
|
|
1701
1768
|
const scoreDefId = generateId("scoredef");
|
|
1769
|
+
// One MIDI channel allocator per score: assigns a distinct channel per GM
|
|
1770
|
+
// program so instruments don't collide on channel 0 (see allocChannel).
|
|
1771
|
+
const channels = newChannelAllocator();
|
|
1702
1772
|
// Build meter attributes
|
|
1703
1773
|
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1704
1774
|
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
@@ -1717,7 +1787,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
|
|
|
1717
1787
|
const layout = parseStaffLayout(stavesCode);
|
|
1718
1788
|
if (layout.stavesCount === flatStaves.length) {
|
|
1719
1789
|
const staffDefAttrs = flatStaves.map(s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`);
|
|
1720
|
-
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments);
|
|
1790
|
+
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
|
|
1721
1791
|
layoutUsed = true;
|
|
1722
1792
|
}
|
|
1723
1793
|
}
|
|
@@ -1733,13 +1803,13 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
|
|
|
1733
1803
|
const last = info.staffOffset + info.maxStaff;
|
|
1734
1804
|
const instr = instruments[`${first}-${last}`];
|
|
1735
1805
|
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
1736
|
-
xml += instrumentLabelXML(instr, `${indent}
|
|
1806
|
+
xml += instrumentLabelXML(instr, `${indent} `, channels);
|
|
1737
1807
|
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1738
1808
|
const globalStaff = info.staffOffset + ls;
|
|
1739
1809
|
const clef = info.clefs[ls] || Clef.treble;
|
|
1740
1810
|
const leafInstr = instruments[`${globalStaff}`];
|
|
1741
1811
|
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
1742
|
-
xml += staffDefWithLabel(attrs, leafInstr, `${indent}
|
|
1812
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
1743
1813
|
}
|
|
1744
1814
|
xml += `${indent} </staffGrp>\n`;
|
|
1745
1815
|
}
|
|
@@ -1749,7 +1819,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
|
|
|
1749
1819
|
const clef = info.clefs[1] || Clef.treble;
|
|
1750
1820
|
const leafInstr = instruments[`${globalStaff}`];
|
|
1751
1821
|
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
1752
|
-
xml += staffDefWithLabel(attrs, leafInstr, `${indent}
|
|
1822
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
1753
1823
|
}
|
|
1754
1824
|
}
|
|
1755
1825
|
xml += `${indent} </staffGrp>\n`;
|
|
@@ -2101,11 +2171,18 @@ const encode = (doc, options = {}) => {
|
|
|
2101
2171
|
const octaveEndReplacements = {};
|
|
2102
2172
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
2103
2173
|
const clefState = {};
|
|
2174
|
+
// Running written→sounding transposition per global staff, as the verbatim
|
|
2175
|
+
// "diat,semi" key. Seeded from the initial clefs (which the leading <scoreDef>
|
|
2176
|
+
// already encoded via staffDefClefAttrs), then advanced whenever a measure
|
|
2177
|
+
// starts under a clef with a different transposition.
|
|
2178
|
+
const transState = {};
|
|
2104
2179
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
2105
2180
|
const partInfo = partInfos[pi];
|
|
2106
2181
|
for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
|
|
2107
2182
|
const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
|
|
2108
2183
|
clefState[globalStaff] = clef;
|
|
2184
|
+
const t = clefTransOf(clef);
|
|
2185
|
+
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2109
2186
|
}
|
|
2110
2187
|
}
|
|
2111
2188
|
// Helper to check if a measure has any musical content
|
|
@@ -2154,6 +2231,41 @@ const encode = (doc, options = {}) => {
|
|
|
2154
2231
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
2155
2232
|
}
|
|
2156
2233
|
}
|
|
2234
|
+
// Check for a written→sounding transposition change at this measure
|
|
2235
|
+
// boundary and mirror it into MIDI. Verovio applies att.transposition only
|
|
2236
|
+
// from <staffDef>, never from a mid-measure <clef>, so a change of
|
|
2237
|
+
// transposing clef must be re-declared via a between-measure <scoreDef>.
|
|
2238
|
+
// We emit only the staves whose transposition actually changed (a partial
|
|
2239
|
+
// <staffGrp> leaves the others' state intact), using explicit "0,0" to
|
|
2240
|
+
// clear a prior transposition when a transposing clef is replaced by a
|
|
2241
|
+
// plain one. mi === 0 is already covered by the leading <scoreDef>.
|
|
2242
|
+
if (mi > 0) {
|
|
2243
|
+
const changed = [];
|
|
2244
|
+
for (let si = 1; si <= totalStaves; si++) {
|
|
2245
|
+
const startClef = measureStartClef(measure, si, partInfos, clefState[si]);
|
|
2246
|
+
if (startClef === undefined)
|
|
2247
|
+
continue;
|
|
2248
|
+
const t = clefTransOf(startClef);
|
|
2249
|
+
const key = `${t.diat},${t.semi}`;
|
|
2250
|
+
if (transState[si] === undefined) {
|
|
2251
|
+
transState[si] = key;
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
if (key !== transState[si]) {
|
|
2255
|
+
transState[si] = key;
|
|
2256
|
+
const c = resolveClef(startClef);
|
|
2257
|
+
changed.push(`${indent}${indent}${indent}${indent}${indent}${indent}${indent}${indent}<staffDef xml:id="${generateId('staffdef')}" n="${si}" lines="5" clef.shape="${c.shape}" clef.line="${c.line}" trans.diat="${t.diat}" trans.semi="${t.semi}" />`);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
if (changed.length > 0) {
|
|
2261
|
+
const sd = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2262
|
+
mei += `${sd}<scoreDef xml:id="${generateId('scoredef')}">\n`;
|
|
2263
|
+
mei += `${sd}${indent}<staffGrp xml:id="${generateId('staffgrp')}">\n`;
|
|
2264
|
+
mei += changed.join("\n") + "\n";
|
|
2265
|
+
mei += `${sd}${indent}</staffGrp>\n`;
|
|
2266
|
+
mei += `${sd}</scoreDef>\n`;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2157
2269
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
|
|
2158
2270
|
});
|
|
2159
2271
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
@@ -52,6 +52,11 @@ export declare class StaffLayout {
|
|
|
52
52
|
get stavesCount(): number;
|
|
53
53
|
}
|
|
54
54
|
export declare const parseStaffLayout: (code: string) => StaffLayout;
|
|
55
|
+
export interface SerializeStaffLayoutOptions {
|
|
56
|
+
anonymous?: boolean;
|
|
57
|
+
idMap?: (originalId: string) => string;
|
|
58
|
+
}
|
|
59
|
+
export declare const serializeStaffLayout: (layout: StaffLayout, options?: SerializeStaffLayoutOptions) => string;
|
|
55
60
|
export declare const encodeStaffLayoutMEI: (layout: StaffLayout, nameDict?: {
|
|
56
61
|
[key: string]: string;
|
|
57
62
|
}, indent?: number, tab?: string) => string;
|