@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
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
InstrumentName,
|
|
29
29
|
} from "./types";
|
|
30
30
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
|
+
import { gmProgramOf } from "./gmInstruments";
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -120,13 +121,24 @@ const resolveClef = (clefStr: string): { shape: string; line: number; trans?: {
|
|
|
120
121
|
|
|
121
122
|
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
122
123
|
// A mid-measure <clef> cannot carry att.transposition (that is a staff-level
|
|
123
|
-
// property
|
|
124
|
-
//
|
|
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.
|
|
125
128
|
const clefElementAttrs = (clefStr: string): string => {
|
|
126
129
|
const c = resolveClef(clefStr);
|
|
127
130
|
return `shape="${c.shape}" line="${c.line}"`;
|
|
128
131
|
};
|
|
129
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 };
|
|
140
|
+
};
|
|
141
|
+
|
|
130
142
|
// Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
|
|
131
143
|
// (trans.diat / trans.semi) when the clef declares a transposition.
|
|
132
144
|
const staffDefClefAttrs = (clefStr: string): string => {
|
|
@@ -1728,6 +1740,36 @@ const generateTempoElement = (tempo: Tempo, indent: string, staff: number = 1):
|
|
|
1728
1740
|
// Clef state for cross-measure clef tracking - maps staff number to current clef
|
|
1729
1741
|
type ClefState = Record<number, Clef>;
|
|
1730
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
|
+
|
|
1731
1773
|
// Barline style to MEI @right attribute mapping
|
|
1732
1774
|
const BARLINE_TO_MEI: Record<string, string> = {
|
|
1733
1775
|
'|': 'single',
|
|
@@ -2021,16 +2063,54 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
|
|
|
2021
2063
|
// MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
|
|
2022
2064
|
const LAYOUT_SYMBOL: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
|
|
2023
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
|
+
|
|
2024
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).
|
|
2025
2099
|
const instrumentLabelXML = (
|
|
2026
2100
|
instr: InstrumentName | undefined,
|
|
2027
2101
|
indent: string,
|
|
2102
|
+
channels?: ChannelAllocator,
|
|
2028
2103
|
): string => {
|
|
2029
2104
|
if (!instr) return "";
|
|
2030
2105
|
let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
|
|
2031
2106
|
if (instr.shortName !== undefined) {
|
|
2032
2107
|
xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
|
|
2033
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
|
+
}
|
|
2034
2114
|
return xml;
|
|
2035
2115
|
};
|
|
2036
2116
|
|
|
@@ -2046,28 +2126,29 @@ const layoutGroupToMEI = (
|
|
|
2046
2126
|
leafCounter: { i: number },
|
|
2047
2127
|
indent: string,
|
|
2048
2128
|
instruments: { [key: string]: InstrumentName },
|
|
2129
|
+
channels: ChannelAllocator,
|
|
2049
2130
|
): string => {
|
|
2050
2131
|
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
2051
2132
|
const instr = group.key !== undefined ? instruments[group.key] : undefined;
|
|
2052
2133
|
|
|
2053
2134
|
// A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
|
|
2054
2135
|
if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
|
|
2055
|
-
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
|
|
2136
|
+
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
|
|
2056
2137
|
}
|
|
2057
2138
|
|
|
2058
2139
|
const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
|
|
2059
2140
|
const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
|
|
2060
2141
|
let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
|
|
2061
2142
|
// A multi-staff group's instrument name labels the whole group.
|
|
2062
|
-
if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ");
|
|
2143
|
+
if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
2063
2144
|
if (isLeaf) {
|
|
2064
2145
|
// A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
|
|
2065
2146
|
// a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
|
|
2066
2147
|
// label goes on the staffDef inside.
|
|
2067
|
-
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
|
|
2148
|
+
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
|
|
2068
2149
|
} else {
|
|
2069
2150
|
for (const sub of group.subs || []) {
|
|
2070
|
-
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
|
|
2151
|
+
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
|
|
2071
2152
|
}
|
|
2072
2153
|
}
|
|
2073
2154
|
xml += `${indent}</staffGrp>\n`;
|
|
@@ -2079,11 +2160,12 @@ const staffDefWithLabel = (
|
|
|
2079
2160
|
attrs: string | undefined,
|
|
2080
2161
|
instr: InstrumentName | undefined,
|
|
2081
2162
|
indent: string,
|
|
2163
|
+
channels?: ChannelAllocator,
|
|
2082
2164
|
): string => {
|
|
2083
2165
|
if (attrs === undefined) return "";
|
|
2084
2166
|
if (!instr) return `${indent}<staffDef ${attrs} />\n`;
|
|
2085
2167
|
let xml = `${indent}<staffDef ${attrs}>\n`;
|
|
2086
|
-
xml += instrumentLabelXML(instr, indent + " ");
|
|
2168
|
+
xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
2087
2169
|
xml += `${indent}</staffDef>\n`;
|
|
2088
2170
|
return xml;
|
|
2089
2171
|
};
|
|
@@ -2101,6 +2183,10 @@ const encodeScoreDef = (
|
|
|
2101
2183
|
): string => {
|
|
2102
2184
|
const scoreDefId = generateId("scoredef");
|
|
2103
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
|
+
|
|
2104
2190
|
// Build meter attributes
|
|
2105
2191
|
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
2106
2192
|
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
@@ -2123,7 +2209,7 @@ const encodeScoreDef = (
|
|
|
2123
2209
|
const staffDefAttrs = flatStaves.map(
|
|
2124
2210
|
s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`
|
|
2125
2211
|
);
|
|
2126
|
-
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments);
|
|
2212
|
+
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
|
|
2127
2213
|
layoutUsed = true;
|
|
2128
2214
|
}
|
|
2129
2215
|
}
|
|
@@ -2142,13 +2228,13 @@ const encodeScoreDef = (
|
|
|
2142
2228
|
const last = info.staffOffset + info.maxStaff;
|
|
2143
2229
|
const instr = instruments[`${first}-${last}`];
|
|
2144
2230
|
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
2145
|
-
xml += instrumentLabelXML(instr, `${indent}
|
|
2231
|
+
xml += instrumentLabelXML(instr, `${indent} `, channels);
|
|
2146
2232
|
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
2147
2233
|
const globalStaff = info.staffOffset + ls;
|
|
2148
2234
|
const clef = info.clefs[ls] || Clef.treble;
|
|
2149
2235
|
const leafInstr = instruments[`${globalStaff}`];
|
|
2150
2236
|
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
2151
|
-
xml += staffDefWithLabel(attrs, leafInstr, `${indent}
|
|
2237
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
2152
2238
|
}
|
|
2153
2239
|
xml += `${indent} </staffGrp>\n`;
|
|
2154
2240
|
} else {
|
|
@@ -2157,7 +2243,7 @@ const encodeScoreDef = (
|
|
|
2157
2243
|
const clef = info.clefs[1] || Clef.treble;
|
|
2158
2244
|
const leafInstr = instruments[`${globalStaff}`];
|
|
2159
2245
|
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
2160
|
-
xml += staffDefWithLabel(attrs, leafInstr, `${indent}
|
|
2246
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
2161
2247
|
}
|
|
2162
2248
|
}
|
|
2163
2249
|
|
|
@@ -2547,11 +2633,18 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2547
2633
|
|
|
2548
2634
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
2549
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> = {};
|
|
2550
2641
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
2551
2642
|
const partInfo = partInfos[pi];
|
|
2552
2643
|
for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
|
|
2553
2644
|
const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
|
|
2554
2645
|
clefState[globalStaff] = clef;
|
|
2646
|
+
const t = clefTransOf(clef);
|
|
2647
|
+
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2555
2648
|
}
|
|
2556
2649
|
}
|
|
2557
2650
|
|
|
@@ -2603,6 +2696,37 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2603
2696
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
2604
2697
|
}
|
|
2605
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
|
+
}
|
|
2606
2730
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
|
|
2607
2731
|
});
|
|
2608
2732
|
|
|
@@ -240,6 +240,82 @@ export class StaffLayout {
|
|
|
240
240
|
|
|
241
241
|
export const parseStaffLayout = (code: string): StaffLayout => new StaffLayout(tokenize(code));
|
|
242
242
|
|
|
243
|
+
// ── Staff-layout serialization (inverse of parseStaffLayout) ──
|
|
244
|
+
// Reconstruct a layout string from a parsed StaffLayout by walking the group tree,
|
|
245
|
+
// so every staff slot and conjunction is preserved structurally (a regex strip of the
|
|
246
|
+
// ids would drop a BARE anonymous leaf — its empty token gets swallowed by whitespace).
|
|
247
|
+
//
|
|
248
|
+
// `anonymous` emits empty ids (the parser re-auto-names slots "1","2",… by position).
|
|
249
|
+
// `idMap` optionally overrides individual staff ids by their original id.
|
|
250
|
+
//
|
|
251
|
+
// Conjunction rendering: Solid → "-", Dashed → ".", Blank → " " ONLY when both sides
|
|
252
|
+
// are bracketed groups (the brackets self-delimit the slots); otherwise Blank → ","
|
|
253
|
+
// so an adjacent empty/bare leaf still tokenizes as its own slot.
|
|
254
|
+
|
|
255
|
+
const CONJ_CHAR: { [c in StaffConjunctionType]: string } = {
|
|
256
|
+
[StaffConjunctionType.Solid]: "-",
|
|
257
|
+
[StaffConjunctionType.Dashed]: ".",
|
|
258
|
+
[StaffConjunctionType.Blank]: ",",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export interface SerializeStaffLayoutOptions {
|
|
262
|
+
anonymous?: boolean;
|
|
263
|
+
idMap?: (originalId: string) => string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const serializeStaffLayout = (layout: StaffLayout, options: SerializeStaffLayoutOptions = {}): string => {
|
|
267
|
+
const { anonymous = false, idMap } = options;
|
|
268
|
+
const isGrouped = (group: StaffGroup): boolean => group.type !== StaffGroupType.Default && !!group.subs;
|
|
269
|
+
|
|
270
|
+
const leafText = (id: string): string => (anonymous ? "" : idMap ? idMap(id) : id);
|
|
271
|
+
|
|
272
|
+
// flat leaf index of a group's first / last staff (for the inter-child conjunction).
|
|
273
|
+
const firstLeafIndex = (group: StaffGroup): number => layout.staffIds.indexOf(groupHead(group)!);
|
|
274
|
+
const lastLeafIndex = (group: StaffGroup): number => layout.staffIds.indexOf(groupTail(group)!);
|
|
275
|
+
|
|
276
|
+
const sep = (conj: StaffConjunctionType, left: StaffGroup, right: StaffGroup): string => {
|
|
277
|
+
if (conj !== StaffConjunctionType.Blank) return CONJ_CHAR[conj];
|
|
278
|
+
// Blank: a space is safe only when both neighbours are bracketed (self-delimiting).
|
|
279
|
+
return isGrouped(left) && isGrouped(right) ? " " : ",";
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const emit = (group: StaffGroup): string => {
|
|
283
|
+
if (!group.subs) return leafText(group.staff!); // Default leaf
|
|
284
|
+
|
|
285
|
+
const open = group.type === StaffGroupType.Brace ? "{" : group.type === StaffGroupType.Bracket ? "<" : group.type === StaffGroupType.Square ? "[" : "";
|
|
286
|
+
const close = group.type === StaffGroupType.Brace ? "}" : group.type === StaffGroupType.Bracket ? ">" : group.type === StaffGroupType.Square ? "]" : "";
|
|
287
|
+
|
|
288
|
+
let inner = "";
|
|
289
|
+
group.subs.forEach((sub, i) => {
|
|
290
|
+
inner += emit(sub);
|
|
291
|
+
if (i < group.subs!.length - 1) {
|
|
292
|
+
const next = group.subs![i + 1];
|
|
293
|
+
const conj = layout.conjunctions[lastLeafIndex(sub)] ?? StaffConjunctionType.Blank;
|
|
294
|
+
inner += sep(conj, sub, next);
|
|
295
|
+
void firstLeafIndex; // (lastLeafIndex(sub) === firstLeafIndex(next) - 1)
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return open + inner + close;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
let out = emit(layout.group);
|
|
302
|
+
|
|
303
|
+
// A TRAILING bare anonymous leaf emits "" with nothing after it to delimit the slot
|
|
304
|
+
// (a leaf before a closing bracket is fine — the bracket gives it bounds; an internal
|
|
305
|
+
// one is flushed by the next separator). The tokenizer only flushes a final empty item
|
|
306
|
+
// if it carries bounds, so append one "," to materialize that last empty slot. This only
|
|
307
|
+
// arises when the OUTERMOST container is the Default sequence (no enclosing bracket) and
|
|
308
|
+
// its last child is a bare leaf; if the whole layout is wrapped in a bracket, the closing
|
|
309
|
+
// bracket already delimits the final leaf. The trailing conjunction is dropped on re-parse
|
|
310
|
+
// (conjunctions = items[0..n-1]), so it is harmless. Anonymous output only.
|
|
311
|
+
if (anonymous && layout.group.type === StaffGroupType.Default && layout.group.subs) {
|
|
312
|
+
const lastTop = layout.group.subs[layout.group.subs.length - 1];
|
|
313
|
+
if (!lastTop.subs && lastTop.staff !== undefined) out += ",";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return out;
|
|
317
|
+
};
|
|
318
|
+
|
|
243
319
|
// ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
|
|
244
320
|
// Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
|
|
245
321
|
// with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
|