@k-l-lambda/lilylet 0.1.69 → 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 +4 -1
- package/lib/lilylet/abcDecoder.js +216 -1
- 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 +13 -0
- 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 +1 -1
- package/source/abc/abc.ts +1 -0
- package/source/abc/grammar.jison.js +4 -1
- package/source/lilylet/abcDecoder.ts +224 -1
- 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 +13 -0
- package/source/lilylet/staffLayout.ts +281 -0
- package/source/lilylet/types.ts +10 -0
package/lib/lilylet/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
2
|
export * from "./parser";
|
|
3
3
|
export * from "./serializer";
|
|
4
|
+
export * from "./staffLayout";
|
|
4
5
|
import * as meiEncoder from "./meiEncoder";
|
|
5
6
|
import * as musicXmlDecoder from "./musicXmlDecoder";
|
|
6
7
|
import * as lilypondEncoder from "./lilypondEncoder";
|
package/lib/lilylet/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export * from "./parser.js";
|
|
3
3
|
export * from "./serializer.js";
|
|
4
|
+
export * from "./staffLayout.js";
|
|
4
5
|
import * as meiEncoder from "./meiEncoder.js";
|
|
5
6
|
import * as musicXmlDecoder from "./musicXmlDecoder.js";
|
|
6
7
|
import * as lilypondEncoder from "./lilypondEncoder.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
|
+
import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
|
|
2
3
|
// MEI key signatures: positive = sharps, negative = flats
|
|
3
4
|
const KEY_SIGS = {
|
|
4
5
|
0: "0",
|
|
@@ -44,38 +45,55 @@ const CLEF_SHAPES = {
|
|
|
44
45
|
F: { shape: "F", line: 4 },
|
|
45
46
|
C: { shape: "C", line: 3 },
|
|
46
47
|
};
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
48
|
+
// Semitone offsets of the major/perfect intervals within one diatonic octave,
|
|
49
|
+
// indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
|
|
50
|
+
const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
51
|
+
// Convert a LilyPond clef interval-number suffix into an MEI written→sounding
|
|
52
|
+
// transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
|
|
53
|
+
// 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
|
|
54
|
+
// raises it. Returns { diat, semi } where diat is the diatonic step shift
|
|
55
|
+
// (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
|
|
56
|
+
// extended octave-wise for compound intervals.
|
|
57
|
+
const clefTransposition = (intervalNumber, up) => {
|
|
58
|
+
const k = intervalNumber - 1; // diatonic steps
|
|
59
|
+
const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
|
|
60
|
+
const sign = up ? 1 : -1;
|
|
61
|
+
return { diat: sign * k, semi: sign * semis };
|
|
62
|
+
};
|
|
63
|
+
// Resolve a clef string into MEI shape/line plus optional written→sounding
|
|
64
|
+
// transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
|
|
65
|
+
// clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
|
|
66
|
+
// "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
|
|
67
|
+
// displacement (8|15|22), so all clef transposition — octaves included — is
|
|
68
|
+
// encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
|
|
52
69
|
const resolveClef = (clefStr) => {
|
|
53
|
-
const match = clefStr.match(/^(.*?)([_^])(
|
|
70
|
+
const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
|
|
54
71
|
const base = match ? match[1] : clefStr;
|
|
55
72
|
const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
|
|
56
73
|
if (!match)
|
|
57
74
|
return { shape: clefInfo.shape, line: clefInfo.line };
|
|
75
|
+
const trans = clefTransposition(Number(match[3]), match[2] === "^");
|
|
58
76
|
return {
|
|
59
77
|
shape: clefInfo.shape,
|
|
60
78
|
line: clefInfo.line,
|
|
61
|
-
|
|
62
|
-
disPlace: match[2] === "^" ? "above" : "below",
|
|
79
|
+
trans,
|
|
63
80
|
};
|
|
64
81
|
};
|
|
65
82
|
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
83
|
+
// A mid-measure <clef> cannot carry att.transposition (that is a staff-level
|
|
84
|
+
// property on <staffDef>); a mid-piece change of transposition would require a
|
|
85
|
+
// new <staffDef>, which is out of scope here. So only shape/line are emitted.
|
|
66
86
|
const clefElementAttrs = (clefStr) => {
|
|
67
87
|
const c = resolveClef(clefStr);
|
|
68
|
-
|
|
69
|
-
if (c.dis)
|
|
70
|
-
attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
|
|
71
|
-
return attrs;
|
|
88
|
+
return `shape="${c.shape}" line="${c.line}"`;
|
|
72
89
|
};
|
|
73
|
-
// Attributes for a <staffDef> clef (clef.* namespace).
|
|
90
|
+
// Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
|
|
91
|
+
// (trans.diat / trans.semi) when the clef declares a transposition.
|
|
74
92
|
const staffDefClefAttrs = (clefStr) => {
|
|
75
93
|
const c = resolveClef(clefStr);
|
|
76
94
|
let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
|
|
77
|
-
if (c.
|
|
78
|
-
attrs += `
|
|
95
|
+
if (c.trans)
|
|
96
|
+
attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
|
|
79
97
|
return attrs;
|
|
80
98
|
};
|
|
81
99
|
// Lilylet duration division to MEI dur
|
|
@@ -1622,33 +1640,120 @@ const analyzePartStructure = (doc) => {
|
|
|
1622
1640
|
}
|
|
1623
1641
|
return partInfos;
|
|
1624
1642
|
};
|
|
1643
|
+
// MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
|
|
1644
|
+
const LAYOUT_SYMBOL = [null, "brace", "bracket", "bracketsq"];
|
|
1645
|
+
// Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
|
|
1646
|
+
const instrumentLabelXML = (instr, indent) => {
|
|
1647
|
+
if (!instr)
|
|
1648
|
+
return "";
|
|
1649
|
+
let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
|
|
1650
|
+
if (instr.shortName !== undefined) {
|
|
1651
|
+
xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
|
|
1652
|
+
}
|
|
1653
|
+
return xml;
|
|
1654
|
+
};
|
|
1655
|
+
// Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
|
|
1656
|
+
// `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
|
|
1657
|
+
// string for the matching global staff's <staffDef>. `instruments` maps a layout group
|
|
1658
|
+
// key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
|
|
1659
|
+
// names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
|
|
1660
|
+
// bar.thru reflects the group's conjunction (Solid).
|
|
1661
|
+
const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments) => {
|
|
1662
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
1663
|
+
const instr = group.key !== undefined ? instruments[group.key] : undefined;
|
|
1664
|
+
// A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
|
|
1665
|
+
if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
|
|
1666
|
+
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
|
|
1667
|
+
}
|
|
1668
|
+
const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
|
|
1669
|
+
const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
|
|
1670
|
+
let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
|
|
1671
|
+
// A multi-staff group's instrument name labels the whole group.
|
|
1672
|
+
if (!isLeaf)
|
|
1673
|
+
xml += instrumentLabelXML(instr, indent + " ");
|
|
1674
|
+
if (isLeaf) {
|
|
1675
|
+
// A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
|
|
1676
|
+
// a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
|
|
1677
|
+
// label goes on the staffDef inside.
|
|
1678
|
+
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
for (const sub of group.subs || []) {
|
|
1682
|
+
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
xml += `${indent}</staffGrp>\n`;
|
|
1686
|
+
return xml;
|
|
1687
|
+
};
|
|
1688
|
+
// Emit a <staffDef> from its attribute string, with optional instrument <label> children.
|
|
1689
|
+
const staffDefWithLabel = (attrs, instr, indent) => {
|
|
1690
|
+
if (attrs === undefined)
|
|
1691
|
+
return "";
|
|
1692
|
+
if (!instr)
|
|
1693
|
+
return `${indent}<staffDef ${attrs} />\n`;
|
|
1694
|
+
let xml = `${indent}<staffDef ${attrs}>\n`;
|
|
1695
|
+
xml += instrumentLabelXML(instr, indent + " ");
|
|
1696
|
+
xml += `${indent}</staffDef>\n`;
|
|
1697
|
+
return xml;
|
|
1698
|
+
};
|
|
1625
1699
|
// Encode scoreDef with part groups
|
|
1626
|
-
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
|
|
1700
|
+
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol, stavesCode, instruments = {}) => {
|
|
1627
1701
|
const scoreDefId = generateId("scoredef");
|
|
1628
1702
|
// Build meter attributes
|
|
1629
1703
|
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1630
1704
|
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
1637
|
-
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1638
|
-
const globalStaff = info.staffOffset + ls;
|
|
1639
|
-
const clef = info.clefs[ls] || Clef.treble;
|
|
1640
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1641
|
-
}
|
|
1642
|
-
xml += `${indent} </staffGrp>\n`;
|
|
1705
|
+
// Flat ordered list of global staves (n + clef), in part/voice order.
|
|
1706
|
+
const flatStaves = [];
|
|
1707
|
+
for (const info of partInfos) {
|
|
1708
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1709
|
+
flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
|
|
1643
1710
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1711
|
+
}
|
|
1712
|
+
// If a [staves] layout is present and its leaf count matches the staves, drive
|
|
1713
|
+
// the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
|
|
1714
|
+
// the per-part grand-staff grouping.
|
|
1715
|
+
let layoutUsed = false;
|
|
1716
|
+
if (stavesCode) {
|
|
1717
|
+
const layout = parseStaffLayout(stavesCode);
|
|
1718
|
+
if (layout.stavesCount === flatStaves.length) {
|
|
1719
|
+
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);
|
|
1721
|
+
layoutUsed = true;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
if (!layoutUsed) {
|
|
1725
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1726
|
+
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
1727
|
+
const info = partInfos[pi];
|
|
1728
|
+
// If part has multiple staves (grand staff), wrap in staffGrp with brace.
|
|
1729
|
+
// Instrument key for a part follows staffLayout's groupKey: a single staff
|
|
1730
|
+
// number, or "first-last" for a grand staff.
|
|
1731
|
+
if (info.maxStaff > 1) {
|
|
1732
|
+
const first = info.staffOffset + 1;
|
|
1733
|
+
const last = info.staffOffset + info.maxStaff;
|
|
1734
|
+
const instr = instruments[`${first}-${last}`];
|
|
1735
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
1736
|
+
xml += instrumentLabelXML(instr, `${indent} `);
|
|
1737
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1738
|
+
const globalStaff = info.staffOffset + ls;
|
|
1739
|
+
const clef = info.clefs[ls] || Clef.treble;
|
|
1740
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
1741
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
1742
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
|
|
1743
|
+
}
|
|
1744
|
+
xml += `${indent} </staffGrp>\n`;
|
|
1745
|
+
}
|
|
1746
|
+
else {
|
|
1747
|
+
// Single staff part
|
|
1748
|
+
const globalStaff = info.staffOffset + 1;
|
|
1749
|
+
const clef = info.clefs[1] || Clef.treble;
|
|
1750
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
1751
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
1752
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
|
|
1753
|
+
}
|
|
1649
1754
|
}
|
|
1755
|
+
xml += `${indent} </staffGrp>\n`;
|
|
1650
1756
|
}
|
|
1651
|
-
xml += `${indent} </staffGrp>\n`;
|
|
1652
1757
|
xml += `${indent}</scoreDef>\n`;
|
|
1653
1758
|
return xml;
|
|
1654
1759
|
};
|
|
@@ -1983,7 +2088,7 @@ const encode = (doc, options = {}) => {
|
|
|
1983
2088
|
mei += `${indent}${indent}<body>\n`;
|
|
1984
2089
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1985
2090
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1986
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
2091
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
1987
2092
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1988
2093
|
// Track tie state across measures for cross-measure ties
|
|
1989
2094
|
const tieState = {};
|
|
@@ -575,6 +575,15 @@ const parseMetadata = (doc) => {
|
|
|
575
575
|
}
|
|
576
576
|
}
|
|
577
577
|
}
|
|
578
|
+
// Staff layout: recover the raw [staves] string stashed at encode time.
|
|
579
|
+
const miscFields = getElements(identificationEl, 'miscellaneous-field');
|
|
580
|
+
for (const field of miscFields) {
|
|
581
|
+
if (getAttribute(field, 'name') === 'lilylet-staves') {
|
|
582
|
+
const code = field.textContent?.trim();
|
|
583
|
+
if (code)
|
|
584
|
+
metadata.staves = code;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
578
587
|
}
|
|
579
588
|
return Object.keys(metadata).length > 0 ? metadata : {};
|
|
580
589
|
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { StemDirection, Accidental, HairpinType, PedalType, } from "./types.js";
|
|
8
8
|
import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
|
|
9
|
+
import { parseStaffLayout } from "./staffLayout.js";
|
|
9
10
|
// === Constants and Reverse Mappings ===
|
|
10
11
|
// Phonet to MusicXML step
|
|
11
12
|
const PHONET_TO_STEP = {
|
|
@@ -87,6 +88,10 @@ const BARLINE_TO_XML = {
|
|
|
87
88
|
':|.': { barStyle: 'light-heavy', repeat: 'backward' },
|
|
88
89
|
':..:': { barStyle: 'light-heavy', repeat: 'backward' }, // Will need special handling
|
|
89
90
|
};
|
|
91
|
+
// MusicXML <group-symbol> value by StaffGroupType (Default → none). MusicXML's
|
|
92
|
+
// allowed values are brace | bracket | line | square | none — note that, unlike
|
|
93
|
+
// MEI (which uses "bracketsq"), MusicXML's square variant IS spelled "square".
|
|
94
|
+
const GROUP_SYMBOLS_XML = [null, "brace", "bracket", "square"];
|
|
90
95
|
// === XML Helper Functions ===
|
|
91
96
|
const escapeXml = (text) => {
|
|
92
97
|
return text
|
|
@@ -566,6 +571,7 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
|
|
|
566
571
|
// Other context changes are handled in attributes
|
|
567
572
|
break;
|
|
568
573
|
}
|
|
574
|
+
case 'times':
|
|
569
575
|
case 'tuplet': {
|
|
570
576
|
const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest');
|
|
571
577
|
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
@@ -649,9 +655,101 @@ const encodeMetadata = (metadata, level) => {
|
|
|
649
655
|
xml += `${indent(level + 2)}<software>Lilylet</software>\n`;
|
|
650
656
|
xml += `${indent(level + 2)}<encoding-date>${new Date().toISOString().split('T')[0]}</encoding-date>\n`;
|
|
651
657
|
xml += `${indent(level + 1)}</encoding>\n`;
|
|
658
|
+
// Preserve the raw staff-layout string for a lossless round-trip. MusicXML has
|
|
659
|
+
// no native carrier for it (its <part-group> only expresses grouping, not the
|
|
660
|
+
// conjunction/anonymous-id detail), so we stash the verbatim code here.
|
|
661
|
+
if (metadata.staves) {
|
|
662
|
+
xml += `${indent(level + 1)}<miscellaneous>\n`;
|
|
663
|
+
xml += `${indent(level + 2)}<miscellaneous-field name="lilylet-staves">${escapeXml(metadata.staves)}</miscellaneous-field>\n`;
|
|
664
|
+
xml += `${indent(level + 1)}</miscellaneous>\n`;
|
|
665
|
+
}
|
|
652
666
|
xml += `${indent(level)}</identification>\n`;
|
|
653
667
|
return xml;
|
|
654
668
|
};
|
|
669
|
+
/**
|
|
670
|
+
* Build <part-group> start/stop brackets from a parsed staff-layout, keyed by the
|
|
671
|
+
* part index they wrap around.
|
|
672
|
+
*
|
|
673
|
+
* The layout is staff-leaf (one leaf per staff); MusicXML <part-group> brackets group
|
|
674
|
+
* *parts*. We map each part to the consecutive run of staff-leaves it owns (a grand-staff
|
|
675
|
+
* part owns `maxStaff` leaves), then translate every layout group whose leaf-span aligns
|
|
676
|
+
* with whole-part boundaries into a part-group. Groups that fall entirely inside one part
|
|
677
|
+
* (e.g. the brace over a single grand-staff part) are intrinsic to that part's <staves>
|
|
678
|
+
* and are skipped here. Returns { starts, stops } maps: partIndex → XML snippets.
|
|
679
|
+
*/
|
|
680
|
+
const buildPartGroups = (stavesCode, staffCountPerPart, level) => {
|
|
681
|
+
const starts = new Map();
|
|
682
|
+
const stops = new Map();
|
|
683
|
+
const layout = parseStaffLayout(stavesCode);
|
|
684
|
+
const totalLeaves = staffCountPerPart.reduce((a, b) => a + b, 0);
|
|
685
|
+
if (layout.stavesCount !== totalLeaves) {
|
|
686
|
+
// Layout/parts mismatch — skip grouping rather than emit something wrong.
|
|
687
|
+
return { starts, stops };
|
|
688
|
+
}
|
|
689
|
+
// leaf index → part index, and the [firstLeaf, lastLeaf] each part spans.
|
|
690
|
+
const partFirstLeaf = [];
|
|
691
|
+
const partLastLeaf = [];
|
|
692
|
+
let leaf = 0;
|
|
693
|
+
for (let pi = 0; pi < staffCountPerPart.length; pi++) {
|
|
694
|
+
partFirstLeaf[pi] = leaf;
|
|
695
|
+
leaf += staffCountPerPart[pi];
|
|
696
|
+
partLastLeaf[pi] = leaf - 1;
|
|
697
|
+
}
|
|
698
|
+
const leafToPart = (leafIdx) => {
|
|
699
|
+
for (let pi = 0; pi < staffCountPerPart.length; pi++) {
|
|
700
|
+
if (leafIdx >= partFirstLeaf[pi] && leafIdx <= partLastLeaf[pi])
|
|
701
|
+
return pi;
|
|
702
|
+
}
|
|
703
|
+
return -1;
|
|
704
|
+
};
|
|
705
|
+
let groupNumber = 0;
|
|
706
|
+
const leafCounter = { i: 0 };
|
|
707
|
+
const walk = (group) => {
|
|
708
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
709
|
+
const symbol = GROUP_SYMBOLS_XML[group.type];
|
|
710
|
+
if (isLeaf) {
|
|
711
|
+
// A leaf may itself carry a bracket (e.g. <b>): a one-staff part-group.
|
|
712
|
+
const li = leafCounter.i++;
|
|
713
|
+
if (symbol) {
|
|
714
|
+
const pi = leafToPart(li);
|
|
715
|
+
if (pi >= 0 && partFirstLeaf[pi] === li && partLastLeaf[pi] === li) {
|
|
716
|
+
emit(pi, pi, symbol, group.bar);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
// Span the leaves covered by this group's subtree, then recurse.
|
|
722
|
+
const firstLeaf = leafCounter.i;
|
|
723
|
+
for (const sub of group.subs || [])
|
|
724
|
+
walk(sub);
|
|
725
|
+
const lastLeaf = leafCounter.i - 1;
|
|
726
|
+
if (symbol) {
|
|
727
|
+
const startPart = leafToPart(firstLeaf);
|
|
728
|
+
const endPart = leafToPart(lastLeaf);
|
|
729
|
+
// Only emit when the span aligns with whole-part boundaries AND wraps >1 part
|
|
730
|
+
// (a group inside a single part is the part's own grand staff, not a part-group).
|
|
731
|
+
if (startPart >= 0 && endPart >= 0 && startPart !== endPart &&
|
|
732
|
+
partFirstLeaf[startPart] === firstLeaf && partLastLeaf[endPart] === lastLeaf) {
|
|
733
|
+
emit(startPart, endPart, symbol, group.bar);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
const emit = (startPart, endPart, symbol, bar) => {
|
|
738
|
+
const num = ++groupNumber;
|
|
739
|
+
const barLine = (bar ?? 0) > 1;
|
|
740
|
+
let s = `${indent(level)}<part-group type="start" number="${num}">\n`;
|
|
741
|
+
s += `${indent(level + 1)}<group-symbol>${symbol}</group-symbol>\n`;
|
|
742
|
+
s += `${indent(level + 1)}<group-barline>${barLine ? 'yes' : 'no'}</group-barline>\n`;
|
|
743
|
+
s += `${indent(level)}</part-group>\n`;
|
|
744
|
+
starts.set(startPart, (starts.get(startPart) || '') + s);
|
|
745
|
+
const e = `${indent(level)}<part-group type="stop" number="${num}"/>\n`;
|
|
746
|
+
// Stops are emitted after the end part; inner groups must close before outer ones,
|
|
747
|
+
// so prepend (deepest group, emitted last, closes first).
|
|
748
|
+
stops.set(endPart, e + (stops.get(endPart) || ''));
|
|
749
|
+
};
|
|
750
|
+
walk(layout.group);
|
|
751
|
+
return { starts, stops };
|
|
752
|
+
};
|
|
655
753
|
/**
|
|
656
754
|
* Encode complete LilyletDoc to MusicXML
|
|
657
755
|
*/
|
|
@@ -665,15 +763,31 @@ export const encode = (doc) => {
|
|
|
665
763
|
}
|
|
666
764
|
// Determine number of parts from first measure
|
|
667
765
|
const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
|
|
766
|
+
// Staff-layout → <part-group> brackets/braces (if a [staves] layout is present and
|
|
767
|
+
// its staff count matches the parts' total staves).
|
|
768
|
+
let partGroups = {
|
|
769
|
+
starts: new Map(), stops: new Map(),
|
|
770
|
+
};
|
|
771
|
+
if (doc.metadata?.staves && doc.measures.length > 0) {
|
|
772
|
+
const staffCountPerPart = doc.measures[0].parts.map(part => {
|
|
773
|
+
let maxStaff = 1;
|
|
774
|
+
for (const voice of part.voices)
|
|
775
|
+
maxStaff = Math.max(maxStaff, voice.staff || 1);
|
|
776
|
+
return maxStaff;
|
|
777
|
+
});
|
|
778
|
+
partGroups = buildPartGroups(doc.metadata.staves, staffCountPerPart, 2);
|
|
779
|
+
}
|
|
668
780
|
// Part list
|
|
669
781
|
xml += `${indent(1)}<part-list>\n`;
|
|
670
782
|
for (let pi = 0; pi < numParts; pi++) {
|
|
671
783
|
const partId = `P${pi + 1}`;
|
|
672
784
|
const partName = doc.measures[0]?.parts[pi]?.name
|
|
673
785
|
|| (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
|
|
786
|
+
xml += partGroups.starts.get(pi) || '';
|
|
674
787
|
xml += `${indent(2)}<score-part id="${partId}">\n`;
|
|
675
788
|
xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
|
|
676
789
|
xml += `${indent(2)}</score-part>\n`;
|
|
790
|
+
xml += partGroups.stops.get(pi) || '';
|
|
677
791
|
}
|
|
678
792
|
xml += `${indent(1)}</part-list>\n`;
|
|
679
793
|
// Encode each part
|
|
@@ -773,6 +773,19 @@ const serializeMetadata = (metadata) => {
|
|
|
773
773
|
if (metadata.instrument) {
|
|
774
774
|
lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
|
|
775
775
|
}
|
|
776
|
+
if (metadata.staves) {
|
|
777
|
+
lines.push('[staves "' + escapeString(metadata.staves) + '"]');
|
|
778
|
+
}
|
|
779
|
+
if (metadata.instruments) {
|
|
780
|
+
for (const [key, instr] of Object.entries(metadata.instruments)) {
|
|
781
|
+
let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
|
|
782
|
+
if (instr.shortName !== undefined) {
|
|
783
|
+
line += ' "' + escapeString(instr.shortName) + '"';
|
|
784
|
+
}
|
|
785
|
+
line += ']';
|
|
786
|
+
lines.push(line);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
776
789
|
if (metadata.autoBeam) {
|
|
777
790
|
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
778
791
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff-layout parser, ported from FindLab starry (app/staffLayout/).
|
|
3
|
+
*
|
|
4
|
+
* The layout string uses STAFF as the leaf unit (distinct from ABC's %%score,
|
|
5
|
+
* which is voice-leaf). Brackets group staves: `{}` = Brace (grand staff),
|
|
6
|
+
* `<>` = Bracket, `[]` = Square; conjunctions between consecutive staves:
|
|
7
|
+
* `,` = Blank, `-` = Solid, `.` = Dashed. Staff ids are [a-zA-Z_0-9]+; a slot
|
|
8
|
+
* with no id is an anonymous staff (auto-named "1","2",…).
|
|
9
|
+
*
|
|
10
|
+
* Example: "<[v1-v2].va> {pl-pr} <b>" → 6 staves v1,v2,va,pl,pr,b grouped as
|
|
11
|
+
* a Bracket over { a Square [v1-v2] dashed-joined to va }, a Brace {pl-pr},
|
|
12
|
+
* and a Bracket <b>.
|
|
13
|
+
*/
|
|
14
|
+
export declare enum StaffGroupType {
|
|
15
|
+
Default = 0,
|
|
16
|
+
Brace = 1,// {}
|
|
17
|
+
Bracket = 2,// <>
|
|
18
|
+
Square = 3
|
|
19
|
+
}
|
|
20
|
+
export declare enum StaffConjunctionType {
|
|
21
|
+
Blank = 0,
|
|
22
|
+
Dashed = 1,
|
|
23
|
+
Solid = 2
|
|
24
|
+
}
|
|
25
|
+
export interface RawItem {
|
|
26
|
+
id: string | null;
|
|
27
|
+
leftBounds: string[];
|
|
28
|
+
rightBounds: string[];
|
|
29
|
+
conjunction: string | null;
|
|
30
|
+
}
|
|
31
|
+
export interface StaffGroup {
|
|
32
|
+
type: StaffGroupType;
|
|
33
|
+
subs?: StaffGroup[];
|
|
34
|
+
staff?: string;
|
|
35
|
+
level?: number;
|
|
36
|
+
grand?: boolean;
|
|
37
|
+
key?: string;
|
|
38
|
+
bar?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface StaffGroupTrait {
|
|
41
|
+
group: StaffGroup;
|
|
42
|
+
range: [number, number];
|
|
43
|
+
key: string;
|
|
44
|
+
}
|
|
45
|
+
export declare const groupKey: (group: StaffGroup) => string | undefined;
|
|
46
|
+
export declare class StaffLayout {
|
|
47
|
+
staffIds: string[];
|
|
48
|
+
conjunctions: StaffConjunctionType[];
|
|
49
|
+
group: StaffGroup;
|
|
50
|
+
groups: StaffGroupTrait[];
|
|
51
|
+
constructor(raw: RawItem[]);
|
|
52
|
+
get stavesCount(): number;
|
|
53
|
+
}
|
|
54
|
+
export declare const parseStaffLayout: (code: string) => StaffLayout;
|
|
55
|
+
export declare const encodeStaffLayoutMEI: (layout: StaffLayout, nameDict?: {
|
|
56
|
+
[key: string]: string;
|
|
57
|
+
}, indent?: number, tab?: string) => string;
|