@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
|
@@ -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
|
|
|
@@ -951,6 +951,19 @@ const serializeMetadata = (metadata: Metadata): string => {
|
|
|
951
951
|
if (metadata.instrument) {
|
|
952
952
|
lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
|
|
953
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
|
+
}
|
|
954
967
|
if (metadata.autoBeam) {
|
|
955
968
|
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
956
969
|
}
|
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
|
|
15
|
+
export enum StaffGroupType {
|
|
16
|
+
Default = 0,
|
|
17
|
+
Brace = 1, // {}
|
|
18
|
+
Bracket = 2, // <>
|
|
19
|
+
Square = 3, // []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export enum StaffConjunctionType {
|
|
23
|
+
Blank = 0,
|
|
24
|
+
Dashed = 1,
|
|
25
|
+
Solid = 2,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RawItem {
|
|
29
|
+
id: string | null;
|
|
30
|
+
leftBounds: string[];
|
|
31
|
+
rightBounds: string[];
|
|
32
|
+
conjunction: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StaffGroup {
|
|
36
|
+
type: StaffGroupType;
|
|
37
|
+
subs?: StaffGroup[];
|
|
38
|
+
staff?: string;
|
|
39
|
+
level?: number;
|
|
40
|
+
grand?: boolean;
|
|
41
|
+
key?: string;
|
|
42
|
+
bar?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StaffGroupTrait {
|
|
46
|
+
group: StaffGroup;
|
|
47
|
+
range: [number, number];
|
|
48
|
+
key: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const singleGroup = (id: string): StaffGroup => ({ type: StaffGroupType.Default, staff: id });
|
|
52
|
+
|
|
53
|
+
const BOUNDS_TO_GROUPTYPE: { [bound: string]: StaffGroupType } = {
|
|
54
|
+
"{": StaffGroupType.Brace,
|
|
55
|
+
"}": StaffGroupType.Brace,
|
|
56
|
+
"<": StaffGroupType.Bracket,
|
|
57
|
+
">": StaffGroupType.Bracket,
|
|
58
|
+
"[": StaffGroupType.Square,
|
|
59
|
+
"]": StaffGroupType.Square,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const OPEN_BOUNDS = "{<[";
|
|
63
|
+
const CLOSE_BOUNDS = "}>]";
|
|
64
|
+
|
|
65
|
+
const CONJUNCTIONS_MAP: { [conj: string]: StaffConjunctionType } = {
|
|
66
|
+
",": StaffConjunctionType.Blank,
|
|
67
|
+
"-": StaffConjunctionType.Solid,
|
|
68
|
+
".": StaffConjunctionType.Dashed,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// MEI staffGrp @symbol by StaffGroupType (Default → none). MEI's allowed values
|
|
72
|
+
// are brace | bracket | bracketsq | line | none — note the square variant is
|
|
73
|
+
// "bracketsq", NOT "square" (the latter is MusicXML's <group-symbol> value).
|
|
74
|
+
const GROUP_SYMBOLS_MEI: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
|
|
75
|
+
|
|
76
|
+
const randomB64 = (): string => {
|
|
77
|
+
const code = Buffer.from(Math.random().toString().slice(2)).toString("base64").replace(/=/g, "");
|
|
78
|
+
return code.split("").reverse().slice(0, 6).join("");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const makeUniqueName = (set: Set<string>, index: number, prefix?: string): string => {
|
|
82
|
+
let name = prefix;
|
|
83
|
+
if (!name) name = index.toString();
|
|
84
|
+
else if (set.has(name)) name += "_" + index.toString();
|
|
85
|
+
|
|
86
|
+
while (set.has(name)) name = (prefix ? prefix + "_" : "") + randomB64();
|
|
87
|
+
|
|
88
|
+
return name;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Tokenize a layout string into RawItem[] (one per staff slot). Lexer: whitespace
|
|
92
|
+
// skipped, single chars [-,.{}<>[]] are bounds/conjunctions, [a-zA-Z_0-9]+ is an id.
|
|
93
|
+
// An item accumulates leading open-bounds (leftBounds), an optional id, trailing
|
|
94
|
+
// close-bounds (rightBounds); a conjunction terminates the item and starts the next.
|
|
95
|
+
const tokenize = (code: string): RawItem[] => {
|
|
96
|
+
const tokens = code.match(/[A-Za-z0-9_]+|[-,.{}<>\[\]]/g) || [];
|
|
97
|
+
const items: RawItem[] = [];
|
|
98
|
+
let cur: RawItem = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
|
|
99
|
+
let seenId = false; // id slot filled for the current item
|
|
100
|
+
let seenRight = false; // started collecting right bounds / closing
|
|
101
|
+
|
|
102
|
+
const pushItem = () => {
|
|
103
|
+
items.push(cur);
|
|
104
|
+
cur = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
|
|
105
|
+
seenId = false;
|
|
106
|
+
seenRight = false;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (const tok of tokens) {
|
|
110
|
+
if (tok in CONJUNCTIONS_MAP) {
|
|
111
|
+
cur.conjunction = tok;
|
|
112
|
+
pushItem();
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (OPEN_BOUNDS.includes(tok)) {
|
|
116
|
+
// An open bound after the id/closing starts a fresh item's left bounds.
|
|
117
|
+
if (seenId || seenRight) pushItem();
|
|
118
|
+
cur.leftBounds.push(tok);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (CLOSE_BOUNDS.includes(tok)) {
|
|
122
|
+
cur.rightBounds.push(tok);
|
|
123
|
+
seenRight = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// id token
|
|
127
|
+
if (seenId || seenRight) pushItem();
|
|
128
|
+
cur.id = tok;
|
|
129
|
+
seenId = true;
|
|
130
|
+
}
|
|
131
|
+
// Flush the final item if it carries any content.
|
|
132
|
+
if (cur.id !== null || cur.leftBounds.length || cur.rightBounds.length) pushItem();
|
|
133
|
+
|
|
134
|
+
return items;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const makeGroupsFromRaw = (parent: StaffGroup, seq: string[]): string[] => {
|
|
138
|
+
let remains = seq;
|
|
139
|
+
while (remains.length) {
|
|
140
|
+
const word = remains.shift() as string;
|
|
141
|
+
const bound = BOUNDS_TO_GROUPTYPE[word];
|
|
142
|
+
if (bound !== undefined) {
|
|
143
|
+
if (CLOSE_BOUNDS.includes(word) && bound === parent.type) break;
|
|
144
|
+
|
|
145
|
+
if (OPEN_BOUNDS.includes(word)) {
|
|
146
|
+
const group: StaffGroup = { type: bound, level: Number.isFinite(parent.level as number) ? (parent.level as number) + 1 : 0 };
|
|
147
|
+
remains = makeGroupsFromRaw(group, remains);
|
|
148
|
+
|
|
149
|
+
parent.subs = parent.subs || [];
|
|
150
|
+
parent.subs.push(group);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
parent.subs = parent.subs || [];
|
|
154
|
+
parent.subs.push(singleGroup(word));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
while (parent.type === StaffGroupType.Default && parent.subs && parent.subs.length === 1) {
|
|
159
|
+
const sub = parent.subs[0];
|
|
160
|
+
parent.type = sub.type;
|
|
161
|
+
parent.subs = sub.subs;
|
|
162
|
+
parent.staff = sub.staff;
|
|
163
|
+
parent.level = sub.level;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
while (parent.subs && parent.subs.length === 1 && parent.subs[0].type === StaffGroupType.Default) {
|
|
167
|
+
const sub = parent.subs[0];
|
|
168
|
+
parent.subs = sub.subs;
|
|
169
|
+
parent.staff = sub.staff;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
parent.grand = parent.type === StaffGroupType.Brace && !!parent.subs && parent.subs.every(sub => !!sub.staff);
|
|
173
|
+
|
|
174
|
+
return remains;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const groupHead = (group: StaffGroup): string | undefined => {
|
|
178
|
+
if (group.staff) return group.staff;
|
|
179
|
+
else if (group.subs) return groupHead(group.subs[0]);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const groupTail = (group: StaffGroup): string | undefined => {
|
|
183
|
+
if (group.staff) return group.staff;
|
|
184
|
+
else if (group.subs) return groupTail(group.subs[group.subs.length - 1]);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const groupKey = (group: StaffGroup): string | undefined => {
|
|
188
|
+
if (group.staff) return group.staff;
|
|
189
|
+
else if (group.subs) return `${groupHead(group)}-${groupTail(group)}`;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const groupDict = (group: StaffGroup, dict: { [key: string]: StaffGroup }): void => {
|
|
193
|
+
const key = groupKey(group);
|
|
194
|
+
if (key !== undefined) dict[key] = group;
|
|
195
|
+
if (group.subs) group.subs.forEach(sub => groupDict(sub, dict));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export class StaffLayout {
|
|
199
|
+
staffIds: string[];
|
|
200
|
+
conjunctions: StaffConjunctionType[];
|
|
201
|
+
group: StaffGroup;
|
|
202
|
+
groups: StaffGroupTrait[];
|
|
203
|
+
|
|
204
|
+
constructor(raw: RawItem[]) {
|
|
205
|
+
// make unique ids (anonymous slots get "1","2",… ; named collisions disambiguated)
|
|
206
|
+
const ids = new Set<string>();
|
|
207
|
+
raw.forEach((item, i) => {
|
|
208
|
+
item.id = makeUniqueName(ids, i + 1, item.id || undefined);
|
|
209
|
+
ids.add(item.id);
|
|
210
|
+
});
|
|
211
|
+
this.staffIds = raw.map(item => item.id as string);
|
|
212
|
+
this.conjunctions = raw.slice(0, raw.length - 1).map(item => item.conjunction ? CONJUNCTIONS_MAP[item.conjunction] : StaffConjunctionType.Blank);
|
|
213
|
+
|
|
214
|
+
// make groups
|
|
215
|
+
const seq = ([] as string[]).concat(...raw.map(item => [...item.leftBounds, item.id as string, ...item.rightBounds]));
|
|
216
|
+
this.group = { type: StaffGroupType.Default };
|
|
217
|
+
makeGroupsFromRaw(this.group, seq);
|
|
218
|
+
|
|
219
|
+
const dict: { [key: string]: StaffGroup } = {};
|
|
220
|
+
groupDict(this.group, dict);
|
|
221
|
+
this.groups = Object.entries(dict).map(([key, group]) => {
|
|
222
|
+
let ids = key.split("-");
|
|
223
|
+
if (ids.length === 1) ids = [ids[0], ids[0]];
|
|
224
|
+
const range = ids.map(id => this.staffIds.indexOf(id)) as [number, number];
|
|
225
|
+
|
|
226
|
+
const cons = this.conjunctions.slice(range[0], range[1]);
|
|
227
|
+
const bar = cons.length ? Math.min(...cons) : 0;
|
|
228
|
+
|
|
229
|
+
group.key = key;
|
|
230
|
+
group.bar = bar;
|
|
231
|
+
|
|
232
|
+
return { group, range, key };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
get stavesCount(): number {
|
|
237
|
+
return this.staffIds.length;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const parseStaffLayout = (code: string): StaffLayout => new StaffLayout(tokenize(code));
|
|
242
|
+
|
|
243
|
+
// ── Staff-layout serialization (inverse of parseStaffLayout) ──
|
|
244
|
+
// Reconstruct a layout string from a parsed StaffLayout by walking the group tree,
|
|
245
|
+
// so every staff slot and conjunction is preserved structurally (a regex strip of the
|
|
246
|
+
// ids would drop a BARE anonymous leaf — its empty token gets swallowed by whitespace).
|
|
247
|
+
//
|
|
248
|
+
// `anonymous` emits empty ids (the parser re-auto-names slots "1","2",… by position).
|
|
249
|
+
// `idMap` optionally overrides individual staff ids by their original id.
|
|
250
|
+
//
|
|
251
|
+
// Conjunction rendering: Solid → "-", Dashed → ".", Blank → " " ONLY when both sides
|
|
252
|
+
// are bracketed groups (the brackets self-delimit the slots); otherwise Blank → ","
|
|
253
|
+
// so an adjacent empty/bare leaf still tokenizes as its own slot.
|
|
254
|
+
|
|
255
|
+
const CONJ_CHAR: { [c in StaffConjunctionType]: string } = {
|
|
256
|
+
[StaffConjunctionType.Solid]: "-",
|
|
257
|
+
[StaffConjunctionType.Dashed]: ".",
|
|
258
|
+
[StaffConjunctionType.Blank]: ",",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export interface SerializeStaffLayoutOptions {
|
|
262
|
+
anonymous?: boolean;
|
|
263
|
+
idMap?: (originalId: string) => string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const serializeStaffLayout = (layout: StaffLayout, options: SerializeStaffLayoutOptions = {}): string => {
|
|
267
|
+
const { anonymous = false, idMap } = options;
|
|
268
|
+
const isGrouped = (group: StaffGroup): boolean => group.type !== StaffGroupType.Default && !!group.subs;
|
|
269
|
+
|
|
270
|
+
const leafText = (id: string): string => (anonymous ? "" : idMap ? idMap(id) : id);
|
|
271
|
+
|
|
272
|
+
// flat leaf index of a group's first / last staff (for the inter-child conjunction).
|
|
273
|
+
const firstLeafIndex = (group: StaffGroup): number => layout.staffIds.indexOf(groupHead(group)!);
|
|
274
|
+
const lastLeafIndex = (group: StaffGroup): number => layout.staffIds.indexOf(groupTail(group)!);
|
|
275
|
+
|
|
276
|
+
const sep = (conj: StaffConjunctionType, left: StaffGroup, right: StaffGroup): string => {
|
|
277
|
+
if (conj !== StaffConjunctionType.Blank) return CONJ_CHAR[conj];
|
|
278
|
+
// Blank: a space is safe only when both neighbours are bracketed (self-delimiting).
|
|
279
|
+
return isGrouped(left) && isGrouped(right) ? " " : ",";
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const emit = (group: StaffGroup): string => {
|
|
283
|
+
if (!group.subs) return leafText(group.staff!); // Default leaf
|
|
284
|
+
|
|
285
|
+
const open = group.type === StaffGroupType.Brace ? "{" : group.type === StaffGroupType.Bracket ? "<" : group.type === StaffGroupType.Square ? "[" : "";
|
|
286
|
+
const close = group.type === StaffGroupType.Brace ? "}" : group.type === StaffGroupType.Bracket ? ">" : group.type === StaffGroupType.Square ? "]" : "";
|
|
287
|
+
|
|
288
|
+
let inner = "";
|
|
289
|
+
group.subs.forEach((sub, i) => {
|
|
290
|
+
inner += emit(sub);
|
|
291
|
+
if (i < group.subs!.length - 1) {
|
|
292
|
+
const next = group.subs![i + 1];
|
|
293
|
+
const conj = layout.conjunctions[lastLeafIndex(sub)] ?? StaffConjunctionType.Blank;
|
|
294
|
+
inner += sep(conj, sub, next);
|
|
295
|
+
void firstLeafIndex; // (lastLeafIndex(sub) === firstLeafIndex(next) - 1)
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return open + inner + close;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
let out = emit(layout.group);
|
|
302
|
+
|
|
303
|
+
// A TRAILING bare anonymous leaf emits "" with nothing after it to delimit the slot
|
|
304
|
+
// (a leaf before a closing bracket is fine — the bracket gives it bounds; an internal
|
|
305
|
+
// one is flushed by the next separator). The tokenizer only flushes a final empty item
|
|
306
|
+
// if it carries bounds, so append one "," to materialize that last empty slot. This only
|
|
307
|
+
// arises when the OUTERMOST container is the Default sequence (no enclosing bracket) and
|
|
308
|
+
// its last child is a bare leaf; if the whole layout is wrapped in a bracket, the closing
|
|
309
|
+
// bracket already delimits the final leaf. The trailing conjunction is dropped on re-parse
|
|
310
|
+
// (conjunctions = items[0..n-1]), so it is harmless. Anonymous output only.
|
|
311
|
+
if (anonymous && layout.group.type === StaffGroupType.Default && layout.group.subs) {
|
|
312
|
+
const lastTop = layout.group.subs[layout.group.subs.length - 1];
|
|
313
|
+
if (!lastTop.subs && lastTop.staff !== undefined) out += ",";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return out;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
|
|
320
|
+
// Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
|
|
321
|
+
// with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
|
|
322
|
+
// label. Returns the inner XML (no <scoreDef> wrapper); the caller positions it.
|
|
323
|
+
|
|
324
|
+
const bool = (x: boolean): string => (x ? "true" : "false");
|
|
325
|
+
|
|
326
|
+
const stateMEIGroup = (
|
|
327
|
+
statements: string[],
|
|
328
|
+
group: StaffGroup,
|
|
329
|
+
nameDict: { [key: string]: string },
|
|
330
|
+
ids: string[],
|
|
331
|
+
indent: number,
|
|
332
|
+
tab: string,
|
|
333
|
+
): void => {
|
|
334
|
+
const pad = tab.repeat(indent);
|
|
335
|
+
const name = group.key !== undefined ? nameDict[group.key] : undefined;
|
|
336
|
+
|
|
337
|
+
if (group.subs) {
|
|
338
|
+
const symbol = GROUP_SYMBOLS_MEI[group.type] ? ` symbol="${GROUP_SYMBOLS_MEI[group.type]}"` : "";
|
|
339
|
+
statements.push(`${pad}<staffGrp bar.thru="${bool((group.bar ?? 0) > 1)}"${symbol}>`);
|
|
340
|
+
if (name) statements.push(`${pad}${tab}<label>${name}</label>`);
|
|
341
|
+
group.subs.forEach(sub => stateMEIGroup(statements, sub, nameDict, ids, indent + 1, tab));
|
|
342
|
+
statements.push(`${pad}</staffGrp>`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (group.staff) statements.push(`${pad}<staffDef n="${ids.indexOf(group.staff) + 1}">`);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export const encodeStaffLayoutMEI = (
|
|
349
|
+
layout: StaffLayout,
|
|
350
|
+
nameDict: { [key: string]: string } = {},
|
|
351
|
+
indent = 0,
|
|
352
|
+
tab = "\t",
|
|
353
|
+
): string => {
|
|
354
|
+
const statements: string[] = [];
|
|
355
|
+
stateMEIGroup(statements, layout.group, nameDict, layout.staffIds, indent, tab);
|
|
356
|
+
return statements.join("\n");
|
|
357
|
+
};
|
package/source/lilylet/types.ts
CHANGED
|
@@ -301,9 +301,19 @@ export interface Metadata {
|
|
|
301
301
|
opus?: string;
|
|
302
302
|
instrument?: string;
|
|
303
303
|
genre?: string;
|
|
304
|
+
staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
|
|
305
|
+
// Per-staff / per-group instrument names, keyed by staff-layout group key (a single
|
|
306
|
+
// staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
|
|
307
|
+
// [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.
|
|
308
|
+
instruments?: { [key: string]: InstrumentName };
|
|
304
309
|
autoBeam?: 'auto' | 'on' | 'off';
|
|
305
310
|
}
|
|
306
311
|
|
|
312
|
+
export interface InstrumentName {
|
|
313
|
+
name: string;
|
|
314
|
+
shortName?: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
307
317
|
// Part within a measure: can be a single staff or grand staff (multiple staves)
|
|
308
318
|
// When voices have staff > 1, it's a grand staff
|
|
309
319
|
export interface Part {
|