@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/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,6 @@
|
|
|
1
1
|
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
|
+
import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
|
|
3
|
+
import { gmProgramOf } from "./gmInstruments.js";
|
|
2
4
|
// MEI key signatures: positive = sharps, negative = flats
|
|
3
5
|
const KEY_SIGS = {
|
|
4
6
|
0: "0",
|
|
@@ -44,38 +46,65 @@ const CLEF_SHAPES = {
|
|
|
44
46
|
F: { shape: "F", line: 4 },
|
|
45
47
|
C: { shape: "C", line: 3 },
|
|
46
48
|
};
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
49
|
+
// Semitone offsets of the major/perfect intervals within one diatonic octave,
|
|
50
|
+
// indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
|
|
51
|
+
const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
52
|
+
// Convert a LilyPond clef interval-number suffix into an MEI written→sounding
|
|
53
|
+
// transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
|
|
54
|
+
// 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
|
|
55
|
+
// raises it. Returns { diat, semi } where diat is the diatonic step shift
|
|
56
|
+
// (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
|
|
57
|
+
// extended octave-wise for compound intervals.
|
|
58
|
+
const clefTransposition = (intervalNumber, up) => {
|
|
59
|
+
const k = intervalNumber - 1; // diatonic steps
|
|
60
|
+
const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
|
|
61
|
+
const sign = up ? 1 : -1;
|
|
62
|
+
return { diat: sign * k, semi: sign * semis };
|
|
63
|
+
};
|
|
64
|
+
// Resolve a clef string into MEI shape/line plus optional written→sounding
|
|
65
|
+
// transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
|
|
66
|
+
// clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
|
|
67
|
+
// "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
|
|
68
|
+
// displacement (8|15|22), so all clef transposition — octaves included — is
|
|
69
|
+
// encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
|
|
52
70
|
const resolveClef = (clefStr) => {
|
|
53
|
-
const match = clefStr.match(/^(.*?)([_^])(
|
|
71
|
+
const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
|
|
54
72
|
const base = match ? match[1] : clefStr;
|
|
55
73
|
const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
|
|
56
74
|
if (!match)
|
|
57
75
|
return { shape: clefInfo.shape, line: clefInfo.line };
|
|
76
|
+
const trans = clefTransposition(Number(match[3]), match[2] === "^");
|
|
58
77
|
return {
|
|
59
78
|
shape: clefInfo.shape,
|
|
60
79
|
line: clefInfo.line,
|
|
61
|
-
|
|
62
|
-
disPlace: match[2] === "^" ? "above" : "below",
|
|
80
|
+
trans,
|
|
63
81
|
};
|
|
64
82
|
};
|
|
65
83
|
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
84
|
+
// A mid-measure <clef> cannot carry att.transposition (that is a staff-level
|
|
85
|
+
// property Verovio only reads from <staffDef>); the visible clef shape/line is
|
|
86
|
+
// emitted here, and a sounding-pitch transposition change is mirrored by a
|
|
87
|
+
// between-measure <scoreDef>/<staffDef trans.*> emitted in the measure loop
|
|
88
|
+
// (see emitClefTranspositionScoreDef). So only shape/line are emitted here.
|
|
66
89
|
const clefElementAttrs = (clefStr) => {
|
|
67
90
|
const c = resolveClef(clefStr);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
return `shape="${c.shape}" line="${c.line}"`;
|
|
92
|
+
};
|
|
93
|
+
// The written→sounding transposition a clef declares, as {diat, semi}; {0,0}
|
|
94
|
+
// when the clef declares none. Used to detect mid-piece transposition changes
|
|
95
|
+
// and to emit the resetting <staffDef> when a transposing clef is replaced by a
|
|
96
|
+
// plain one (Verovio retains a prior trans.* until an explicit 0/0 overrides it).
|
|
97
|
+
const clefTransOf = (clefStr) => {
|
|
98
|
+
const c = resolveClef(clefStr);
|
|
99
|
+
return c.trans || { diat: 0, semi: 0 };
|
|
72
100
|
};
|
|
73
|
-
// Attributes for a <staffDef> clef (clef.* namespace).
|
|
101
|
+
// Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
|
|
102
|
+
// (trans.diat / trans.semi) when the clef declares a transposition.
|
|
74
103
|
const staffDefClefAttrs = (clefStr) => {
|
|
75
104
|
const c = resolveClef(clefStr);
|
|
76
105
|
let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
|
|
77
|
-
if (c.
|
|
78
|
-
attrs += `
|
|
106
|
+
if (c.trans)
|
|
107
|
+
attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
|
|
79
108
|
return attrs;
|
|
80
109
|
};
|
|
81
110
|
// Lilylet duration division to MEI dur
|
|
@@ -1368,6 +1397,36 @@ const generateTempoElement = (tempo, indent, staff = 1) => {
|
|
|
1368
1397
|
}
|
|
1369
1398
|
return `${indent}<tempo ${attrs} />\n`;
|
|
1370
1399
|
};
|
|
1400
|
+
// The clef governing a measure's sounding pitch on one global staff: the clef
|
|
1401
|
+
// carried in from the previous measure, overridden by a leading clef change that
|
|
1402
|
+
// appears before the first note/rest in the measure (the normal boundary-change
|
|
1403
|
+
// case). A clef change occurring *after* notes does not retune this measure — it
|
|
1404
|
+
// becomes the carried clef for the next measure via clefState. This per-measure
|
|
1405
|
+
// granularity matches what Verovio honors for MIDI: a transposition change takes
|
|
1406
|
+
// effect only via a between-measure <scoreDef>/<staffDef trans.*>, never from a
|
|
1407
|
+
// mid-measure <clef> element.
|
|
1408
|
+
const measureStartClef = (measure, globalStaff, partInfos, carriedClef) => {
|
|
1409
|
+
let active = carriedClef;
|
|
1410
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
1411
|
+
const partOffset = partInfos[pi]?.staffOffset || 0;
|
|
1412
|
+
for (const voice of measure.parts[pi].voices) {
|
|
1413
|
+
if ((partOffset + (voice.staff || 1)) !== globalStaff)
|
|
1414
|
+
continue;
|
|
1415
|
+
for (const event of voice.events) {
|
|
1416
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
1417
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
1418
|
+
break; // notes have started; a later clef change governs the next measure
|
|
1419
|
+
}
|
|
1420
|
+
if (event.type === 'context') {
|
|
1421
|
+
const ctx = event;
|
|
1422
|
+
if (ctx.clef)
|
|
1423
|
+
active = ctx.clef;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return active;
|
|
1429
|
+
};
|
|
1371
1430
|
// Barline style to MEI @right attribute mapping
|
|
1372
1431
|
const BARLINE_TO_MEI = {
|
|
1373
1432
|
'|': 'single',
|
|
@@ -1622,33 +1681,149 @@ const analyzePartStructure = (doc) => {
|
|
|
1622
1681
|
}
|
|
1623
1682
|
return partInfos;
|
|
1624
1683
|
};
|
|
1684
|
+
// MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
|
|
1685
|
+
const LAYOUT_SYMBOL = [null, "brace", "bracket", "bracketsq"];
|
|
1686
|
+
const newChannelAllocator = () => ({ byProgram: new Map(), nextChannel: 0 });
|
|
1687
|
+
// Channel for a program: reuse the channel already assigned to that program,
|
|
1688
|
+
// else take the next free channel (skipping 9). Wraps past 16 as graceful
|
|
1689
|
+
// degradation for scores with more than 15 distinct timbres.
|
|
1690
|
+
const allocChannel = (alloc, program) => {
|
|
1691
|
+
const existing = alloc.byProgram.get(program);
|
|
1692
|
+
if (existing !== undefined)
|
|
1693
|
+
return existing;
|
|
1694
|
+
let ch = alloc.nextChannel;
|
|
1695
|
+
if (ch % 16 === 9)
|
|
1696
|
+
ch++; // skip GM drum channel
|
|
1697
|
+
const assigned = ch % 16;
|
|
1698
|
+
alloc.byProgram.set(program, assigned);
|
|
1699
|
+
alloc.nextChannel = ch + 1;
|
|
1700
|
+
return assigned;
|
|
1701
|
+
};
|
|
1702
|
+
// Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
|
|
1703
|
+
// When the instrument name resolves to a General MIDI program, also emit an
|
|
1704
|
+
// <instrDef midi.instrnum midi.channel> sibling so Verovio's MIDI export assigns
|
|
1705
|
+
// that timbre (it honors only the numeric @midi.instrnum) on its own channel
|
|
1706
|
+
// (@midi.channel, else all instruments collide on channel 0). Unknown names emit
|
|
1707
|
+
// just the label, leaving Verovio's default program (0 = piano).
|
|
1708
|
+
const instrumentLabelXML = (instr, indent, channels) => {
|
|
1709
|
+
if (!instr)
|
|
1710
|
+
return "";
|
|
1711
|
+
let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
|
|
1712
|
+
if (instr.shortName !== undefined) {
|
|
1713
|
+
xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
|
|
1714
|
+
}
|
|
1715
|
+
const program = gmProgramOf(instr.name);
|
|
1716
|
+
if (program !== undefined) {
|
|
1717
|
+
const chanAttr = channels ? ` midi.channel="${allocChannel(channels, program)}"` : "";
|
|
1718
|
+
xml += `${indent}<instrDef xml:id="${generateId("instrdef")}" midi.instrnum="${program}"${chanAttr} />\n`;
|
|
1719
|
+
}
|
|
1720
|
+
return xml;
|
|
1721
|
+
};
|
|
1722
|
+
// Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
|
|
1723
|
+
// `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
|
|
1724
|
+
// string for the matching global staff's <staffDef>. `instruments` maps a layout group
|
|
1725
|
+
// key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
|
|
1726
|
+
// names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
|
|
1727
|
+
// bar.thru reflects the group's conjunction (Solid).
|
|
1728
|
+
const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments, channels) => {
|
|
1729
|
+
const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
|
|
1730
|
+
const instr = group.key !== undefined ? instruments[group.key] : undefined;
|
|
1731
|
+
// A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
|
|
1732
|
+
if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
|
|
1733
|
+
return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
|
|
1734
|
+
}
|
|
1735
|
+
const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
|
|
1736
|
+
const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
|
|
1737
|
+
let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
|
|
1738
|
+
// A multi-staff group's instrument name labels the whole group.
|
|
1739
|
+
if (!isLeaf)
|
|
1740
|
+
xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
1741
|
+
if (isLeaf) {
|
|
1742
|
+
// A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
|
|
1743
|
+
// a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
|
|
1744
|
+
// label goes on the staffDef inside.
|
|
1745
|
+
xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
for (const sub of group.subs || []) {
|
|
1749
|
+
xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
xml += `${indent}</staffGrp>\n`;
|
|
1753
|
+
return xml;
|
|
1754
|
+
};
|
|
1755
|
+
// Emit a <staffDef> from its attribute string, with optional instrument <label> children.
|
|
1756
|
+
const staffDefWithLabel = (attrs, instr, indent, channels) => {
|
|
1757
|
+
if (attrs === undefined)
|
|
1758
|
+
return "";
|
|
1759
|
+
if (!instr)
|
|
1760
|
+
return `${indent}<staffDef ${attrs} />\n`;
|
|
1761
|
+
let xml = `${indent}<staffDef ${attrs}>\n`;
|
|
1762
|
+
xml += instrumentLabelXML(instr, indent + " ", channels);
|
|
1763
|
+
xml += `${indent}</staffDef>\n`;
|
|
1764
|
+
return xml;
|
|
1765
|
+
};
|
|
1625
1766
|
// Encode scoreDef with part groups
|
|
1626
|
-
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
|
|
1767
|
+
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol, stavesCode, instruments = {}) => {
|
|
1627
1768
|
const scoreDefId = generateId("scoredef");
|
|
1769
|
+
// One MIDI channel allocator per score: assigns a distinct channel per GM
|
|
1770
|
+
// program so instruments don't collide on channel 0 (see allocChannel).
|
|
1771
|
+
const channels = newChannelAllocator();
|
|
1628
1772
|
// Build meter attributes
|
|
1629
1773
|
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1630
1774
|
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`;
|
|
1775
|
+
// Flat ordered list of global staves (n + clef), in part/voice order.
|
|
1776
|
+
const flatStaves = [];
|
|
1777
|
+
for (const info of partInfos) {
|
|
1778
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1779
|
+
flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
|
|
1643
1780
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1781
|
+
}
|
|
1782
|
+
// If a [staves] layout is present and its leaf count matches the staves, drive
|
|
1783
|
+
// the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
|
|
1784
|
+
// the per-part grand-staff grouping.
|
|
1785
|
+
let layoutUsed = false;
|
|
1786
|
+
if (stavesCode) {
|
|
1787
|
+
const layout = parseStaffLayout(stavesCode);
|
|
1788
|
+
if (layout.stavesCount === flatStaves.length) {
|
|
1789
|
+
const staffDefAttrs = flatStaves.map(s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`);
|
|
1790
|
+
xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
|
|
1791
|
+
layoutUsed = true;
|
|
1649
1792
|
}
|
|
1650
1793
|
}
|
|
1651
|
-
|
|
1794
|
+
if (!layoutUsed) {
|
|
1795
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1796
|
+
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
1797
|
+
const info = partInfos[pi];
|
|
1798
|
+
// If part has multiple staves (grand staff), wrap in staffGrp with brace.
|
|
1799
|
+
// Instrument key for a part follows staffLayout's groupKey: a single staff
|
|
1800
|
+
// number, or "first-last" for a grand staff.
|
|
1801
|
+
if (info.maxStaff > 1) {
|
|
1802
|
+
const first = info.staffOffset + 1;
|
|
1803
|
+
const last = info.staffOffset + info.maxStaff;
|
|
1804
|
+
const instr = instruments[`${first}-${last}`];
|
|
1805
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
1806
|
+
xml += instrumentLabelXML(instr, `${indent} `, channels);
|
|
1807
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1808
|
+
const globalStaff = info.staffOffset + ls;
|
|
1809
|
+
const clef = info.clefs[ls] || Clef.treble;
|
|
1810
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
1811
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
1812
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
1813
|
+
}
|
|
1814
|
+
xml += `${indent} </staffGrp>\n`;
|
|
1815
|
+
}
|
|
1816
|
+
else {
|
|
1817
|
+
// Single staff part
|
|
1818
|
+
const globalStaff = info.staffOffset + 1;
|
|
1819
|
+
const clef = info.clefs[1] || Clef.treble;
|
|
1820
|
+
const leafInstr = instruments[`${globalStaff}`];
|
|
1821
|
+
const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
|
|
1822
|
+
xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
xml += `${indent} </staffGrp>\n`;
|
|
1826
|
+
}
|
|
1652
1827
|
xml += `${indent}</scoreDef>\n`;
|
|
1653
1828
|
return xml;
|
|
1654
1829
|
};
|
|
@@ -1983,7 +2158,7 @@ const encode = (doc, options = {}) => {
|
|
|
1983
2158
|
mei += `${indent}${indent}<body>\n`;
|
|
1984
2159
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1985
2160
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1986
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
2161
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
1987
2162
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1988
2163
|
// Track tie state across measures for cross-measure ties
|
|
1989
2164
|
const tieState = {};
|
|
@@ -1996,11 +2171,18 @@ const encode = (doc, options = {}) => {
|
|
|
1996
2171
|
const octaveEndReplacements = {};
|
|
1997
2172
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1998
2173
|
const clefState = {};
|
|
2174
|
+
// Running written→sounding transposition per global staff, as the verbatim
|
|
2175
|
+
// "diat,semi" key. Seeded from the initial clefs (which the leading <scoreDef>
|
|
2176
|
+
// already encoded via staffDefClefAttrs), then advanced whenever a measure
|
|
2177
|
+
// starts under a clef with a different transposition.
|
|
2178
|
+
const transState = {};
|
|
1999
2179
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
2000
2180
|
const partInfo = partInfos[pi];
|
|
2001
2181
|
for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
|
|
2002
2182
|
const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
|
|
2003
2183
|
clefState[globalStaff] = clef;
|
|
2184
|
+
const t = clefTransOf(clef);
|
|
2185
|
+
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2004
2186
|
}
|
|
2005
2187
|
}
|
|
2006
2188
|
// Helper to check if a measure has any musical content
|
|
@@ -2049,6 +2231,41 @@ const encode = (doc, options = {}) => {
|
|
|
2049
2231
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
2050
2232
|
}
|
|
2051
2233
|
}
|
|
2234
|
+
// Check for a written→sounding transposition change at this measure
|
|
2235
|
+
// boundary and mirror it into MIDI. Verovio applies att.transposition only
|
|
2236
|
+
// from <staffDef>, never from a mid-measure <clef>, so a change of
|
|
2237
|
+
// transposing clef must be re-declared via a between-measure <scoreDef>.
|
|
2238
|
+
// We emit only the staves whose transposition actually changed (a partial
|
|
2239
|
+
// <staffGrp> leaves the others' state intact), using explicit "0,0" to
|
|
2240
|
+
// clear a prior transposition when a transposing clef is replaced by a
|
|
2241
|
+
// plain one. mi === 0 is already covered by the leading <scoreDef>.
|
|
2242
|
+
if (mi > 0) {
|
|
2243
|
+
const changed = [];
|
|
2244
|
+
for (let si = 1; si <= totalStaves; si++) {
|
|
2245
|
+
const startClef = measureStartClef(measure, si, partInfos, clefState[si]);
|
|
2246
|
+
if (startClef === undefined)
|
|
2247
|
+
continue;
|
|
2248
|
+
const t = clefTransOf(startClef);
|
|
2249
|
+
const key = `${t.diat},${t.semi}`;
|
|
2250
|
+
if (transState[si] === undefined) {
|
|
2251
|
+
transState[si] = key;
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
if (key !== transState[si]) {
|
|
2255
|
+
transState[si] = key;
|
|
2256
|
+
const c = resolveClef(startClef);
|
|
2257
|
+
changed.push(`${indent}${indent}${indent}${indent}${indent}${indent}${indent}${indent}<staffDef xml:id="${generateId('staffdef')}" n="${si}" lines="5" clef.shape="${c.shape}" clef.line="${c.line}" trans.diat="${t.diat}" trans.semi="${t.semi}" />`);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
if (changed.length > 0) {
|
|
2261
|
+
const sd = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2262
|
+
mei += `${sd}<scoreDef xml:id="${generateId('scoredef')}">\n`;
|
|
2263
|
+
mei += `${sd}${indent}<staffGrp xml:id="${generateId('staffgrp')}">\n`;
|
|
2264
|
+
mei += changed.join("\n") + "\n";
|
|
2265
|
+
mei += `${sd}${indent}</staffGrp>\n`;
|
|
2266
|
+
mei += `${sd}</scoreDef>\n`;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2052
2269
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
|
|
2053
2270
|
});
|
|
2054
2271
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
@@ -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,62 @@
|
|
|
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 interface SerializeStaffLayoutOptions {
|
|
56
|
+
anonymous?: boolean;
|
|
57
|
+
idMap?: (originalId: string) => string;
|
|
58
|
+
}
|
|
59
|
+
export declare const serializeStaffLayout: (layout: StaffLayout, options?: SerializeStaffLayoutOptions) => string;
|
|
60
|
+
export declare const encodeStaffLayoutMEI: (layout: StaffLayout, nameDict?: {
|
|
61
|
+
[key: string]: string;
|
|
62
|
+
}, indent?: number, tab?: string) => string;
|