@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
package/source/lilylet/index.ts
CHANGED
|
@@ -130,6 +130,25 @@
|
|
|
130
130
|
const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
|
|
131
131
|
const markupMark = (content) => ({ markType: 'markup', content });
|
|
132
132
|
|
|
133
|
+
// Build an { instruments: { <key>: { name, shortName? } } } fragment from a
|
|
134
|
+
// `[instrument-<key>` header token. <key> is a staff-layout group key — a single
|
|
135
|
+
// staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
|
|
136
|
+
// "[instrument-" prefix so it lines up with staffLayout's groupKey().
|
|
137
|
+
const instrumentStaff = (token, name, shortName) => {
|
|
138
|
+
const key = token.slice('[instrument-'.length);
|
|
139
|
+
const entry = shortName !== undefined ? { name, shortName } : { name };
|
|
140
|
+
return { instruments: { [key]: entry } };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Merge two header fragments. Plain fields are overwritten by the later one, but the
|
|
144
|
+
// `instruments` map is deep-merged so multiple [instrument-<key>] lines accumulate.
|
|
145
|
+
const mergeHeaders = (a, b) => {
|
|
146
|
+
const merged = { ...a, ...b };
|
|
147
|
+
if (a.instruments || b.instruments)
|
|
148
|
+
merged.instruments = { ...a.instruments, ...b.instruments };
|
|
149
|
+
return merged;
|
|
150
|
+
};
|
|
151
|
+
|
|
133
152
|
// Parse PITCH token (e.g., "c", "cs", "bf", "css", "bff") into phonet and accidental
|
|
134
153
|
const parsePitch = (text, octave) => {
|
|
135
154
|
const phonet = text[0].toLowerCase();
|
|
@@ -227,8 +246,10 @@
|
|
|
227
246
|
\[arranger return 'HEADER_ARRANGER'
|
|
228
247
|
\[lyricist return 'HEADER_LYRICIST'
|
|
229
248
|
\[opus return 'HEADER_OPUS'
|
|
249
|
+
\[instrument\-[A-Za-z0-9_]+(?:\-[A-Za-z0-9_]+)* return 'HEADER_INSTRUMENT_STAFF'
|
|
230
250
|
\[instrument return 'HEADER_INSTRUMENT'
|
|
231
251
|
\[genre return 'HEADER_GENRE'
|
|
252
|
+
\[staves return 'HEADER_STAVES'
|
|
232
253
|
\[auto\-beam return 'HEADER_AUTOBEAM'
|
|
233
254
|
\] return ']'
|
|
234
255
|
|
|
@@ -350,6 +371,8 @@ document
|
|
|
350
371
|
content
|
|
351
372
|
: headers measures -> ({ metadata: $1, measures: $2 })
|
|
352
373
|
| headers newlines measures -> ({ metadata: $1, measures: $3 })
|
|
374
|
+
| newlines headers measures -> ({ metadata: $2, measures: $3 })
|
|
375
|
+
| newlines headers newlines measures -> ({ metadata: $2, measures: $4 })
|
|
353
376
|
| newlines measures -> ({ metadata: undefined, measures: $2 })
|
|
354
377
|
| measures -> ({ metadata: undefined, measures: $1 })
|
|
355
378
|
;
|
|
@@ -361,9 +384,9 @@ newlines
|
|
|
361
384
|
|
|
362
385
|
headers
|
|
363
386
|
: header -> $1
|
|
364
|
-
| headers header -> (
|
|
387
|
+
| headers header -> mergeHeaders($1, $2)
|
|
365
388
|
| headers NEWLINE -> $1
|
|
366
|
-
| headers NEWLINE header -> (
|
|
389
|
+
| headers NEWLINE header -> mergeHeaders($1, $3)
|
|
367
390
|
;
|
|
368
391
|
|
|
369
392
|
header
|
|
@@ -374,7 +397,10 @@ header
|
|
|
374
397
|
| HEADER_LYRICIST STRING ']' -> ({ lyricist: $2.slice(1, -1) })
|
|
375
398
|
| HEADER_OPUS STRING ']' -> ({ opus: $2.slice(1, -1) })
|
|
376
399
|
| HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2.slice(1, -1) })
|
|
400
|
+
| HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, $2.slice(1, -1))
|
|
401
|
+
| HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2.slice(1, -1), $3.slice(1, -1))
|
|
377
402
|
| HEADER_GENRE STRING ']' -> ({ genre: $2.slice(1, -1) })
|
|
403
|
+
| HEADER_STAVES STRING ']' -> ({ staves: $2.slice(1, -1) })
|
|
378
404
|
| HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
|
|
379
405
|
;
|
|
380
406
|
|
|
@@ -25,7 +25,10 @@ import {
|
|
|
25
25
|
NavigationMarkType,
|
|
26
26
|
Tempo,
|
|
27
27
|
Event,
|
|
28
|
+
InstrumentName,
|
|
28
29
|
} from "./types";
|
|
30
|
+
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
|
+
import { gmProgramOf } from "./gmInstruments";
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -80,41 +83,73 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
|
|
|
80
83
|
};
|
|
81
84
|
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
// Semitone offsets of the major/perfect intervals within one diatonic octave,
|
|
87
|
+
// indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
|
|
88
|
+
const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
89
|
+
|
|
90
|
+
// Convert a LilyPond clef interval-number suffix into an MEI written→sounding
|
|
91
|
+
// transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
|
|
92
|
+
// 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
|
|
93
|
+
// raises it. Returns { diat, semi } where diat is the diatonic step shift
|
|
94
|
+
// (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
|
|
95
|
+
// extended octave-wise for compound intervals.
|
|
96
|
+
const clefTransposition = (intervalNumber: number, up: boolean): { diat: number; semi: number } => {
|
|
97
|
+
const k = intervalNumber - 1; // diatonic steps
|
|
98
|
+
const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
|
|
99
|
+
const sign = up ? 1 : -1;
|
|
100
|
+
return { diat: sign * k, semi: sign * semis };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Resolve a clef string into MEI shape/line plus optional written→sounding
|
|
104
|
+
// transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
|
|
105
|
+
// clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
|
|
106
|
+
// "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
|
|
107
|
+
// displacement (8|15|22), so all clef transposition — octaves included — is
|
|
108
|
+
// encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
|
|
109
|
+
const resolveClef = (clefStr: string): { shape: string; line: number; trans?: { diat: number; semi: number } } => {
|
|
110
|
+
const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
|
|
90
111
|
const base = match ? match[1] : clefStr;
|
|
91
112
|
const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
|
|
92
113
|
if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
|
|
114
|
+
const trans = clefTransposition(Number(match[3]), match[2] === "^");
|
|
93
115
|
return {
|
|
94
116
|
shape: clefInfo.shape,
|
|
95
117
|
line: clefInfo.line,
|
|
96
|
-
|
|
97
|
-
disPlace: match[2] === "^" ? "above" : "below",
|
|
118
|
+
trans,
|
|
98
119
|
};
|
|
99
120
|
};
|
|
100
121
|
|
|
101
122
|
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
123
|
+
// A mid-measure <clef> cannot carry att.transposition (that is a staff-level
|
|
124
|
+
// property Verovio only reads from <staffDef>); the visible clef shape/line is
|
|
125
|
+
// emitted here, and a sounding-pitch transposition change is mirrored by a
|
|
126
|
+
// between-measure <scoreDef>/<staffDef trans.*> emitted in the measure loop
|
|
127
|
+
// (see emitClefTranspositionScoreDef). So only shape/line are emitted here.
|
|
102
128
|
const clefElementAttrs = (clefStr: string): string => {
|
|
103
129
|
const c = resolveClef(clefStr);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
130
|
+
return `shape="${c.shape}" line="${c.line}"`;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// The written→sounding transposition a clef declares, as {diat, semi}; {0,0}
|
|
134
|
+
// when the clef declares none. Used to detect mid-piece transposition changes
|
|
135
|
+
// and to emit the resetting <staffDef> when a transposing clef is replaced by a
|
|
136
|
+
// plain one (Verovio retains a prior trans.* until an explicit 0/0 overrides it).
|
|
137
|
+
const clefTransOf = (clefStr: string): { diat: number; semi: number } => {
|
|
138
|
+
const c = resolveClef(clefStr);
|
|
139
|
+
return c.trans || { diat: 0, semi: 0 };
|
|
107
140
|
};
|
|
108
141
|
|
|
109
|
-
// Attributes for a <staffDef> clef (clef.* namespace).
|
|
142
|
+
// Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
|
|
143
|
+
// (trans.diat / trans.semi) when the clef declares a transposition.
|
|
110
144
|
const staffDefClefAttrs = (clefStr: string): string => {
|
|
111
145
|
const c = resolveClef(clefStr);
|
|
112
146
|
let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
|
|
113
|
-
if (c.
|
|
147
|
+
if (c.trans) attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
|
|
114
148
|
return attrs;
|
|
115
149
|
};
|
|
116
150
|
|
|
117
151
|
|
|
152
|
+
|
|
118
153
|
// Lilylet duration division to MEI dur
|
|
119
154
|
// division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
|
|
120
155
|
const DURATIONS: Record<number, string> = {
|
|
@@ -1705,6 +1740,36 @@ const generateTempoElement = (tempo: Tempo, indent: string, staff: number = 1):
|
|
|
1705
1740
|
// Clef state for cross-measure clef tracking - maps staff number to current clef
|
|
1706
1741
|
type ClefState = Record<number, Clef>;
|
|
1707
1742
|
|
|
1743
|
+
// The clef governing a measure's sounding pitch on one global staff: the clef
|
|
1744
|
+
// carried in from the previous measure, overridden by a leading clef change that
|
|
1745
|
+
// appears before the first note/rest in the measure (the normal boundary-change
|
|
1746
|
+
// case). A clef change occurring *after* notes does not retune this measure — it
|
|
1747
|
+
// becomes the carried clef for the next measure via clefState. This per-measure
|
|
1748
|
+
// granularity matches what Verovio honors for MIDI: a transposition change takes
|
|
1749
|
+
// effect only via a between-measure <scoreDef>/<staffDef trans.*>, never from a
|
|
1750
|
+
// mid-measure <clef> element.
|
|
1751
|
+
const measureStartClef = (measure: Measure, globalStaff: number, partInfos: PartInfo[], carriedClef?: string): string | undefined => {
|
|
1752
|
+
let active = carriedClef;
|
|
1753
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
1754
|
+
const partOffset = partInfos[pi]?.staffOffset || 0;
|
|
1755
|
+
for (const voice of measure.parts[pi].voices) {
|
|
1756
|
+
if ((partOffset + (voice.staff || 1)) !== globalStaff) continue;
|
|
1757
|
+
for (const event of voice.events) {
|
|
1758
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
1759
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
1760
|
+
break; // notes have started; a later clef change governs the next measure
|
|
1761
|
+
}
|
|
1762
|
+
if (event.type === 'context') {
|
|
1763
|
+
const ctx = event as ContextChange;
|
|
1764
|
+
if (ctx.clef) active = ctx.clef;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return active;
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
|
|
1708
1773
|
// Barline style to MEI @right attribute mapping
|
|
1709
1774
|
const BARLINE_TO_MEI: Record<string, string> = {
|
|
1710
1775
|
'|': 'single',
|
|
@@ -1995,6 +2060,116 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
|
|
|
1995
2060
|
return partInfos;
|
|
1996
2061
|
};
|
|
1997
2062
|
|
|
2063
|
+
// MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
|
|
2064
|
+
const LAYOUT_SYMBOL: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
|
|
2065
|
+
|
|
2066
|
+
// MIDI channel allocator: assigns one channel per distinct GM program so that
|
|
2067
|
+
// instruments get separate timbres. Verovio defaults every staff to channel 0
|
|
2068
|
+
// unless <instrDef midi.channel> is set, which would make all ProgramChanges
|
|
2069
|
+
// collide on one channel (only the last program would sound). We dedup by
|
|
2070
|
+
// program number — duplicate desks (e.g. two "Violins" staves) share a channel
|
|
2071
|
+
// — and skip channel 9 (the GM percussion channel) for pitched instruments.
|
|
2072
|
+
interface ChannelAllocator {
|
|
2073
|
+
byProgram: Map<number, number>;
|
|
2074
|
+
nextChannel: number;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
const newChannelAllocator = (): ChannelAllocator => ({ byProgram: new Map(), nextChannel: 0 });
|
|
2078
|
+
|
|
2079
|
+
// Channel for a program: reuse the channel already assigned to that program,
|
|
2080
|
+
// else take the next free channel (skipping 9). Wraps past 16 as graceful
|
|
2081
|
+
// degradation for scores with more than 15 distinct timbres.
|
|
2082
|
+
const allocChannel = (alloc: ChannelAllocator, program: number): number => {
|
|
2083
|
+
const existing = alloc.byProgram.get(program);
|
|
2084
|
+
if (existing !== undefined) return existing;
|
|
2085
|
+
let ch = alloc.nextChannel;
|
|
2086
|
+
if (ch % 16 === 9) ch++; // skip GM drum channel
|
|
2087
|
+
const assigned = ch % 16;
|
|
2088
|
+
alloc.byProgram.set(program, assigned);
|
|
2089
|
+
alloc.nextChannel = ch + 1;
|
|
2090
|
+
return assigned;
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
// Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
|
|
2094
|
+
// When the instrument name resolves to a General MIDI program, also emit an
|
|
2095
|
+
// <instrDef midi.instrnum midi.channel> sibling so Verovio's MIDI export assigns
|
|
2096
|
+
// that timbre (it honors only the numeric @midi.instrnum) on its own channel
|
|
2097
|
+
// (@midi.channel, else all instruments collide on channel 0). Unknown names emit
|
|
2098
|
+
// just the label, leaving Verovio's default program (0 = piano).
|
|
2099
|
+
const instrumentLabelXML = (
|
|
2100
|
+
instr: InstrumentName | undefined,
|
|
2101
|
+
indent: string,
|
|
2102
|
+
channels?: ChannelAllocator,
|
|
2103
|
+
): string => {
|
|
2104
|
+
if (!instr) return "";
|
|
2105
|
+
let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
|
|
2106
|
+
if (instr.shortName !== undefined) {
|
|
2107
|
+
xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
|
|
2108
|
+
}
|
|
2109
|
+
const program = gmProgramOf(instr.name);
|
|
2110
|
+
if (program !== undefined) {
|
|
2111
|
+
const chanAttr = channels ? ` midi.channel="${allocChannel(channels, program)}"` : "";
|
|
2112
|
+
xml += `${indent}<instrDef xml:id="${generateId("instrdef")}" midi.instrnum="${program}"${chanAttr} />\n`;
|
|
2113
|
+
}
|
|
2114
|
+
return xml;
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
// Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
|
|
2118
|
+
// `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
|
|
2119
|
+
// string for the matching global staff's <staffDef>. `instruments` maps a layout group
|
|
2120
|
+
// key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
|
|
2121
|
+
// names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
|
|
2122
|
+
// bar.thru reflects the group's conjunction (Solid).
|
|
2123
|
+
const layoutGroupToMEI = (
|
|
2124
|
+
group: StaffGroup,
|
|
2125
|
+
staffDefAttrs: string[],
|
|
2126
|
+
leafCounter: { i: number },
|
|
2127
|
+
indent: string,
|
|
2128
|
+
instruments: { [key: string]: InstrumentName },
|
|
2129
|
+
channels: ChannelAllocator,
|
|
2130
|
+
): string => {
|
|
2131
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
2132
|
+
const instr = group.key !== undefined ? instruments[group.key] : undefined;
|
|
2133
|
+
|
|
2134
|
+
// A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
|
|
2135
|
+
if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
|
|
2136
|
+
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
|
|
2140
|
+
const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
|
|
2141
|
+
let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
|
|
2142
|
+
// A multi-staff group's instrument name labels the whole group.
|
|
2143
|
+
if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
2144
|
+
if (isLeaf) {
|
|
2145
|
+
// A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
|
|
2146
|
+
// a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
|
|
2147
|
+
// label goes on the staffDef inside.
|
|
2148
|
+
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
|
|
2149
|
+
} else {
|
|
2150
|
+
for (const sub of group.subs || []) {
|
|
2151
|
+
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
xml += `${indent}</staffGrp>\n`;
|
|
2155
|
+
return xml;
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
// Emit a <staffDef> from its attribute string, with optional instrument <label> children.
|
|
2159
|
+
const staffDefWithLabel = (
|
|
2160
|
+
attrs: string | undefined,
|
|
2161
|
+
instr: InstrumentName | undefined,
|
|
2162
|
+
indent: string,
|
|
2163
|
+
channels?: ChannelAllocator,
|
|
2164
|
+
): string => {
|
|
2165
|
+
if (attrs === undefined) return "";
|
|
2166
|
+
if (!instr) return `${indent}<staffDef ${attrs} />\n`;
|
|
2167
|
+
let xml = `${indent}<staffDef ${attrs}>\n`;
|
|
2168
|
+
xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
2169
|
+
xml += `${indent}</staffDef>\n`;
|
|
2170
|
+
return xml;
|
|
2171
|
+
};
|
|
2172
|
+
|
|
1998
2173
|
// Encode scoreDef with part groups
|
|
1999
2174
|
const encodeScoreDef = (
|
|
2000
2175
|
keySig: string,
|
|
@@ -2002,36 +2177,79 @@ const encodeScoreDef = (
|
|
|
2002
2177
|
timeDen: number,
|
|
2003
2178
|
partInfos: PartInfo[],
|
|
2004
2179
|
indent: string,
|
|
2005
|
-
meterSymbol?: 'common' | 'cut'
|
|
2180
|
+
meterSymbol?: 'common' | 'cut',
|
|
2181
|
+
stavesCode?: string,
|
|
2182
|
+
instruments: { [key: string]: InstrumentName } = {}
|
|
2006
2183
|
): string => {
|
|
2007
2184
|
const scoreDefId = generateId("scoredef");
|
|
2008
2185
|
|
|
2186
|
+
// One MIDI channel allocator per score: assigns a distinct channel per GM
|
|
2187
|
+
// program so instruments don't collide on channel 0 (see allocChannel).
|
|
2188
|
+
const channels = newChannelAllocator();
|
|
2189
|
+
|
|
2009
2190
|
// Build meter attributes
|
|
2010
2191
|
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
2011
2192
|
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
2012
|
-
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
2013
2193
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2194
|
+
// Flat ordered list of global staves (n + clef), in part/voice order.
|
|
2195
|
+
const flatStaves: { n: number; clef: Clef }[] = [];
|
|
2196
|
+
for (const info of partInfos) {
|
|
2197
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
2198
|
+
flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// If a [staves] layout is present and its leaf count matches the staves, drive
|
|
2203
|
+
// the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
|
|
2204
|
+
// the per-part grand-staff grouping.
|
|
2205
|
+
let layoutUsed = false;
|
|
2206
|
+
if (stavesCode) {
|
|
2207
|
+
const layout = parseStaffLayout(stavesCode);
|
|
2208
|
+
if (layout.stavesCount === flatStaves.length) {
|
|
2209
|
+
const staffDefAttrs = flatStaves.map(
|
|
2210
|
+
s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`
|
|
2211
|
+
);
|
|
2212
|
+
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
|
|
2213
|
+
layoutUsed = true;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
if (!layoutUsed) {
|
|
2218
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
2219
|
+
|
|
2220
|
+
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
2221
|
+
const info = partInfos[pi];
|
|
2222
|
+
|
|
2223
|
+
// If part has multiple staves (grand staff), wrap in staffGrp with brace.
|
|
2224
|
+
// Instrument key for a part follows staffLayout's groupKey: a single staff
|
|
2225
|
+
// number, or "first-last" for a grand staff.
|
|
2226
|
+
if (info.maxStaff > 1) {
|
|
2227
|
+
const first = info.staffOffset + 1;
|
|
2228
|
+
const last = info.staffOffset + info.maxStaff;
|
|
2229
|
+
const instr = instruments[`${first}-${last}`];
|
|
2230
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
2231
|
+
xml += instrumentLabelXML(instr, `${indent} `, channels);
|
|
2232
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
2233
|
+
const globalStaff = info.staffOffset + ls;
|
|
2234
|
+
const clef = info.clefs[ls] || Clef.treble;
|
|
2235
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
2236
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
2237
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
2238
|
+
}
|
|
2239
|
+
xml += `${indent} </staffGrp>\n`;
|
|
2240
|
+
} else {
|
|
2241
|
+
// Single staff part
|
|
2242
|
+
const globalStaff = info.staffOffset + 1;
|
|
2243
|
+
const clef = info.clefs[1] || Clef.treble;
|
|
2244
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
2245
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
2246
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
2024
2247
|
}
|
|
2025
|
-
xml += `${indent} </staffGrp>\n`;
|
|
2026
|
-
} else {
|
|
2027
|
-
// Single staff part
|
|
2028
|
-
const globalStaff = info.staffOffset + 1;
|
|
2029
|
-
const clef = info.clefs[1] || Clef.treble;
|
|
2030
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
2031
2248
|
}
|
|
2249
|
+
|
|
2250
|
+
xml += `${indent} </staffGrp>\n`;
|
|
2032
2251
|
}
|
|
2033
2252
|
|
|
2034
|
-
xml += `${indent} </staffGrp>\n`;
|
|
2035
2253
|
xml += `${indent}</scoreDef>\n`;
|
|
2036
2254
|
return xml;
|
|
2037
2255
|
};
|
|
@@ -2397,7 +2615,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2397
2615
|
mei += `${indent}${indent}<body>\n`;
|
|
2398
2616
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
2399
2617
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
2400
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
2618
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
2401
2619
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
2402
2620
|
|
|
2403
2621
|
// Track tie state across measures for cross-measure ties
|
|
@@ -2415,11 +2633,18 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2415
2633
|
|
|
2416
2634
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
2417
2635
|
const clefState: ClefState = {};
|
|
2636
|
+
// Running written→sounding transposition per global staff, as the verbatim
|
|
2637
|
+
// "diat,semi" key. Seeded from the initial clefs (which the leading <scoreDef>
|
|
2638
|
+
// already encoded via staffDefClefAttrs), then advanced whenever a measure
|
|
2639
|
+
// starts under a clef with a different transposition.
|
|
2640
|
+
const transState: Record<number, string> = {};
|
|
2418
2641
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
2419
2642
|
const partInfo = partInfos[pi];
|
|
2420
2643
|
for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
|
|
2421
2644
|
const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
|
|
2422
2645
|
clefState[globalStaff] = clef;
|
|
2646
|
+
const t = clefTransOf(clef);
|
|
2647
|
+
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2423
2648
|
}
|
|
2424
2649
|
}
|
|
2425
2650
|
|
|
@@ -2471,6 +2696,37 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2471
2696
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
2472
2697
|
}
|
|
2473
2698
|
}
|
|
2699
|
+
// Check for a written→sounding transposition change at this measure
|
|
2700
|
+
// boundary and mirror it into MIDI. Verovio applies att.transposition only
|
|
2701
|
+
// from <staffDef>, never from a mid-measure <clef>, so a change of
|
|
2702
|
+
// transposing clef must be re-declared via a between-measure <scoreDef>.
|
|
2703
|
+
// We emit only the staves whose transposition actually changed (a partial
|
|
2704
|
+
// <staffGrp> leaves the others' state intact), using explicit "0,0" to
|
|
2705
|
+
// clear a prior transposition when a transposing clef is replaced by a
|
|
2706
|
+
// plain one. mi === 0 is already covered by the leading <scoreDef>.
|
|
2707
|
+
if (mi > 0) {
|
|
2708
|
+
const changed: string[] = [];
|
|
2709
|
+
for (let si = 1; si <= totalStaves; si++) {
|
|
2710
|
+
const startClef = measureStartClef(measure, si, partInfos, clefState[si]);
|
|
2711
|
+
if (startClef === undefined) continue;
|
|
2712
|
+
const t = clefTransOf(startClef);
|
|
2713
|
+
const key = `${t.diat},${t.semi}`;
|
|
2714
|
+
if (transState[si] === undefined) { transState[si] = key; continue; }
|
|
2715
|
+
if (key !== transState[si]) {
|
|
2716
|
+
transState[si] = key;
|
|
2717
|
+
const c = resolveClef(startClef);
|
|
2718
|
+
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}" />`);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
if (changed.length > 0) {
|
|
2722
|
+
const sd = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2723
|
+
mei += `${sd}<scoreDef xml:id="${generateId('scoredef')}">\n`;
|
|
2724
|
+
mei += `${sd}${indent}<staffGrp xml:id="${generateId('staffgrp')}">\n`;
|
|
2725
|
+
mei += changed.join("\n") + "\n";
|
|
2726
|
+
mei += `${sd}${indent}</staffGrp>\n`;
|
|
2727
|
+
mei += `${sd}</scoreDef>\n`;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2474
2730
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
|
|
2475
2731
|
});
|
|
2476
2732
|
|
|
@@ -749,6 +749,15 @@ const parseMetadata = (doc: Document): Metadata => {
|
|
|
749
749
|
}
|
|
750
750
|
}
|
|
751
751
|
}
|
|
752
|
+
|
|
753
|
+
// Staff layout: recover the raw [staves] string stashed at encode time.
|
|
754
|
+
const miscFields = getElements(identificationEl, 'miscellaneous-field');
|
|
755
|
+
for (const field of miscFields) {
|
|
756
|
+
if (getAttribute(field, 'name') === 'lilylet-staves') {
|
|
757
|
+
const code = field.textContent?.trim();
|
|
758
|
+
if (code) metadata.staves = code;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
752
761
|
}
|
|
753
762
|
|
|
754
763
|
return Object.keys(metadata).length > 0 ? metadata : {};
|