@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.
- package/lib/abc/abc.d.ts +1 -0
- package/lib/abc/grammar.jison.js +158 -135
- package/lib/lilylet/abcDecoder.js +219 -4
- 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 +140 -35
- package/lib/lilylet/musicXmlDecoder.js +9 -0
- package/lib/lilylet/musicXmlEncoder.js +114 -0
- package/lib/lilylet/serializer.js +36 -6
- package/lib/lilylet/staffLayout.d.ts +57 -0
- package/lib/lilylet/staffLayout.js +226 -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 +38 -13
- package/source/abc/abc.ts +1 -0
- package/source/abc/grammar.jison.js +158 -135
- package/source/lilylet/abcDecoder.ts +228 -4
- 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 +166 -34
- package/source/lilylet/musicXmlDecoder.ts +9 -0
- package/source/lilylet/musicXmlEncoder.ts +134 -0
- package/source/lilylet/serializer.ts +40 -6
- package/source/lilylet/staffLayout.ts +281 -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,9 @@ import {
|
|
|
25
25
|
NavigationMarkType,
|
|
26
26
|
Tempo,
|
|
27
27
|
Event,
|
|
28
|
+
InstrumentName,
|
|
28
29
|
} from "./types";
|
|
30
|
+
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -80,41 +82,62 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
|
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
// Semitone offsets of the major/perfect intervals within one diatonic octave,
|
|
86
|
+
// indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
|
|
87
|
+
const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
88
|
+
|
|
89
|
+
// Convert a LilyPond clef interval-number suffix into an MEI written→sounding
|
|
90
|
+
// transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
|
|
91
|
+
// 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
|
|
92
|
+
// raises it. Returns { diat, semi } where diat is the diatonic step shift
|
|
93
|
+
// (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
|
|
94
|
+
// extended octave-wise for compound intervals.
|
|
95
|
+
const clefTransposition = (intervalNumber: number, up: boolean): { diat: number; semi: number } => {
|
|
96
|
+
const k = intervalNumber - 1; // diatonic steps
|
|
97
|
+
const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
|
|
98
|
+
const sign = up ? 1 : -1;
|
|
99
|
+
return { diat: sign * k, semi: sign * semis };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Resolve a clef string into MEI shape/line plus optional written→sounding
|
|
103
|
+
// transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
|
|
104
|
+
// clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
|
|
105
|
+
// "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
|
|
106
|
+
// displacement (8|15|22), so all clef transposition — octaves included — is
|
|
107
|
+
// encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
|
|
108
|
+
const resolveClef = (clefStr: string): { shape: string; line: number; trans?: { diat: number; semi: number } } => {
|
|
109
|
+
const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
|
|
90
110
|
const base = match ? match[1] : clefStr;
|
|
91
111
|
const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
|
|
92
112
|
if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
|
|
113
|
+
const trans = clefTransposition(Number(match[3]), match[2] === "^");
|
|
93
114
|
return {
|
|
94
115
|
shape: clefInfo.shape,
|
|
95
116
|
line: clefInfo.line,
|
|
96
|
-
|
|
97
|
-
disPlace: match[2] === "^" ? "above" : "below",
|
|
117
|
+
trans,
|
|
98
118
|
};
|
|
99
119
|
};
|
|
100
120
|
|
|
101
121
|
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
122
|
+
// A mid-measure <clef> cannot carry att.transposition (that is a staff-level
|
|
123
|
+
// property on <staffDef>); a mid-piece change of transposition would require a
|
|
124
|
+
// new <staffDef>, which is out of scope here. So only shape/line are emitted.
|
|
102
125
|
const clefElementAttrs = (clefStr: string): string => {
|
|
103
126
|
const c = resolveClef(clefStr);
|
|
104
|
-
|
|
105
|
-
if (c.dis) attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
|
|
106
|
-
return attrs;
|
|
127
|
+
return `shape="${c.shape}" line="${c.line}"`;
|
|
107
128
|
};
|
|
108
129
|
|
|
109
|
-
// Attributes for a <staffDef> clef (clef.* namespace).
|
|
130
|
+
// Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
|
|
131
|
+
// (trans.diat / trans.semi) when the clef declares a transposition.
|
|
110
132
|
const staffDefClefAttrs = (clefStr: string): string => {
|
|
111
133
|
const c = resolveClef(clefStr);
|
|
112
134
|
let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
|
|
113
|
-
if (c.
|
|
135
|
+
if (c.trans) attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
|
|
114
136
|
return attrs;
|
|
115
137
|
};
|
|
116
138
|
|
|
117
139
|
|
|
140
|
+
|
|
118
141
|
// Lilylet duration division to MEI dur
|
|
119
142
|
// division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
|
|
120
143
|
const DURATIONS: Record<number, string> = {
|
|
@@ -1995,6 +2018,76 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
|
|
|
1995
2018
|
return partInfos;
|
|
1996
2019
|
};
|
|
1997
2020
|
|
|
2021
|
+
// MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
|
|
2022
|
+
const LAYOUT_SYMBOL: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
|
|
2023
|
+
|
|
2024
|
+
// Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
|
|
2025
|
+
const instrumentLabelXML = (
|
|
2026
|
+
instr: InstrumentName | undefined,
|
|
2027
|
+
indent: string,
|
|
2028
|
+
): string => {
|
|
2029
|
+
if (!instr) return "";
|
|
2030
|
+
let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
|
|
2031
|
+
if (instr.shortName !== undefined) {
|
|
2032
|
+
xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
|
|
2033
|
+
}
|
|
2034
|
+
return xml;
|
|
2035
|
+
};
|
|
2036
|
+
|
|
2037
|
+
// Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
|
|
2038
|
+
// `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
|
|
2039
|
+
// string for the matching global staff's <staffDef>. `instruments` maps a layout group
|
|
2040
|
+
// key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
|
|
2041
|
+
// names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
|
|
2042
|
+
// bar.thru reflects the group's conjunction (Solid).
|
|
2043
|
+
const layoutGroupToMEI = (
|
|
2044
|
+
group: StaffGroup,
|
|
2045
|
+
staffDefAttrs: string[],
|
|
2046
|
+
leafCounter: { i: number },
|
|
2047
|
+
indent: string,
|
|
2048
|
+
instruments: { [key: string]: InstrumentName },
|
|
2049
|
+
): string => {
|
|
2050
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
2051
|
+
const instr = group.key !== undefined ? instruments[group.key] : undefined;
|
|
2052
|
+
|
|
2053
|
+
// A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
|
|
2054
|
+
if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
|
|
2055
|
+
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
|
|
2059
|
+
const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
|
|
2060
|
+
let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
|
|
2061
|
+
// A multi-staff group's instrument name labels the whole group.
|
|
2062
|
+
if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ");
|
|
2063
|
+
if (isLeaf) {
|
|
2064
|
+
// A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
|
|
2065
|
+
// a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
|
|
2066
|
+
// label goes on the staffDef inside.
|
|
2067
|
+
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
|
|
2068
|
+
} else {
|
|
2069
|
+
for (const sub of group.subs || []) {
|
|
2070
|
+
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
xml += `${indent}</staffGrp>\n`;
|
|
2074
|
+
return xml;
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
// Emit a <staffDef> from its attribute string, with optional instrument <label> children.
|
|
2078
|
+
const staffDefWithLabel = (
|
|
2079
|
+
attrs: string | undefined,
|
|
2080
|
+
instr: InstrumentName | undefined,
|
|
2081
|
+
indent: string,
|
|
2082
|
+
): string => {
|
|
2083
|
+
if (attrs === undefined) return "";
|
|
2084
|
+
if (!instr) return `${indent}<staffDef ${attrs} />\n`;
|
|
2085
|
+
let xml = `${indent}<staffDef ${attrs}>\n`;
|
|
2086
|
+
xml += instrumentLabelXML(instr, indent + " ");
|
|
2087
|
+
xml += `${indent}</staffDef>\n`;
|
|
2088
|
+
return xml;
|
|
2089
|
+
};
|
|
2090
|
+
|
|
1998
2091
|
// Encode scoreDef with part groups
|
|
1999
2092
|
const encodeScoreDef = (
|
|
2000
2093
|
keySig: string,
|
|
@@ -2002,36 +2095,75 @@ const encodeScoreDef = (
|
|
|
2002
2095
|
timeDen: number,
|
|
2003
2096
|
partInfos: PartInfo[],
|
|
2004
2097
|
indent: string,
|
|
2005
|
-
meterSymbol?: 'common' | 'cut'
|
|
2098
|
+
meterSymbol?: 'common' | 'cut',
|
|
2099
|
+
stavesCode?: string,
|
|
2100
|
+
instruments: { [key: string]: InstrumentName } = {}
|
|
2006
2101
|
): string => {
|
|
2007
2102
|
const scoreDefId = generateId("scoredef");
|
|
2008
2103
|
|
|
2009
2104
|
// Build meter attributes
|
|
2010
2105
|
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
2011
2106
|
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
2107
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2108
|
+
// Flat ordered list of global staves (n + clef), in part/voice order.
|
|
2109
|
+
const flatStaves: { n: number; clef: Clef }[] = [];
|
|
2110
|
+
for (const info of partInfos) {
|
|
2111
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
2112
|
+
flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// If a [staves] layout is present and its leaf count matches the staves, drive
|
|
2117
|
+
// the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
|
|
2118
|
+
// the per-part grand-staff grouping.
|
|
2119
|
+
let layoutUsed = false;
|
|
2120
|
+
if (stavesCode) {
|
|
2121
|
+
const layout = parseStaffLayout(stavesCode);
|
|
2122
|
+
if (layout.stavesCount === flatStaves.length) {
|
|
2123
|
+
const staffDefAttrs = flatStaves.map(
|
|
2124
|
+
s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`
|
|
2125
|
+
);
|
|
2126
|
+
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments);
|
|
2127
|
+
layoutUsed = true;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (!layoutUsed) {
|
|
2132
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
2133
|
+
|
|
2134
|
+
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
2135
|
+
const info = partInfos[pi];
|
|
2136
|
+
|
|
2137
|
+
// If part has multiple staves (grand staff), wrap in staffGrp with brace.
|
|
2138
|
+
// Instrument key for a part follows staffLayout's groupKey: a single staff
|
|
2139
|
+
// number, or "first-last" for a grand staff.
|
|
2140
|
+
if (info.maxStaff > 1) {
|
|
2141
|
+
const first = info.staffOffset + 1;
|
|
2142
|
+
const last = info.staffOffset + info.maxStaff;
|
|
2143
|
+
const instr = instruments[`${first}-${last}`];
|
|
2144
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
2145
|
+
xml += instrumentLabelXML(instr, `${indent} `);
|
|
2146
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
2147
|
+
const globalStaff = info.staffOffset + ls;
|
|
2148
|
+
const clef = info.clefs[ls] || Clef.treble;
|
|
2149
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
2150
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
2151
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
|
|
2152
|
+
}
|
|
2153
|
+
xml += `${indent} </staffGrp>\n`;
|
|
2154
|
+
} else {
|
|
2155
|
+
// Single staff part
|
|
2156
|
+
const globalStaff = info.staffOffset + 1;
|
|
2157
|
+
const clef = info.clefs[1] || Clef.treble;
|
|
2158
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
2159
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
2160
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
|
|
2024
2161
|
}
|
|
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
2162
|
}
|
|
2163
|
+
|
|
2164
|
+
xml += `${indent} </staffGrp>\n`;
|
|
2032
2165
|
}
|
|
2033
2166
|
|
|
2034
|
-
xml += `${indent} </staffGrp>\n`;
|
|
2035
2167
|
xml += `${indent}</scoreDef>\n`;
|
|
2036
2168
|
return xml;
|
|
2037
2169
|
};
|
|
@@ -2397,7 +2529,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2397
2529
|
mei += `${indent}${indent}<body>\n`;
|
|
2398
2530
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
2399
2531
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
2400
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
2532
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
2401
2533
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
2402
2534
|
|
|
2403
2535
|
// Track tie state across measures for cross-measure ties
|
|
@@ -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 : {};
|
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
calculateDuration,
|
|
44
44
|
} from "./musicXmlUtils";
|
|
45
45
|
|
|
46
|
+
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
47
|
+
|
|
46
48
|
|
|
47
49
|
// === Constants and Reverse Mappings ===
|
|
48
50
|
|
|
@@ -135,6 +137,11 @@ const BARLINE_TO_XML: Record<string, { barStyle: string; repeat?: string }> = {
|
|
|
135
137
|
};
|
|
136
138
|
|
|
137
139
|
|
|
140
|
+
// MusicXML <group-symbol> value by StaffGroupType (Default → none). MusicXML's
|
|
141
|
+
// allowed values are brace | bracket | line | square | none — note that, unlike
|
|
142
|
+
// MEI (which uses "bracketsq"), MusicXML's square variant IS spelled "square".
|
|
143
|
+
const GROUP_SYMBOLS_XML: (string | null)[] = [null, "brace", "bracket", "square"];
|
|
144
|
+
|
|
138
145
|
// === XML Helper Functions ===
|
|
139
146
|
|
|
140
147
|
const escapeXml = (text: string): string => {
|
|
@@ -753,6 +760,7 @@ const encodeMeasure = (
|
|
|
753
760
|
break;
|
|
754
761
|
}
|
|
755
762
|
|
|
763
|
+
case 'times':
|
|
756
764
|
case 'tuplet': {
|
|
757
765
|
const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest') as (NoteEvent | RestEvent)[];
|
|
758
766
|
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
@@ -847,12 +855,122 @@ const encodeMetadata = (metadata: Metadata, level: number): string => {
|
|
|
847
855
|
xml += `${indent(level + 2)}<encoding-date>${new Date().toISOString().split('T')[0]}</encoding-date>\n`;
|
|
848
856
|
xml += `${indent(level + 1)}</encoding>\n`;
|
|
849
857
|
|
|
858
|
+
// Preserve the raw staff-layout string for a lossless round-trip. MusicXML has
|
|
859
|
+
// no native carrier for it (its <part-group> only expresses grouping, not the
|
|
860
|
+
// conjunction/anonymous-id detail), so we stash the verbatim code here.
|
|
861
|
+
if (metadata.staves) {
|
|
862
|
+
xml += `${indent(level + 1)}<miscellaneous>\n`;
|
|
863
|
+
xml += `${indent(level + 2)}<miscellaneous-field name="lilylet-staves">${escapeXml(metadata.staves)}</miscellaneous-field>\n`;
|
|
864
|
+
xml += `${indent(level + 1)}</miscellaneous>\n`;
|
|
865
|
+
}
|
|
866
|
+
|
|
850
867
|
xml += `${indent(level)}</identification>\n`;
|
|
851
868
|
|
|
852
869
|
return xml;
|
|
853
870
|
};
|
|
854
871
|
|
|
855
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Build <part-group> start/stop brackets from a parsed staff-layout, keyed by the
|
|
875
|
+
* part index they wrap around.
|
|
876
|
+
*
|
|
877
|
+
* The layout is staff-leaf (one leaf per staff); MusicXML <part-group> brackets group
|
|
878
|
+
* *parts*. We map each part to the consecutive run of staff-leaves it owns (a grand-staff
|
|
879
|
+
* part owns `maxStaff` leaves), then translate every layout group whose leaf-span aligns
|
|
880
|
+
* with whole-part boundaries into a part-group. Groups that fall entirely inside one part
|
|
881
|
+
* (e.g. the brace over a single grand-staff part) are intrinsic to that part's <staves>
|
|
882
|
+
* and are skipped here. Returns { starts, stops } maps: partIndex → XML snippets.
|
|
883
|
+
*/
|
|
884
|
+
const buildPartGroups = (
|
|
885
|
+
stavesCode: string,
|
|
886
|
+
staffCountPerPart: number[],
|
|
887
|
+
level: number,
|
|
888
|
+
): { starts: Map<number, string>; stops: Map<number, string> } => {
|
|
889
|
+
const starts = new Map<number, string>();
|
|
890
|
+
const stops = new Map<number, string>();
|
|
891
|
+
|
|
892
|
+
const layout = parseStaffLayout(stavesCode);
|
|
893
|
+
const totalLeaves = staffCountPerPart.reduce((a, b) => a + b, 0);
|
|
894
|
+
if (layout.stavesCount !== totalLeaves) {
|
|
895
|
+
// Layout/parts mismatch — skip grouping rather than emit something wrong.
|
|
896
|
+
return { starts, stops };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// leaf index → part index, and the [firstLeaf, lastLeaf] each part spans.
|
|
900
|
+
const partFirstLeaf: number[] = [];
|
|
901
|
+
const partLastLeaf: number[] = [];
|
|
902
|
+
let leaf = 0;
|
|
903
|
+
for (let pi = 0; pi < staffCountPerPart.length; pi++) {
|
|
904
|
+
partFirstLeaf[pi] = leaf;
|
|
905
|
+
leaf += staffCountPerPart[pi];
|
|
906
|
+
partLastLeaf[pi] = leaf - 1;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const leafToPart = (leafIdx: number): number => {
|
|
910
|
+
for (let pi = 0; pi < staffCountPerPart.length; pi++) {
|
|
911
|
+
if (leafIdx >= partFirstLeaf[pi] && leafIdx <= partLastLeaf[pi]) return pi;
|
|
912
|
+
}
|
|
913
|
+
return -1;
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
let groupNumber = 0;
|
|
917
|
+
const leafCounter = { i: 0 };
|
|
918
|
+
|
|
919
|
+
const walk = (group: StaffGroup): void => {
|
|
920
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
921
|
+
const symbol = GROUP_SYMBOLS_XML[group.type];
|
|
922
|
+
|
|
923
|
+
if (isLeaf) {
|
|
924
|
+
// A leaf may itself carry a bracket (e.g. <b>): a one-staff part-group.
|
|
925
|
+
const li = leafCounter.i++;
|
|
926
|
+
if (symbol) {
|
|
927
|
+
const pi = leafToPart(li);
|
|
928
|
+
if (pi >= 0 && partFirstLeaf[pi] === li && partLastLeaf[pi] === li) {
|
|
929
|
+
emit(pi, pi, symbol, group.bar);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Span the leaves covered by this group's subtree, then recurse.
|
|
936
|
+
const firstLeaf = leafCounter.i;
|
|
937
|
+
for (const sub of group.subs || []) walk(sub);
|
|
938
|
+
const lastLeaf = leafCounter.i - 1;
|
|
939
|
+
|
|
940
|
+
if (symbol) {
|
|
941
|
+
const startPart = leafToPart(firstLeaf);
|
|
942
|
+
const endPart = leafToPart(lastLeaf);
|
|
943
|
+
// Only emit when the span aligns with whole-part boundaries AND wraps >1 part
|
|
944
|
+
// (a group inside a single part is the part's own grand staff, not a part-group).
|
|
945
|
+
if (
|
|
946
|
+
startPart >= 0 && endPart >= 0 && startPart !== endPart &&
|
|
947
|
+
partFirstLeaf[startPart] === firstLeaf && partLastLeaf[endPart] === lastLeaf
|
|
948
|
+
) {
|
|
949
|
+
emit(startPart, endPart, symbol, group.bar);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const emit = (startPart: number, endPart: number, symbol: string, bar?: number): void => {
|
|
955
|
+
const num = ++groupNumber;
|
|
956
|
+
const barLine = (bar ?? 0) > 1;
|
|
957
|
+
let s = `${indent(level)}<part-group type="start" number="${num}">\n`;
|
|
958
|
+
s += `${indent(level + 1)}<group-symbol>${symbol}</group-symbol>\n`;
|
|
959
|
+
s += `${indent(level + 1)}<group-barline>${barLine ? 'yes' : 'no'}</group-barline>\n`;
|
|
960
|
+
s += `${indent(level)}</part-group>\n`;
|
|
961
|
+
starts.set(startPart, (starts.get(startPart) || '') + s);
|
|
962
|
+
|
|
963
|
+
const e = `${indent(level)}<part-group type="stop" number="${num}"/>\n`;
|
|
964
|
+
// Stops are emitted after the end part; inner groups must close before outer ones,
|
|
965
|
+
// so prepend (deepest group, emitted last, closes first).
|
|
966
|
+
stops.set(endPart, e + (stops.get(endPart) || ''));
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
walk(layout.group);
|
|
970
|
+
return { starts, stops };
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
|
|
856
974
|
/**
|
|
857
975
|
* Encode complete LilyletDoc to MusicXML
|
|
858
976
|
*/
|
|
@@ -869,15 +987,31 @@ export const encode = (doc: LilyletDoc): string => {
|
|
|
869
987
|
// Determine number of parts from first measure
|
|
870
988
|
const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
|
|
871
989
|
|
|
990
|
+
// Staff-layout → <part-group> brackets/braces (if a [staves] layout is present and
|
|
991
|
+
// its staff count matches the parts' total staves).
|
|
992
|
+
let partGroups: { starts: Map<number, string>; stops: Map<number, string> } = {
|
|
993
|
+
starts: new Map(), stops: new Map(),
|
|
994
|
+
};
|
|
995
|
+
if (doc.metadata?.staves && doc.measures.length > 0) {
|
|
996
|
+
const staffCountPerPart = doc.measures[0].parts.map(part => {
|
|
997
|
+
let maxStaff = 1;
|
|
998
|
+
for (const voice of part.voices) maxStaff = Math.max(maxStaff, voice.staff || 1);
|
|
999
|
+
return maxStaff;
|
|
1000
|
+
});
|
|
1001
|
+
partGroups = buildPartGroups(doc.metadata.staves, staffCountPerPart, 2);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
872
1004
|
// Part list
|
|
873
1005
|
xml += `${indent(1)}<part-list>\n`;
|
|
874
1006
|
for (let pi = 0; pi < numParts; pi++) {
|
|
875
1007
|
const partId = `P${pi + 1}`;
|
|
876
1008
|
const partName = doc.measures[0]?.parts[pi]?.name
|
|
877
1009
|
|| (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
|
|
1010
|
+
xml += partGroups.starts.get(pi) || '';
|
|
878
1011
|
xml += `${indent(2)}<score-part id="${partId}">\n`;
|
|
879
1012
|
xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
|
|
880
1013
|
xml += `${indent(2)}</score-part>\n`;
|
|
1014
|
+
xml += partGroups.stops.get(pi) || '';
|
|
881
1015
|
}
|
|
882
1016
|
xml += `${indent(1)}</part-list>\n`;
|
|
883
1017
|
|
|
@@ -278,13 +278,15 @@ const serializeMarks = (marks: Mark[]): string => {
|
|
|
278
278
|
const serializeNoteEvent = (
|
|
279
279
|
event: NoteEvent,
|
|
280
280
|
env: PitchEnv,
|
|
281
|
-
prevDuration?: Duration
|
|
281
|
+
prevDuration?: Duration,
|
|
282
|
+
suppressGracePrefix: boolean = false
|
|
282
283
|
): { str: string; newEnv: PitchEnv } => {
|
|
283
284
|
const parts: string[] = [];
|
|
284
285
|
let currentEnv = env;
|
|
285
286
|
|
|
286
|
-
// Grace note prefix
|
|
287
|
-
|
|
287
|
+
// Grace note prefix. When the caller groups consecutive grace notes into a single
|
|
288
|
+
// scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
|
|
289
|
+
if (event.grace && !suppressGracePrefix) {
|
|
288
290
|
parts.push('\\grace ');
|
|
289
291
|
}
|
|
290
292
|
|
|
@@ -564,11 +566,12 @@ const serializeBarlineEvent = (event: BarlineEvent): string => {
|
|
|
564
566
|
const serializeEvent = (
|
|
565
567
|
event: Event,
|
|
566
568
|
env: PitchEnv,
|
|
567
|
-
prevDuration?: Duration
|
|
569
|
+
prevDuration?: Duration,
|
|
570
|
+
suppressGracePrefix: boolean = false
|
|
568
571
|
): { str: string; newEnv: PitchEnv } => {
|
|
569
572
|
switch (event.type) {
|
|
570
573
|
case 'note':
|
|
571
|
-
return serializeNoteEvent(event as NoteEvent, env, prevDuration);
|
|
574
|
+
return serializeNoteEvent(event as NoteEvent, env, prevDuration, suppressGracePrefix);
|
|
572
575
|
case 'rest':
|
|
573
576
|
return serializeRestEvent(event as RestEvent, env, prevDuration);
|
|
574
577
|
case 'context':
|
|
@@ -716,6 +719,7 @@ const serializeVoice = (
|
|
|
716
719
|
|
|
717
720
|
let activeStaff = effectiveInitialStaff;
|
|
718
721
|
let activeStemDir: StemDirection | undefined;
|
|
722
|
+
let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
|
|
719
723
|
|
|
720
724
|
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
721
725
|
const event = voice.events[eventIdx];
|
|
@@ -781,7 +785,19 @@ const serializeVoice = (
|
|
|
781
785
|
}
|
|
782
786
|
}
|
|
783
787
|
|
|
784
|
-
const
|
|
788
|
+
const isGraceNote = event.type === 'note' && !!(event as NoteEvent).grace;
|
|
789
|
+
|
|
790
|
+
// Group consecutive grace notes into one scoped \grace { ... } instead of
|
|
791
|
+
// emitting a separate \grace prefix per note.
|
|
792
|
+
if (isGraceNote && !graceGroupOpen) {
|
|
793
|
+
parts.push('\\grace {');
|
|
794
|
+
graceGroupOpen = true;
|
|
795
|
+
} else if (!isGraceNote && graceGroupOpen) {
|
|
796
|
+
parts.push('}');
|
|
797
|
+
graceGroupOpen = false;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
|
|
785
801
|
pitchEnv = newEnv;
|
|
786
802
|
|
|
787
803
|
if (eventStr) {
|
|
@@ -805,6 +821,11 @@ const serializeVoice = (
|
|
|
805
821
|
}
|
|
806
822
|
}
|
|
807
823
|
|
|
824
|
+
// Close a grace group left open at the end of the voice (unusual but possible).
|
|
825
|
+
if (graceGroupOpen) {
|
|
826
|
+
parts.push('}');
|
|
827
|
+
}
|
|
828
|
+
|
|
808
829
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
809
830
|
};
|
|
810
831
|
|
|
@@ -930,6 +951,19 @@ const serializeMetadata = (metadata: Metadata): string => {
|
|
|
930
951
|
if (metadata.instrument) {
|
|
931
952
|
lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
|
|
932
953
|
}
|
|
954
|
+
if (metadata.staves) {
|
|
955
|
+
lines.push('[staves "' + escapeString(metadata.staves) + '"]');
|
|
956
|
+
}
|
|
957
|
+
if (metadata.instruments) {
|
|
958
|
+
for (const [key, instr] of Object.entries(metadata.instruments)) {
|
|
959
|
+
let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
|
|
960
|
+
if (instr.shortName !== undefined) {
|
|
961
|
+
line += ' "' + escapeString(instr.shortName) + '"';
|
|
962
|
+
}
|
|
963
|
+
line += ']';
|
|
964
|
+
lines.push(line);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
933
967
|
if (metadata.autoBeam) {
|
|
934
968
|
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
935
969
|
}
|