@sudobility/music_codecs 0.1.1
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/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/midi/analyze.d.ts +53 -0
- package/dist/midi/analyze.d.ts.map +1 -0
- package/dist/midi/analyze.js +58 -0
- package/dist/midi/analyze.js.map +1 -0
- package/dist/midi/export.d.ts +20 -0
- package/dist/midi/export.d.ts.map +1 -0
- package/dist/midi/export.js +135 -0
- package/dist/midi/export.js.map +1 -0
- package/dist/midi/grid-detection.d.ts +41 -0
- package/dist/midi/grid-detection.d.ts.map +1 -0
- package/dist/midi/grid-detection.js +61 -0
- package/dist/midi/grid-detection.js.map +1 -0
- package/dist/midi/import-options.d.ts +45 -0
- package/dist/midi/import-options.d.ts.map +1 -0
- package/dist/midi/import-options.js +38 -0
- package/dist/midi/import-options.js.map +1 -0
- package/dist/midi/import.d.ts +43 -0
- package/dist/midi/import.d.ts.map +1 -0
- package/dist/midi/import.js +441 -0
- package/dist/midi/import.js.map +1 -0
- package/dist/midi/key-detection.d.ts +11 -0
- package/dist/midi/key-detection.d.ts.map +1 -0
- package/dist/midi/key-detection.js +96 -0
- package/dist/midi/key-detection.js.map +1 -0
- package/dist/midi/measures.d.ts +39 -0
- package/dist/midi/measures.d.ts.map +1 -0
- package/dist/midi/measures.js +141 -0
- package/dist/midi/measures.js.map +1 -0
- package/dist/mod/export.d.ts +54 -0
- package/dist/mod/export.d.ts.map +1 -0
- package/dist/mod/export.js +172 -0
- package/dist/mod/export.js.map +1 -0
- package/dist/mod/fill.d.ts +11 -0
- package/dist/mod/fill.d.ts.map +1 -0
- package/dist/mod/fill.js +50 -0
- package/dist/mod/fill.js.map +1 -0
- package/dist/mod/import.d.ts +14 -0
- package/dist/mod/import.d.ts.map +1 -0
- package/dist/mod/import.js +182 -0
- package/dist/mod/import.js.map +1 -0
- package/dist/mod/limits.d.ts +26 -0
- package/dist/mod/limits.d.ts.map +1 -0
- package/dist/mod/limits.js +14 -0
- package/dist/mod/limits.js.map +1 -0
- package/dist/mod/timing.d.ts +29 -0
- package/dist/mod/timing.d.ts.map +1 -0
- package/dist/mod/timing.js +61 -0
- package/dist/mod/timing.js.map +1 -0
- package/dist/mod/types.d.ts +8 -0
- package/dist/mod/types.d.ts.map +1 -0
- package/dist/mod/types.js +2 -0
- package/dist/mod/types.js.map +1 -0
- package/dist/musicxml/duration-map.d.ts +34 -0
- package/dist/musicxml/duration-map.d.ts.map +1 -0
- package/dist/musicxml/duration-map.js +79 -0
- package/dist/musicxml/duration-map.js.map +1 -0
- package/dist/musicxml/export.d.ts +35 -0
- package/dist/musicxml/export.d.ts.map +1 -0
- package/dist/musicxml/export.js +271 -0
- package/dist/musicxml/export.js.map +1 -0
- package/dist/musicxml/import.d.ts +52 -0
- package/dist/musicxml/import.d.ts.map +1 -0
- package/dist/musicxml/import.js +637 -0
- package/dist/musicxml/import.js.map +1 -0
- package/dist/test/fixtures.d.ts +45 -0
- package/dist/test/fixtures.d.ts.map +1 -0
- package/dist/test/fixtures.js +420 -0
- package/dist/test/fixtures.js.map +1 -0
- package/dist/test/musicxml-warnings.d.ts +11 -0
- package/dist/test/musicxml-warnings.d.ts.map +1 -0
- package/dist/test/musicxml-warnings.js +26 -0
- package/dist/test/musicxml-warnings.js.map +1 -0
- package/dist/test/platform.d.ts +13 -0
- package/dist/test/platform.d.ts.map +1 -0
- package/dist/test/platform.js +191 -0
- package/dist/test/platform.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { XmlParser } from '@sudobility/music_types';
|
|
2
|
+
import type { Score } from '@sudobility/music_types';
|
|
3
|
+
/**
|
|
4
|
+
* Every warning this importer can raise, in the caller's words.
|
|
5
|
+
*
|
|
6
|
+
* Functions where the warning names something it found — a clef, a tag, a
|
|
7
|
+
* tempo — so the host can put the value where its own grammar wants it rather
|
|
8
|
+
* than having English word order baked in here.
|
|
9
|
+
*/
|
|
10
|
+
export type MusicXmlWarnings = {
|
|
11
|
+
unsupportedClef: (sign: string, line: number) => string;
|
|
12
|
+
unsupportedKeyMode: (mode: string) => string;
|
|
13
|
+
unsupportedTime: (measureNumber: number) => string;
|
|
14
|
+
complexTimeSignature: string;
|
|
15
|
+
clefChangeDropped: (clef: string, measureNumber: number) => string;
|
|
16
|
+
unsupportedPitchStep: (step: string) => string;
|
|
17
|
+
alterRounded: (alter: string, clamped: number) => string;
|
|
18
|
+
graceNotes: string;
|
|
19
|
+
lyrics: string;
|
|
20
|
+
tuplets: string;
|
|
21
|
+
ornaments: string;
|
|
22
|
+
unsupportedNotation: (tag: string) => string;
|
|
23
|
+
unsupportedNoteElement: (tag: string) => string;
|
|
24
|
+
unsupportedArticulation: (tag: string) => string;
|
|
25
|
+
multipleArticulations: string;
|
|
26
|
+
unpitched: string;
|
|
27
|
+
noPitchOrRest: string;
|
|
28
|
+
noDuration: string;
|
|
29
|
+
nonPositiveDuration: string;
|
|
30
|
+
noteTrimmed: string;
|
|
31
|
+
unsupportedMeasureElement: (tag: string) => string;
|
|
32
|
+
noTempo: (defaultBpm: number) => string;
|
|
33
|
+
tempoClamped: (bpm: number, min: number, max: number, clamped: number) => string;
|
|
34
|
+
};
|
|
35
|
+
export type MusicXmlImportResult = {
|
|
36
|
+
score: Score;
|
|
37
|
+
warnings: string[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Imports a MusicXML (score-partwise) document string as a `Score` (spec
|
|
41
|
+
* §17): part-list (name/instrument/MIDI program/channel) -> tracks,
|
|
42
|
+
* measures (attributes converted to 480-ppq divisions, key/time/clef),
|
|
43
|
+
* notes/rests/chords/voices (via `<backup>`/`<forward>` cursor tracking),
|
|
44
|
+
* ties, articulations, and tempo directions. Never throws for
|
|
45
|
+
* unsupported-but-valid musical content (lyrics, grace notes, ornaments,
|
|
46
|
+
* unsupported tuplet ratios/clefs/key modes, ...) — those are skipped and
|
|
47
|
+
* reported once each in the returned `warnings`. Throws only for input
|
|
48
|
+
* that isn't parseable XML, or whose root isn't `<score-partwise>`
|
|
49
|
+
* (score-timewise documents aren't supported).
|
|
50
|
+
*/
|
|
51
|
+
export declare function importMusicXml(xmlText: string, parser: XmlParser, warningText: MusicXmlWarnings): MusicXmlImportResult;
|
|
52
|
+
//# sourceMappingURL=import.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/musicxml/import.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACrE,OAAO,KAAK,EAUV,KAAK,EAIN,MAAM,yBAAyB,CAAC;AAoDjC;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,kBAAkB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7C,eAAe,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC;IACnD,oBAAoB,EAAE,MAAM,CAAC;IAC7B,iBAAiB,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC;IACnE,oBAAoB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACzD,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,mBAAmB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7C,sBAAsB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAChD,uBAAuB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACjD,qBAAqB,EAAE,MAAM,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,yBAAyB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACnD,OAAO,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACxC,YAAY,EAAE,CACZ,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,KACZ,MAAM,CAAC;CACb,CAAC;AA8rBF,MAAM,MAAM,oBAAoB,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAExE;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,SAAS,EACjB,WAAW,EAAE,gBAAgB,GAC5B,oBAAoB,CAyDtB"}
|
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MusicXML (score-partwise) -> `Score` import (spec §17), via `DOMParser`
|
|
3
|
+
* (available in both the browser and Vitest/jsdom, per the Task 8 brief —
|
|
4
|
+
* no XML-parsing library dependency).
|
|
5
|
+
*
|
|
6
|
+
* Unlike MIDI, MusicXML already carries full notation semantics (exact
|
|
7
|
+
* durations, voices, ties, articulations, ...), so this import is a
|
|
8
|
+
* structural translation rather than a heuristic reconstruction — but the
|
|
9
|
+
* format is large, and real-world files routinely contain elements this
|
|
10
|
+
* adapter's "basic support" (spec §17) doesn't cover (lyrics, grace notes,
|
|
11
|
+
* ornaments, tuplet ratios, unusual clefs, ...). Those are always skipped
|
|
12
|
+
* safely (never thrown on) and reported once each in the returned
|
|
13
|
+
* `warnings` array — never silently.
|
|
14
|
+
*
|
|
15
|
+
* MusicXML has no standard per-note velocity (see `export.ts`'s header);
|
|
16
|
+
* every imported note is given the domain default velocity, 80.
|
|
17
|
+
*/
|
|
18
|
+
import { createId } from '@sudobility/music_types';
|
|
19
|
+
import { measureDurationTicks } from '@sudobility/music_types';
|
|
20
|
+
import { ticksForNotatedType, isMusicXmlNoteType } from './duration-map.js';
|
|
21
|
+
/** The score model's fixed internal PPQ (spec §4/§17: every import is normalized to 480). */
|
|
22
|
+
const SCORE_PPQ = 480;
|
|
23
|
+
const DEFAULT_VELOCITY = 80;
|
|
24
|
+
const MIN_BPM = 20;
|
|
25
|
+
const MAX_BPM = 400;
|
|
26
|
+
const DEFAULT_TIME_SIGNATURE = { numerator: 4, denominator: 4 };
|
|
27
|
+
const DEFAULT_KEY_SIGNATURE = { fifths: 0, mode: 'major' };
|
|
28
|
+
const DEFAULT_TEMPO_BPM = 120;
|
|
29
|
+
const VALID_STEPS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
|
|
30
|
+
/** MusicXML `<articulations>` child element name -> domain `Articulation`. */
|
|
31
|
+
const ARTICULATION_FROM_ELEMENT = {
|
|
32
|
+
staccato: 'staccato',
|
|
33
|
+
accent: 'accent',
|
|
34
|
+
tenuto: 'tenuto',
|
|
35
|
+
'strong-accent': 'marcato',
|
|
36
|
+
};
|
|
37
|
+
// ---- Small DOM helpers -------------------------------------------------------
|
|
38
|
+
function directChild(el, tagName) {
|
|
39
|
+
for (const child of Array.from(el.children)) {
|
|
40
|
+
if (child.tagName === tagName)
|
|
41
|
+
return child;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function directChildren(el, tagName) {
|
|
46
|
+
return Array.from(el.children).filter(c => c.tagName === tagName);
|
|
47
|
+
}
|
|
48
|
+
function textOf(el, tagName) {
|
|
49
|
+
if (!el)
|
|
50
|
+
return null;
|
|
51
|
+
return directChild(el, tagName)?.textContent?.trim() ?? null;
|
|
52
|
+
}
|
|
53
|
+
function numberOf(el, tagName) {
|
|
54
|
+
const text = el ? textOf(el, tagName) : null;
|
|
55
|
+
if (text === null)
|
|
56
|
+
return null;
|
|
57
|
+
const n = Number(text);
|
|
58
|
+
return Number.isFinite(n) ? n : null;
|
|
59
|
+
}
|
|
60
|
+
/** Collects warnings, deduplicated by exact message text (so a repeated unsupported element only appears once). */
|
|
61
|
+
class WarningCollector {
|
|
62
|
+
/** The caller's warning text; already threaded to every site that warns. */
|
|
63
|
+
constructor(text) {
|
|
64
|
+
this.text = text;
|
|
65
|
+
this.seen = new Set();
|
|
66
|
+
this.messages = [];
|
|
67
|
+
}
|
|
68
|
+
add(message) {
|
|
69
|
+
if (this.seen.has(message))
|
|
70
|
+
return;
|
|
71
|
+
this.seen.add(message);
|
|
72
|
+
this.messages.push(message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function parsePartList(root) {
|
|
76
|
+
const result = new Map();
|
|
77
|
+
const partList = directChild(root, 'part-list');
|
|
78
|
+
if (!partList)
|
|
79
|
+
return result;
|
|
80
|
+
for (const scorePart of directChildren(partList, 'score-part')) {
|
|
81
|
+
const id = scorePart.getAttribute('id');
|
|
82
|
+
if (!id)
|
|
83
|
+
continue;
|
|
84
|
+
const name = textOf(scorePart, 'part-name') ?? '';
|
|
85
|
+
const instrumentEl = directChild(scorePart, 'score-instrument');
|
|
86
|
+
const instrumentName = (instrumentEl ? textOf(instrumentEl, 'instrument-name') : null) ?? '';
|
|
87
|
+
const midiInstrument = directChild(scorePart, 'midi-instrument');
|
|
88
|
+
const midiChannel = (midiInstrument ? numberOf(midiInstrument, 'midi-channel') : null) ?? 1;
|
|
89
|
+
const midiProgram = (midiInstrument ? numberOf(midiInstrument, 'midi-program') : null) ?? 1;
|
|
90
|
+
result.set(id, { name, instrumentName, midiProgram, midiChannel });
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
// ---- Clef -----------------------------------------------------------------------
|
|
95
|
+
const CLEF_FROM_SIGN = [
|
|
96
|
+
{ sign: 'G', line: 2, clef: 'treble' },
|
|
97
|
+
{ sign: 'F', line: 4, clef: 'bass' },
|
|
98
|
+
{ sign: 'C', line: 3, clef: 'alto' },
|
|
99
|
+
{ sign: 'C', line: 4, clef: 'tenor' },
|
|
100
|
+
];
|
|
101
|
+
function defaultLineForSign(sign) {
|
|
102
|
+
if (sign === 'G')
|
|
103
|
+
return 2;
|
|
104
|
+
if (sign === 'F')
|
|
105
|
+
return 4;
|
|
106
|
+
if (sign === 'C')
|
|
107
|
+
return 3;
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
function parseClef(clefEl, warnings) {
|
|
111
|
+
const sign = textOf(clefEl, 'sign') ?? '';
|
|
112
|
+
if (sign === 'percussion' || sign === 'none')
|
|
113
|
+
return 'percussion';
|
|
114
|
+
const line = numberOf(clefEl, 'line') ?? defaultLineForSign(sign);
|
|
115
|
+
const match = CLEF_FROM_SIGN.find(c => c.sign === sign && c.line === line);
|
|
116
|
+
if (match)
|
|
117
|
+
return match.clef;
|
|
118
|
+
warnings.add(warnings.text.unsupportedClef(sign, line));
|
|
119
|
+
return 'treble';
|
|
120
|
+
}
|
|
121
|
+
function applyAttributes(attrs, state, measureNumber, warnings) {
|
|
122
|
+
const divisions = numberOf(attrs, 'divisions');
|
|
123
|
+
if (divisions !== null && divisions > 0) {
|
|
124
|
+
state.ratio = SCORE_PPQ / divisions;
|
|
125
|
+
}
|
|
126
|
+
const keyEl = directChild(attrs, 'key');
|
|
127
|
+
if (keyEl) {
|
|
128
|
+
const fifths = numberOf(keyEl, 'fifths') ?? 0;
|
|
129
|
+
const modeText = textOf(keyEl, 'mode');
|
|
130
|
+
let mode = 'major';
|
|
131
|
+
if (modeText === 'major' || modeText === 'minor') {
|
|
132
|
+
mode = modeText;
|
|
133
|
+
}
|
|
134
|
+
else if (modeText !== null) {
|
|
135
|
+
warnings.add(warnings.text.unsupportedKeyMode(modeText));
|
|
136
|
+
}
|
|
137
|
+
state.keySignature = { fifths: Math.round(fifths), mode };
|
|
138
|
+
}
|
|
139
|
+
const timeEl = directChild(attrs, 'time');
|
|
140
|
+
if (timeEl) {
|
|
141
|
+
const beatsEls = directChildren(timeEl, 'beats');
|
|
142
|
+
const beatTypeEls = directChildren(timeEl, 'beat-type');
|
|
143
|
+
if (beatsEls.length === 0 || beatTypeEls.length === 0) {
|
|
144
|
+
warnings.add(`A senza-misura or otherwise unsupported <time> element at measure ${measureNumber} did not change the time signature; the previous value was kept (4/4 if none had been set yet).`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (beatsEls.length > 1) {
|
|
148
|
+
warnings.add(warnings.text.complexTimeSignature);
|
|
149
|
+
}
|
|
150
|
+
const numerator = Number(beatsEls[0].textContent);
|
|
151
|
+
const denominator = Number(beatTypeEls[0].textContent);
|
|
152
|
+
if (Number.isFinite(numerator) &&
|
|
153
|
+
Number.isFinite(denominator) &&
|
|
154
|
+
numerator > 0 &&
|
|
155
|
+
denominator > 0) {
|
|
156
|
+
state.timeSignature = { numerator, denominator };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const clefEl = directChild(attrs, 'clef');
|
|
161
|
+
if (clefEl) {
|
|
162
|
+
const clef = parseClef(clefEl, warnings);
|
|
163
|
+
if (state.clef === null) {
|
|
164
|
+
// First clef seen for this part locks the track's clef: the domain
|
|
165
|
+
// model (`Track.clef`) has no per-measure clef, so only one clef per
|
|
166
|
+
// track can be represented at all (see `MeasureState.clef`'s doc).
|
|
167
|
+
state.clef = clef;
|
|
168
|
+
}
|
|
169
|
+
else if (clef !== state.clef) {
|
|
170
|
+
warnings.add(`A clef change to ${clef} at measure ${measureNumber} was dropped (the domain model supports only one clef per track); the track kept its first clef, ${state.clef}.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function parseStep(text, warnings) {
|
|
175
|
+
if (text && VALID_STEPS.includes(text))
|
|
176
|
+
return text;
|
|
177
|
+
warnings.add(warnings.text.unsupportedPitchStep(text ?? ''));
|
|
178
|
+
return 'C';
|
|
179
|
+
}
|
|
180
|
+
function parseAlter(alterText, warnings) {
|
|
181
|
+
if (alterText === null)
|
|
182
|
+
return 0;
|
|
183
|
+
const raw = Number(alterText);
|
|
184
|
+
if (!Number.isFinite(raw))
|
|
185
|
+
return 0;
|
|
186
|
+
const rounded = Math.round(raw);
|
|
187
|
+
const clamped = Math.max(-2, Math.min(2, rounded));
|
|
188
|
+
if (clamped !== raw) {
|
|
189
|
+
warnings.add(warnings.text.alterRounded(alterText, clamped));
|
|
190
|
+
}
|
|
191
|
+
return clamped;
|
|
192
|
+
}
|
|
193
|
+
function parsePitch(pitchEl, warnings) {
|
|
194
|
+
const step = parseStep(textOf(pitchEl, 'step'), warnings);
|
|
195
|
+
const alter = parseAlter(textOf(pitchEl, 'alter'), warnings);
|
|
196
|
+
const octave = numberOf(pitchEl, 'octave') ?? 4;
|
|
197
|
+
return { step, accidental: alter, octave };
|
|
198
|
+
}
|
|
199
|
+
/** Recognized-and-ignored purely-typographic note children; never warned about. */
|
|
200
|
+
const SILENTLY_IGNORED_NOTE_CHILDREN = new Set([
|
|
201
|
+
'chord',
|
|
202
|
+
'pitch',
|
|
203
|
+
'rest',
|
|
204
|
+
'unpitched',
|
|
205
|
+
'duration',
|
|
206
|
+
'tie',
|
|
207
|
+
'voice',
|
|
208
|
+
'type',
|
|
209
|
+
'dot',
|
|
210
|
+
'accidental',
|
|
211
|
+
'stem',
|
|
212
|
+
'notehead',
|
|
213
|
+
'notehead-text',
|
|
214
|
+
'staff',
|
|
215
|
+
'beam',
|
|
216
|
+
'instrument',
|
|
217
|
+
'footnote',
|
|
218
|
+
'level',
|
|
219
|
+
'play',
|
|
220
|
+
'listen',
|
|
221
|
+
]);
|
|
222
|
+
function warnUnsupportedNoteChildren(noteEl, warnings) {
|
|
223
|
+
for (const child of Array.from(noteEl.children)) {
|
|
224
|
+
if (SILENTLY_IGNORED_NOTE_CHILDREN.has(child.tagName))
|
|
225
|
+
continue;
|
|
226
|
+
if (child.tagName === 'grace') {
|
|
227
|
+
warnings.add(warnings.text.graceNotes);
|
|
228
|
+
}
|
|
229
|
+
else if (child.tagName === 'lyric') {
|
|
230
|
+
warnings.add(warnings.text.lyrics);
|
|
231
|
+
}
|
|
232
|
+
else if (child.tagName === 'time-modification') {
|
|
233
|
+
warnings.add(warnings.text.tuplets);
|
|
234
|
+
}
|
|
235
|
+
else if (child.tagName === 'notations') {
|
|
236
|
+
for (const notationChild of Array.from(child.children)) {
|
|
237
|
+
if (notationChild.tagName === 'articulations' ||
|
|
238
|
+
notationChild.tagName === 'tied')
|
|
239
|
+
continue;
|
|
240
|
+
if (notationChild.tagName === 'ornaments') {
|
|
241
|
+
warnings.add(warnings.text.ornaments);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
warnings.add(warnings.text.unsupportedNotation(notationChild.tagName));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
warnings.add(warnings.text.unsupportedNoteElement(child.tagName));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function parseArticulation(noteEl, warnings) {
|
|
254
|
+
const notations = directChild(noteEl, 'notations');
|
|
255
|
+
const articulationsEl = notations
|
|
256
|
+
? directChild(notations, 'articulations')
|
|
257
|
+
: null;
|
|
258
|
+
if (!articulationsEl)
|
|
259
|
+
return undefined;
|
|
260
|
+
let found;
|
|
261
|
+
let extraCount = 0;
|
|
262
|
+
for (const child of Array.from(articulationsEl.children)) {
|
|
263
|
+
const mapped = ARTICULATION_FROM_ELEMENT[child.tagName];
|
|
264
|
+
if (mapped) {
|
|
265
|
+
if (found === undefined) {
|
|
266
|
+
found = mapped;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
extraCount += 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
warnings.add(warnings.text.unsupportedArticulation(child.tagName));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (extraCount > 0) {
|
|
277
|
+
warnings.add(warnings.text.multipleArticulations);
|
|
278
|
+
}
|
|
279
|
+
return found;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Parses one `<note>` element into a `RawEvent` (measure-relative ticks).
|
|
283
|
+
* `cursor` is the shared measure-relative tick cursor *before* this note;
|
|
284
|
+
* `measureEndTick` bounds a `<rest measure="yes">` shorthand.
|
|
285
|
+
*/
|
|
286
|
+
function parseNote(noteEl, cursor, measureEndTick, ratio, warnings) {
|
|
287
|
+
warnUnsupportedNoteChildren(noteEl, warnings);
|
|
288
|
+
if (directChild(noteEl, 'grace')) {
|
|
289
|
+
return { raw: null, cursorAdvance: 0 };
|
|
290
|
+
}
|
|
291
|
+
const restEl = directChild(noteEl, 'rest');
|
|
292
|
+
const pitchEl = directChild(noteEl, 'pitch');
|
|
293
|
+
const unpitchedEl = directChild(noteEl, 'unpitched');
|
|
294
|
+
const hasUsablePitchOrRest = Boolean(restEl) || Boolean(pitchEl);
|
|
295
|
+
if (!hasUsablePitchOrRest) {
|
|
296
|
+
warnings.add(unpitchedEl ? warnings.text.unpitched : warnings.text.noPitchOrRest);
|
|
297
|
+
}
|
|
298
|
+
const durationEl = directChild(noteEl, 'duration');
|
|
299
|
+
let durationTicks;
|
|
300
|
+
if (durationEl) {
|
|
301
|
+
const xmlDuration = Number(durationEl.textContent);
|
|
302
|
+
durationTicks = Number.isFinite(xmlDuration)
|
|
303
|
+
? Math.round(xmlDuration * ratio)
|
|
304
|
+
: 1;
|
|
305
|
+
}
|
|
306
|
+
else if (restEl && restEl.getAttribute('measure') === 'yes') {
|
|
307
|
+
durationTicks = Math.max(1, measureEndTick - cursor);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const typeText = textOf(noteEl, 'type');
|
|
311
|
+
const dots = directChildren(noteEl, 'dot').length;
|
|
312
|
+
if (typeText && isMusicXmlNoteType(typeText)) {
|
|
313
|
+
durationTicks = ticksForNotatedType(typeText, dots, SCORE_PPQ);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
warnings.add(warnings.text.noDuration);
|
|
317
|
+
return { raw: null, cursorAdvance: 0 };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (durationTicks <= 0) {
|
|
321
|
+
warnings.add(warnings.text.nonPositiveDuration);
|
|
322
|
+
durationTicks = 1;
|
|
323
|
+
}
|
|
324
|
+
if (!hasUsablePitchOrRest) {
|
|
325
|
+
// Skipped for lack of pitch/rest info, but its duration is known, so
|
|
326
|
+
// the cursor still advances by it (see `ParsedNote.cursorAdvance`).
|
|
327
|
+
return { raw: null, cursorAdvance: durationTicks };
|
|
328
|
+
}
|
|
329
|
+
const pitch = restEl ? null : pitchEl ? parsePitch(pitchEl, warnings) : null;
|
|
330
|
+
const voiceNumber = textOf(noteEl, 'voice') ?? '1';
|
|
331
|
+
const articulation = restEl ? undefined : parseArticulation(noteEl, warnings);
|
|
332
|
+
const tieEls = directChildren(noteEl, 'tie');
|
|
333
|
+
const tieStart = tieEls.some(t => t.getAttribute('type') === 'start');
|
|
334
|
+
const tieStop = tieEls.some(t => t.getAttribute('type') === 'stop');
|
|
335
|
+
const raw = {
|
|
336
|
+
startTick: cursor,
|
|
337
|
+
durationTicks,
|
|
338
|
+
pitch,
|
|
339
|
+
voiceNumber,
|
|
340
|
+
articulation,
|
|
341
|
+
tieStart,
|
|
342
|
+
tieStop,
|
|
343
|
+
};
|
|
344
|
+
return { raw, cursorAdvance: durationTicks };
|
|
345
|
+
}
|
|
346
|
+
// ---- Gap-filling within a measure's voice ------------------------------------
|
|
347
|
+
/**
|
|
348
|
+
* Fills the gaps in one measure-voice's raw (measure-relative) events with
|
|
349
|
+
* rests, trims any event extending past the measure end, and offsets every
|
|
350
|
+
* event by `measureStartTick` to produce absolute score ticks — so the
|
|
351
|
+
* voice's events cover `[measureStartTick, measureStartTick + durationTicks)`
|
|
352
|
+
* exactly (spec §23's "voice content sums exactly to measure duration"
|
|
353
|
+
* invariant). The counterpart of `adapters/midi/measures.ts`'s private
|
|
354
|
+
* `fillVoiceGaps`, kept as a local copy since import here works from
|
|
355
|
+
* already-measure-scoped XML content rather than a continuous cross-measure
|
|
356
|
+
* timeline.
|
|
357
|
+
*/
|
|
358
|
+
function fillAndClip(raw, durationTicks, measureStartTick, trackId, voiceId, warnings) {
|
|
359
|
+
const sorted = [...raw].sort((a, b) => a.startTick - b.startTick);
|
|
360
|
+
const events = [];
|
|
361
|
+
let cursor = 0;
|
|
362
|
+
for (const item of sorted) {
|
|
363
|
+
const start = Math.max(0, item.startTick);
|
|
364
|
+
if (start > cursor) {
|
|
365
|
+
events.push({
|
|
366
|
+
id: createId(),
|
|
367
|
+
startTick: measureStartTick + cursor,
|
|
368
|
+
durationTicks: start - cursor,
|
|
369
|
+
voiceId,
|
|
370
|
+
trackId,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
let duration = item.durationTicks;
|
|
374
|
+
if (start + duration > durationTicks) {
|
|
375
|
+
warnings.add(warnings.text.noteTrimmed);
|
|
376
|
+
duration = Math.max(1, durationTicks - start);
|
|
377
|
+
}
|
|
378
|
+
if (item.pitch) {
|
|
379
|
+
const note = {
|
|
380
|
+
id: createId(),
|
|
381
|
+
pitch: item.pitch,
|
|
382
|
+
startTick: measureStartTick + start,
|
|
383
|
+
durationTicks: duration,
|
|
384
|
+
velocity: DEFAULT_VELOCITY,
|
|
385
|
+
voiceId,
|
|
386
|
+
trackId,
|
|
387
|
+
...(item.tieStart ? { tieStart: true } : {}),
|
|
388
|
+
...(item.tieStop ? { tieStop: true } : {}),
|
|
389
|
+
...(item.articulation ? { articulation: item.articulation } : {}),
|
|
390
|
+
};
|
|
391
|
+
events.push(note);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const rest = {
|
|
395
|
+
id: createId(),
|
|
396
|
+
startTick: measureStartTick + start,
|
|
397
|
+
durationTicks: duration,
|
|
398
|
+
voiceId,
|
|
399
|
+
trackId,
|
|
400
|
+
};
|
|
401
|
+
events.push(rest);
|
|
402
|
+
}
|
|
403
|
+
cursor = Math.max(cursor, start + duration);
|
|
404
|
+
}
|
|
405
|
+
if (cursor < durationTicks) {
|
|
406
|
+
events.push({
|
|
407
|
+
id: createId(),
|
|
408
|
+
startTick: measureStartTick + cursor,
|
|
409
|
+
durationTicks: durationTicks - cursor,
|
|
410
|
+
voiceId,
|
|
411
|
+
trackId,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return events;
|
|
415
|
+
}
|
|
416
|
+
function parseMeasure(measureEl, index, startTick, state, trackId, warnings) {
|
|
417
|
+
const buckets = new Map();
|
|
418
|
+
const tempoEvents = [];
|
|
419
|
+
let cursor = 0;
|
|
420
|
+
/** The position `<chord/>`-flagged notes attach to: the cursor value at the start of the current (non-chord) note. */
|
|
421
|
+
let lastNoteStart = 0;
|
|
422
|
+
const knownTags = new Set([
|
|
423
|
+
'attributes',
|
|
424
|
+
'note',
|
|
425
|
+
'backup',
|
|
426
|
+
'forward',
|
|
427
|
+
'direction',
|
|
428
|
+
'print',
|
|
429
|
+
'barline',
|
|
430
|
+
'sound',
|
|
431
|
+
]);
|
|
432
|
+
for (const child of Array.from(measureEl.children)) {
|
|
433
|
+
switch (child.tagName) {
|
|
434
|
+
case 'attributes':
|
|
435
|
+
applyAttributes(child, state, index + 1, warnings);
|
|
436
|
+
break;
|
|
437
|
+
case 'direction': {
|
|
438
|
+
const soundEl = directChild(child, 'sound');
|
|
439
|
+
const tempoAttr = soundEl?.getAttribute('tempo');
|
|
440
|
+
if (tempoAttr) {
|
|
441
|
+
const bpmRaw = Number(tempoAttr);
|
|
442
|
+
if (Number.isFinite(bpmRaw)) {
|
|
443
|
+
const offsetEl = directChild(child, 'offset');
|
|
444
|
+
const offsetTicks = offsetEl
|
|
445
|
+
? Math.round(Number(offsetEl.textContent) * state.ratio)
|
|
446
|
+
: 0;
|
|
447
|
+
tempoEvents.push({
|
|
448
|
+
id: createId(),
|
|
449
|
+
tick: startTick + Math.max(0, offsetTicks),
|
|
450
|
+
bpm: bpmRaw,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case 'note': {
|
|
457
|
+
const isChord = Boolean(directChild(child, 'chord'));
|
|
458
|
+
// A chord note shares its "root" (the preceding non-chord note)'s
|
|
459
|
+
// start position; a non-chord note starts wherever the shared
|
|
460
|
+
// cursor currently sits (and becomes the new root for any chord
|
|
461
|
+
// notes that follow it).
|
|
462
|
+
const eventStart = isChord ? lastNoteStart : cursor;
|
|
463
|
+
const measureEndTick = measureDurationTicks(state.timeSignature, SCORE_PPQ);
|
|
464
|
+
const { raw, cursorAdvance } = parseNote(child, eventStart, measureEndTick, state.ratio, warnings);
|
|
465
|
+
if (raw) {
|
|
466
|
+
const bucket = buckets.get(raw.voiceNumber) ?? [];
|
|
467
|
+
buckets.set(raw.voiceNumber, bucket);
|
|
468
|
+
bucket.push(raw);
|
|
469
|
+
}
|
|
470
|
+
if (!isChord) {
|
|
471
|
+
lastNoteStart = cursor;
|
|
472
|
+
cursor += cursorAdvance;
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case 'backup': {
|
|
477
|
+
const d = numberOf(child, 'duration') ?? 0;
|
|
478
|
+
cursor = Math.max(0, cursor - Math.round(d * state.ratio));
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
case 'forward': {
|
|
482
|
+
const d = numberOf(child, 'duration') ?? 0;
|
|
483
|
+
cursor += Math.round(d * state.ratio);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case 'print':
|
|
487
|
+
case 'barline':
|
|
488
|
+
case 'sound':
|
|
489
|
+
break;
|
|
490
|
+
default:
|
|
491
|
+
if (!knownTags.has(child.tagName)) {
|
|
492
|
+
warnings.add(warnings.text.unsupportedMeasureElement(child.tagName));
|
|
493
|
+
}
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Computed only now (after every <attributes> in this measure has been
|
|
498
|
+
// applied to `state`) so a measure whose own <attributes> changes the
|
|
499
|
+
// time signature sizes itself by the *new* signature, not whatever was
|
|
500
|
+
// in effect when this measure started.
|
|
501
|
+
const durationTicks = measureDurationTicks(state.timeSignature, SCORE_PPQ);
|
|
502
|
+
const voiceNumbers = [...buckets.keys()].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
503
|
+
const effectiveVoiceNumbers = voiceNumbers.length > 0 ? voiceNumbers : ['1'];
|
|
504
|
+
const voices = effectiveVoiceNumbers.map((voiceNumber, i) => {
|
|
505
|
+
const voiceId = createId();
|
|
506
|
+
const raw = buckets.get(voiceNumber) ?? [];
|
|
507
|
+
return {
|
|
508
|
+
id: voiceId,
|
|
509
|
+
name: `Voice ${i + 1}`,
|
|
510
|
+
events: fillAndClip(raw, durationTicks, startTick, trackId, voiceId, warnings),
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
const measure = {
|
|
514
|
+
id: createId(),
|
|
515
|
+
index,
|
|
516
|
+
startTick,
|
|
517
|
+
durationTicks,
|
|
518
|
+
timeSignature: state.timeSignature,
|
|
519
|
+
keySignature: state.keySignature,
|
|
520
|
+
voices,
|
|
521
|
+
};
|
|
522
|
+
return { measure, tempoEvents };
|
|
523
|
+
}
|
|
524
|
+
// ---- Part / track assembly ------------------------------------------------------
|
|
525
|
+
function parsePart(partEl, meta, partIndex, warnings) {
|
|
526
|
+
const trackId = createId();
|
|
527
|
+
const state = {
|
|
528
|
+
ratio: 1,
|
|
529
|
+
timeSignature: DEFAULT_TIME_SIGNATURE,
|
|
530
|
+
keySignature: DEFAULT_KEY_SIGNATURE,
|
|
531
|
+
clef: null,
|
|
532
|
+
};
|
|
533
|
+
const measures = [];
|
|
534
|
+
const tempoEvents = [];
|
|
535
|
+
let startTick = 0;
|
|
536
|
+
directChildren(partEl, 'measure').forEach((measureEl, index) => {
|
|
537
|
+
const { measure, tempoEvents: measureTempos } = parseMeasure(measureEl, index, startTick, state, trackId, warnings);
|
|
538
|
+
measures.push(measure);
|
|
539
|
+
tempoEvents.push(...measureTempos);
|
|
540
|
+
startTick += measure.durationTicks;
|
|
541
|
+
});
|
|
542
|
+
const track = {
|
|
543
|
+
id: trackId,
|
|
544
|
+
name: meta?.name && meta.name.length > 0 ? meta.name : `Part ${partIndex + 1}`,
|
|
545
|
+
instrumentName: meta?.instrumentName && meta.instrumentName.length > 0
|
|
546
|
+
? meta.instrumentName
|
|
547
|
+
: 'Instrument',
|
|
548
|
+
midiProgram: Math.min(127, Math.max(0, Math.round((meta?.midiProgram ?? 1) - 1))),
|
|
549
|
+
midiChannel: Math.min(15, Math.max(0, Math.round((meta?.midiChannel ?? 1) - 1))),
|
|
550
|
+
clef: state.clef ?? 'treble',
|
|
551
|
+
volume: 1,
|
|
552
|
+
pan: 0,
|
|
553
|
+
muted: false,
|
|
554
|
+
solo: false,
|
|
555
|
+
measures,
|
|
556
|
+
};
|
|
557
|
+
return { track, tempoEvents };
|
|
558
|
+
}
|
|
559
|
+
// ---- Tempo map ------------------------------------------------------------------
|
|
560
|
+
function buildTempoMap(rawTempos, warnings) {
|
|
561
|
+
const byTick = new Map();
|
|
562
|
+
for (const t of rawTempos) {
|
|
563
|
+
if (!byTick.has(t.tick))
|
|
564
|
+
byTick.set(t.tick, t.bpm);
|
|
565
|
+
}
|
|
566
|
+
const sorted = [...byTick.entries()].sort((a, b) => a[0] - b[0]);
|
|
567
|
+
const withOrigin = sorted.length > 0 && sorted[0][0] === 0
|
|
568
|
+
? sorted
|
|
569
|
+
: [[0, DEFAULT_TEMPO_BPM], ...sorted];
|
|
570
|
+
if (sorted.length === 0) {
|
|
571
|
+
warnings.add(warnings.text.noTempo(DEFAULT_TEMPO_BPM));
|
|
572
|
+
}
|
|
573
|
+
return withOrigin.map(([tick, bpm]) => {
|
|
574
|
+
const clamped = Math.min(MAX_BPM, Math.max(MIN_BPM, bpm));
|
|
575
|
+
if (clamped !== bpm) {
|
|
576
|
+
warnings.add(warnings.text.tempoClamped(bpm, MIN_BPM, MAX_BPM, clamped));
|
|
577
|
+
}
|
|
578
|
+
return { id: createId(), tick, bpm: clamped };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Imports a MusicXML (score-partwise) document string as a `Score` (spec
|
|
583
|
+
* §17): part-list (name/instrument/MIDI program/channel) -> tracks,
|
|
584
|
+
* measures (attributes converted to 480-ppq divisions, key/time/clef),
|
|
585
|
+
* notes/rests/chords/voices (via `<backup>`/`<forward>` cursor tracking),
|
|
586
|
+
* ties, articulations, and tempo directions. Never throws for
|
|
587
|
+
* unsupported-but-valid musical content (lyrics, grace notes, ornaments,
|
|
588
|
+
* unsupported tuplet ratios/clefs/key modes, ...) — those are skipped and
|
|
589
|
+
* reported once each in the returned `warnings`. Throws only for input
|
|
590
|
+
* that isn't parseable XML, or whose root isn't `<score-partwise>`
|
|
591
|
+
* (score-timewise documents aren't supported).
|
|
592
|
+
*/
|
|
593
|
+
export function importMusicXml(xmlText, parser, warningText) {
|
|
594
|
+
// Well-formedness is the parser's business -- it throws XmlParseError -- so
|
|
595
|
+
// all that is left here is rejecting a document that parses but is not a
|
|
596
|
+
// score-partwise.
|
|
597
|
+
const root = parser.parse(xmlText);
|
|
598
|
+
if (root.tagName !== 'score-partwise') {
|
|
599
|
+
throw new Error(`importMusicXml: expected a <score-partwise> root element, found <${root.tagName}>. score-timewise documents are not supported.`);
|
|
600
|
+
}
|
|
601
|
+
const warnings = new WarningCollector(warningText);
|
|
602
|
+
const partMeta = parsePartList(root);
|
|
603
|
+
const tracks = [];
|
|
604
|
+
const allTempoEvents = [];
|
|
605
|
+
Array.from(root.children)
|
|
606
|
+
.filter(el => el.tagName === 'part')
|
|
607
|
+
.forEach((partEl, index) => {
|
|
608
|
+
const id = partEl.getAttribute('id');
|
|
609
|
+
const { track, tempoEvents } = parsePart(partEl, id ? partMeta.get(id) : undefined, index, warnings);
|
|
610
|
+
tracks.push(track);
|
|
611
|
+
allTempoEvents.push(...tempoEvents);
|
|
612
|
+
});
|
|
613
|
+
const tempoMap = buildTempoMap(allTempoEvents, warnings);
|
|
614
|
+
const workTitle = textOf(directChild(root, 'work'), 'work-title');
|
|
615
|
+
const identification = directChild(root, 'identification');
|
|
616
|
+
const composer = identification
|
|
617
|
+
? Array.from(directChildren(identification, 'creator'))
|
|
618
|
+
.find(c => c.getAttribute('type') === 'composer')
|
|
619
|
+
?.textContent?.trim()
|
|
620
|
+
: undefined;
|
|
621
|
+
const now = new Date().toISOString();
|
|
622
|
+
const score = {
|
|
623
|
+
id: createId(),
|
|
624
|
+
version: 1,
|
|
625
|
+
ppq: SCORE_PPQ,
|
|
626
|
+
metadata: {
|
|
627
|
+
title: workTitle && workTitle.length > 0 ? workTitle : 'Imported MusicXML',
|
|
628
|
+
...(composer ? { composer } : {}),
|
|
629
|
+
createdAt: now,
|
|
630
|
+
updatedAt: now,
|
|
631
|
+
},
|
|
632
|
+
tempoMap,
|
|
633
|
+
tracks,
|
|
634
|
+
};
|
|
635
|
+
return { score, warnings: warnings.messages };
|
|
636
|
+
}
|
|
637
|
+
//# sourceMappingURL=import.js.map
|