@k-l-lambda/lilylet 0.1.89 → 0.1.90
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/lilylet/clefTransposition.d.ts +24 -0
- package/lib/lilylet/clefTransposition.js +42 -0
- package/lib/lilylet/lilypondDecoder.js +88 -2
- package/lib/lilylet/lilypondEncoder.js +32 -2
- package/lib/lilylet/musicXmlDecoder.js +56 -3
- package/lib/lilylet/musicXmlEncoder.js +33 -1
- package/lib/lilylet/musicXmlTypes.d.ts +6 -0
- package/package.json +2 -1
- package/source/lilylet/clefTransposition.ts +48 -0
- package/source/lilylet/lilypondDecoder.ts +89 -2
- package/source/lilylet/lilypondEncoder.ts +36 -2
- package/source/lilylet/musicXmlDecoder.ts +67 -3
- package/source/lilylet/musicXmlEncoder.ts +35 -1
- package/source/lilylet/musicXmlTypes.ts +10 -0
|
@@ -85,3 +85,27 @@ export declare const semitonesToClefSuffix: (semitones: number) => string | unde
|
|
|
85
85
|
* produced wrong sounding pitches before this module existed.
|
|
86
86
|
*/
|
|
87
87
|
export declare const withClefTransposition: (base: string, semitones: number) => string | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Whether LilyPond itself accepts this suffix on a clef name. LilyPond's grammar is
|
|
90
|
+
* `([_^])([^0-9a-zA-Z]*)([1-9][0-9]*)` — a sign then a bare number — so it has no
|
|
91
|
+
* place for the `m` that selects a minor interval, and `\clef "treble_m3"` is
|
|
92
|
+
* rejected outright as an unknown clef type.
|
|
93
|
+
*/
|
|
94
|
+
export declare const isLilyPondClefSuffix: (clefStr: string) => boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The LilyPond `\transposition` pitch that declares a written→sounding shift of
|
|
97
|
+
* `semitones`, or undefined when the shift is beyond the two octaves either side of
|
|
98
|
+
* c' that instrument transpositions occupy.
|
|
99
|
+
*
|
|
100
|
+
* This is the mechanism that carries sounding pitch in LilyPond: `\transposition`
|
|
101
|
+
* takes a pitch, reaches every semitone, and DOES move MIDI, while the clef suffix
|
|
102
|
+
* is purely notational and moves nothing. The two are orthogonal, so a lilylet clef
|
|
103
|
+
* whose suffix means a sounding shift translates to BOTH — the suffix for the glyph
|
|
104
|
+
* (when LilyPond accepts it) and `\transposition` for the sound.
|
|
105
|
+
*
|
|
106
|
+
* The pitch is spelled relative to c', the no-transposition reference: an octave
|
|
107
|
+
* mark `'` per octave above, a `,` per octave below. Compound intervals are real —
|
|
108
|
+
* a major ninth down (-14) is the written form of some orchestral parts — so the
|
|
109
|
+
* range is not limited to a single octave.
|
|
110
|
+
*/
|
|
111
|
+
export declare const lilyPondTranspositionPitch: (semitones: number) => string | undefined;
|
|
@@ -131,3 +131,45 @@ export const withClefTransposition = (base, semitones) => {
|
|
|
131
131
|
const suffix = semitonesToClefSuffix(semitones);
|
|
132
132
|
return suffix === undefined ? undefined : base + suffix;
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Whether LilyPond itself accepts this suffix on a clef name. LilyPond's grammar is
|
|
136
|
+
* `([_^])([^0-9a-zA-Z]*)([1-9][0-9]*)` — a sign then a bare number — so it has no
|
|
137
|
+
* place for the `m` that selects a minor interval, and `\clef "treble_m3"` is
|
|
138
|
+
* rejected outright as an unknown clef type.
|
|
139
|
+
*/
|
|
140
|
+
export const isLilyPondClefSuffix = (clefStr) => {
|
|
141
|
+
const parsed = parseClefSuffix(clefStr);
|
|
142
|
+
return parsed !== undefined && !parsed.minor;
|
|
143
|
+
};
|
|
144
|
+
// Note names for the twelve semitones above c, in ENGLISH spelling (`bf`, not Dutch
|
|
145
|
+
// `bes`) — lilypondEncoder emits `\language "english"`, under which a Dutch name is
|
|
146
|
+
// rejected as "wrong type for argument 1, expecting pitch". Flats throughout: a
|
|
147
|
+
// transposing instrument is named by its flat spelling.
|
|
148
|
+
const ENGLISH_PITCH_NAMES = ["c", "df", "d", "ef", "e", "f", "gf", "g", "af", "a", "bf", "b"];
|
|
149
|
+
/**
|
|
150
|
+
* The LilyPond `\transposition` pitch that declares a written→sounding shift of
|
|
151
|
+
* `semitones`, or undefined when the shift is beyond the two octaves either side of
|
|
152
|
+
* c' that instrument transpositions occupy.
|
|
153
|
+
*
|
|
154
|
+
* This is the mechanism that carries sounding pitch in LilyPond: `\transposition`
|
|
155
|
+
* takes a pitch, reaches every semitone, and DOES move MIDI, while the clef suffix
|
|
156
|
+
* is purely notational and moves nothing. The two are orthogonal, so a lilylet clef
|
|
157
|
+
* whose suffix means a sounding shift translates to BOTH — the suffix for the glyph
|
|
158
|
+
* (when LilyPond accepts it) and `\transposition` for the sound.
|
|
159
|
+
*
|
|
160
|
+
* The pitch is spelled relative to c', the no-transposition reference: an octave
|
|
161
|
+
* mark `'` per octave above, a `,` per octave below. Compound intervals are real —
|
|
162
|
+
* a major ninth down (-14) is the written form of some orchestral parts — so the
|
|
163
|
+
* range is not limited to a single octave.
|
|
164
|
+
*/
|
|
165
|
+
export const lilyPondTranspositionPitch = (semitones) => {
|
|
166
|
+
if (!Number.isInteger(semitones) || Math.abs(semitones) > 24)
|
|
167
|
+
return undefined;
|
|
168
|
+
// Reduce to a pitch class plus an octave count, measuring from c'.
|
|
169
|
+
const within = ((semitones % 12) + 12) % 12;
|
|
170
|
+
const octaves = Math.floor(semitones / 12);
|
|
171
|
+
const name = ENGLISH_PITCH_NAMES[within];
|
|
172
|
+
// c' is the reference and carries one `'`; each octave adds or removes one.
|
|
173
|
+
const marks = 1 + octaves;
|
|
174
|
+
return name + (marks >= 0 ? "'".repeat(marks) : ",".repeat(-marks));
|
|
175
|
+
};
|
|
@@ -10,6 +10,7 @@ import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
|
|
|
10
10
|
// @ts-ignore - CommonJS module
|
|
11
11
|
import * as lilypondParser from "@k-l-lambda/lotus/lib.browser/lib/lilyParser.js";
|
|
12
12
|
import { Clef, StemDirection, Accidental, Phonet, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
|
|
13
|
+
import { clefBaseName, parseClefSuffix, withClefTransposition } from "./clefTransposition.js";
|
|
13
14
|
// Phonet names mapping
|
|
14
15
|
const PHONET_NAMES = {
|
|
15
16
|
0: Phonet.c,
|
|
@@ -37,6 +38,72 @@ const LILYPOND_CLEF_MAP = {
|
|
|
37
38
|
alto: Clef.alto,
|
|
38
39
|
C: Clef.alto,
|
|
39
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Fold a standing `\transposition` shift onto a clef as a lilylet suffix.
|
|
43
|
+
*
|
|
44
|
+
* The two carry different things in LilyPond and must not be added: a clef suffix
|
|
45
|
+
* there is notational and moves no pitch, while `\transposition` moves pitch and
|
|
46
|
+
* draws nothing. lilylet's suffix means sounding shift, so `\transposition` is the
|
|
47
|
+
* authority whenever it is present — a file with BOTH `\clef "treble_8"` and
|
|
48
|
+
* `\transposition c` (which is what lilypondEncoder emits for an octave clef) states
|
|
49
|
+
* one octave, not two.
|
|
50
|
+
*
|
|
51
|
+
* With no `\transposition`, a suffix already on the clef is kept as-is: that is the
|
|
52
|
+
* plain octave-clef part, where the notation is taken to imply its own shift.
|
|
53
|
+
*/
|
|
54
|
+
const applyTransposition = (clef, semitones) => {
|
|
55
|
+
if (!clef || semitones === 0)
|
|
56
|
+
return clef;
|
|
57
|
+
const base = clefBaseName(clef);
|
|
58
|
+
const shifted = withClefTransposition(base, semitones);
|
|
59
|
+
if (shifted === undefined) {
|
|
60
|
+
console.warn(`lilypondDecoder: \\transposition of ${semitones} semitones has no exact clef suffix; dropped for clef "${clef}"`);
|
|
61
|
+
return clef;
|
|
62
|
+
}
|
|
63
|
+
return shifted;
|
|
64
|
+
};
|
|
65
|
+
// Semitones above c for each diatonic step index (0 = c ... 6 = b).
|
|
66
|
+
const PHONET_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
67
|
+
/**
|
|
68
|
+
* Semitone offset from c' of a `\transposition` pitch term, which is how LilyPond
|
|
69
|
+
* states a transposing instrument's written->sounding shift (the clef suffix there is
|
|
70
|
+
* notational and states nothing). Returns 0 for a pitch that cannot be read.
|
|
71
|
+
*/
|
|
72
|
+
const transpositionSemitones = (term) => {
|
|
73
|
+
const el = term?.args?.[0];
|
|
74
|
+
if (!el)
|
|
75
|
+
return 0;
|
|
76
|
+
const step = el.phonetStep;
|
|
77
|
+
const octave = el.absolutePitch?.octave;
|
|
78
|
+
if (typeof step !== 'number' || typeof octave !== 'number')
|
|
79
|
+
return 0;
|
|
80
|
+
// c' is the no-transposition reference, and sits at 12 in this scale.
|
|
81
|
+
return (PHONET_SEMITONES[step] + (el.alterValue || 0) + 12 * octave) - 12;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* A LilyPond clef name to a lilylet Clef, keeping any transposing suffix.
|
|
85
|
+
*
|
|
86
|
+
* The map is keyed on BASE names, so looking up the whole string missed every
|
|
87
|
+
* suffixed clef ("treble_8" is not a key) and `if (clef)` then dropped the clef
|
|
88
|
+
* event altogether — the transposition went with it. Resolve the base through the
|
|
89
|
+
* map and reattach the suffix, which is the form lilylet's own model uses.
|
|
90
|
+
*
|
|
91
|
+
* A suffix LilyPond cannot write (the `m` minor-interval form) cannot appear in
|
|
92
|
+
* LilyPond input, so nothing here has to handle it; parseClefSuffix simply returns
|
|
93
|
+
* undefined for an unsuffixed name and the base is used as-is.
|
|
94
|
+
*/
|
|
95
|
+
const resolveLilyPondClef = (clefName) => {
|
|
96
|
+
const direct = LILYPOND_CLEF_MAP[clefName];
|
|
97
|
+
if (direct)
|
|
98
|
+
return direct;
|
|
99
|
+
const parsed = parseClefSuffix(clefName);
|
|
100
|
+
if (!parsed)
|
|
101
|
+
return undefined;
|
|
102
|
+
const base = LILYPOND_CLEF_MAP[parsed.base];
|
|
103
|
+
if (!base)
|
|
104
|
+
return undefined;
|
|
105
|
+
return (base + clefName.slice(parsed.base.length));
|
|
106
|
+
};
|
|
40
107
|
// Key signature fifths to pitch/accidental mapping
|
|
41
108
|
const KEY_FIFTHS_MAP = {
|
|
42
109
|
[-7]: { pitch: Phonet.c, accidental: Accidental.flat, mode: 'major' },
|
|
@@ -468,6 +535,9 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
468
535
|
let lastKey = undefined; // Track value changes (key fifths)
|
|
469
536
|
let lastTimeSig = undefined; // Track value changes (as string for comparison)
|
|
470
537
|
let lastClef = undefined; // Track value changes
|
|
538
|
+
// \transposition is a standing declaration: it applies to every clef emitted
|
|
539
|
+
// after it, and lilylet folds the shift into the clef suffix.
|
|
540
|
+
let transposeSemis = 0;
|
|
471
541
|
let lastOttava = undefined; // Track value changes
|
|
472
542
|
let lastStemDirection = undefined; // Track value changes
|
|
473
543
|
let partialEmitted = false; // Emit \partial context once per track
|
|
@@ -570,7 +640,7 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
570
640
|
}
|
|
571
641
|
// Handle clef context change (emit when value changes)
|
|
572
642
|
if (context.clef) {
|
|
573
|
-
const clef =
|
|
643
|
+
const clef = applyTransposition(resolveLilyPondClef(context.clef.clefName), transposeSemis);
|
|
574
644
|
if (clef && clef !== lastClef) {
|
|
575
645
|
voice.events.push({
|
|
576
646
|
type: 'context',
|
|
@@ -683,9 +753,25 @@ const parseLilyDocument = (lilyDocument) => {
|
|
|
683
753
|
}
|
|
684
754
|
}
|
|
685
755
|
}
|
|
756
|
+
else if (term instanceof lilyParser.LilyTerms.Transposition) {
|
|
757
|
+
const semis = transpositionSemitones(term);
|
|
758
|
+
if (semis !== transposeSemis) {
|
|
759
|
+
transposeSemis = semis;
|
|
760
|
+
// A `\transposition` AFTER the clef still governs it, so re-emit the
|
|
761
|
+
// clef with the shift folded in rather than let the already-emitted
|
|
762
|
+
// plain clef stand. Order between the two is not fixed in the wild.
|
|
763
|
+
if (lastClef) {
|
|
764
|
+
const corrected = applyTransposition(clefBaseName(lastClef), transposeSemis);
|
|
765
|
+
if (corrected && corrected !== lastClef) {
|
|
766
|
+
voice.events.push({ type: 'context', clef: corrected });
|
|
767
|
+
lastClef = corrected;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
686
772
|
// Handle standalone clef (emit when value changes)
|
|
687
773
|
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
688
|
-
const clef =
|
|
774
|
+
const clef = applyTransposition(resolveLilyPondClef(term.clefName), transposeSemis);
|
|
689
775
|
if (clef && clef !== lastClef) {
|
|
690
776
|
voice.events.push({
|
|
691
777
|
type: 'context',
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Uses relative pitch mode matching LilyPond's default behavior.
|
|
6
6
|
*/
|
|
7
7
|
import { Accidental, } from "./types.js";
|
|
8
|
+
import { clefBaseName, clefSuffixSemitones, isLilyPondClefSuffix, lilyPondTranspositionPitch } from "./clefTransposition.js";
|
|
8
9
|
// === Constants and Mappings ===
|
|
9
10
|
const PHONETS = "cdefgab";
|
|
10
11
|
// Key signature to LilyPond notation (using English note names)
|
|
@@ -227,10 +228,39 @@ const encodeTimeSig = (timeSig) => {
|
|
|
227
228
|
return `\\time ${timeSig.numerator}/${timeSig.denominator}`;
|
|
228
229
|
};
|
|
229
230
|
/**
|
|
230
|
-
* Encode clef to LilyPond
|
|
231
|
+
* Encode clef to LilyPond.
|
|
232
|
+
*
|
|
233
|
+
* A lilylet clef suffix carries a written→sounding shift; LilyPond's does not —
|
|
234
|
+
* there the suffix only repositions middle C on the staff and MIDI is untouched,
|
|
235
|
+
* and sounding pitch comes from the separate `\transposition <pitch>`. The two are
|
|
236
|
+
* orthogonal, so a transposing clef translates to BOTH: the suffix for the glyph,
|
|
237
|
+
* `\transposition` for the sound. Emitting only the suffix (what this did before)
|
|
238
|
+
* produced a file whose notation was right and whose MIDI was a transposition out.
|
|
239
|
+
*
|
|
240
|
+
* The clef string is quoted. LilyPond accepts a bare name unquoted, but `_m3`
|
|
241
|
+
* lexes as `_` followed by `m3` and breaks the parse; quoting is valid for every
|
|
242
|
+
* form. A `m` suffix has no LilyPond glyph at all (unknown clef type), so the base
|
|
243
|
+
* clef is emitted and the shift rides entirely on `\transposition`.
|
|
231
244
|
*/
|
|
232
245
|
const encodeClef = (clef) => {
|
|
233
|
-
|
|
246
|
+
const clefStr = clef;
|
|
247
|
+
const shift = clefSuffixSemitones(clefStr);
|
|
248
|
+
// Keep the suffix only when LilyPond can render it; otherwise fall back to the
|
|
249
|
+
// base name rather than emit a clef LilyPond rejects.
|
|
250
|
+
const name = (shift === 0 || isLilyPondClefSuffix(clefStr)) ? clefStr : clefBaseName(clefStr);
|
|
251
|
+
const mapped = CLEF_MAP[name] || name;
|
|
252
|
+
const parts = [];
|
|
253
|
+
// \transposition comes FIRST so that a reader tracking it as a standing
|
|
254
|
+
// declaration has the shift in hand by the time the clef arrives.
|
|
255
|
+
if (shift !== 0) {
|
|
256
|
+
const pitch = lilyPondTranspositionPitch(shift);
|
|
257
|
+
if (pitch)
|
|
258
|
+
parts.push(`\\transposition ${pitch}`);
|
|
259
|
+
else
|
|
260
|
+
console.warn(`lilypondEncoder: clef "${clefStr}" shift ${shift} has no \\transposition pitch; sounding pitch not declared`);
|
|
261
|
+
}
|
|
262
|
+
parts.push(`\\clef "${mapped}"`);
|
|
263
|
+
return parts.join(' ');
|
|
234
264
|
};
|
|
235
265
|
/**
|
|
236
266
|
* Encode tempo to LilyPond
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { DOMParser } from '@xmldom/xmldom';
|
|
8
8
|
import { HairpinType, NavigationMarkType, OrnamentType, Placement, } from "./types.js";
|
|
9
|
+
import { withClefTransposition } from "./clefTransposition.js";
|
|
9
10
|
import { getElementText, getElementInt, getElements, getDirectChildren, getChildElements, getAttribute, getAttributeNumber, hasElement, convertPitch, convertDuration, convertKeySignature, convertClef, convertStemDirection, convertArticulation, convertOrnament, convertDynamic, convertPedal, convertBarlineStyle, convertHarmonyToText, createFraction, TYPE_TO_DIVISION, } from "./musicXmlUtils.js";
|
|
10
11
|
import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
|
|
11
12
|
// ============ Spanner Tracker ============
|
|
@@ -491,6 +492,20 @@ const parseAttributes = (attrEl) => {
|
|
|
491
492
|
}
|
|
492
493
|
}
|
|
493
494
|
}
|
|
495
|
+
// Instrument transposition. This is what carries a transposing part's sounding
|
|
496
|
+
// pitch in MusicXML (<clef-octave-change> is only the glyph), and it was never
|
|
497
|
+
// read, so every transposing instrument decoded as sounding-as-written.
|
|
498
|
+
const transposeEl = getElements(attrEl, 'transpose')[0];
|
|
499
|
+
if (transposeEl) {
|
|
500
|
+
const chromatic = getElementInt(transposeEl, 'chromatic');
|
|
501
|
+
if (chromatic != null) {
|
|
502
|
+
result.transpose = {
|
|
503
|
+
diatonic: getElementInt(transposeEl, 'diatonic') ?? undefined,
|
|
504
|
+
chromatic,
|
|
505
|
+
octaveChange: getElementInt(transposeEl, 'octave-change') ?? undefined,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
494
509
|
// Staves
|
|
495
510
|
const staves = getElementInt(attrEl, 'staves');
|
|
496
511
|
if (staves !== undefined) {
|
|
@@ -1038,6 +1053,35 @@ const forwardGapToRests = (gapTicks, divisions) => {
|
|
|
1038
1053
|
}
|
|
1039
1054
|
return rests;
|
|
1040
1055
|
};
|
|
1056
|
+
/**
|
|
1057
|
+
* Fold a MusicXML transposition onto a lilylet clef as a suffix.
|
|
1058
|
+
*
|
|
1059
|
+
* MusicXML keeps the two apart: <transpose> is the sounding shift and moves pitch,
|
|
1060
|
+
* <clef-octave-change> is the "8" glyph under the clef and moves nothing. A lilylet
|
|
1061
|
+
* suffix means sounding shift, so only ONE of them may be folded in — adding both
|
|
1062
|
+
* would double an octave clef that also declares <transpose> (the shape this
|
|
1063
|
+
* encoder itself now emits) into two octaves.
|
|
1064
|
+
*
|
|
1065
|
+
* <transpose> wins when present, being the explicit statement of sounding pitch.
|
|
1066
|
+
* Absent it, an octave clef is read as implying its own shift, which is what a
|
|
1067
|
+
* tenor-voice or guitar treble_8 part means.
|
|
1068
|
+
*
|
|
1069
|
+
* A shift with no exact suffix (only the tritone) leaves the clef plain rather than
|
|
1070
|
+
* rounding: a silently wrong sounding pitch is worse than a visibly absent shift.
|
|
1071
|
+
*/
|
|
1072
|
+
const clefWithShift = (clef, transposeSemitones, clefOctaveChange) => {
|
|
1073
|
+
if (!clef)
|
|
1074
|
+
return clef;
|
|
1075
|
+
const semitones = transposeSemitones !== 0 ? transposeSemitones : 12 * (clefOctaveChange ?? 0);
|
|
1076
|
+
if (semitones === 0)
|
|
1077
|
+
return clef;
|
|
1078
|
+
const shifted = withClefTransposition(clef, semitones);
|
|
1079
|
+
if (shifted === undefined) {
|
|
1080
|
+
console.warn(`musicXmlDecoder: transposition ${semitones} has no exact clef suffix; dropped for clef "${clef}"`);
|
|
1081
|
+
return clef;
|
|
1082
|
+
}
|
|
1083
|
+
return shifted;
|
|
1084
|
+
};
|
|
1041
1085
|
/**
|
|
1042
1086
|
* Total time a TupletEvent advances the voice, in voiceTracker duration units.
|
|
1043
1087
|
* Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
|
|
@@ -1054,7 +1098,10 @@ const tupletAdvanceDuration = (tupletEvent, divisions) => {
|
|
|
1054
1098
|
/**
|
|
1055
1099
|
* Convert a MusicXML measure to Lilylet events, grouped by voice
|
|
1056
1100
|
*/
|
|
1057
|
-
const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker
|
|
1101
|
+
const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker,
|
|
1102
|
+
// A part's <transpose> is declared once, usually in measure 1, and stays in force
|
|
1103
|
+
// for the whole part, so it has to survive across measures like the ottava does.
|
|
1104
|
+
transposeTracker) => {
|
|
1058
1105
|
let key;
|
|
1059
1106
|
let timeSig;
|
|
1060
1107
|
let barline;
|
|
@@ -1136,10 +1183,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1136
1183
|
if (attrs.time) {
|
|
1137
1184
|
timeSig = createFraction(attrs.time.beats, attrs.time.beatType);
|
|
1138
1185
|
}
|
|
1186
|
+
// Instrument transposition, in force from here to the end of the part.
|
|
1187
|
+
if (attrs.transpose) {
|
|
1188
|
+
const { chromatic, octaveChange } = attrs.transpose;
|
|
1189
|
+
transposeTracker.semitones = chromatic + 12 * (octaveChange ?? 0);
|
|
1190
|
+
}
|
|
1139
1191
|
// Clefs - store by staff number
|
|
1140
1192
|
if (attrs.clefs) {
|
|
1141
1193
|
for (const clefEntry of attrs.clefs) {
|
|
1142
|
-
const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
|
|
1194
|
+
const clef = clefWithShift(convertClef(clefEntry.clef.sign, clefEntry.clef.line), transposeTracker.semitones, clefEntry.clef.clefOctaveChange);
|
|
1143
1195
|
if (clef) {
|
|
1144
1196
|
const staff = clefEntry.staff;
|
|
1145
1197
|
if (staffHasNotes.has(staff)) {
|
|
@@ -1508,6 +1560,7 @@ const convertPart = (partEl) => {
|
|
|
1508
1560
|
const voiceTracker = new VoiceTracker();
|
|
1509
1561
|
const spannerTracker = new SpannerTracker();
|
|
1510
1562
|
const ottavaTracker = { current: 0 };
|
|
1563
|
+
const transposeTracker = { semitones: 0 };
|
|
1511
1564
|
const tupletTracker = new TupletTracker();
|
|
1512
1565
|
let lastKey;
|
|
1513
1566
|
let lastTimeSig;
|
|
@@ -1523,7 +1576,7 @@ const convertPart = (partEl) => {
|
|
|
1523
1576
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
1524
1577
|
for (const measureEl of measureEls) {
|
|
1525
1578
|
voiceTracker.reset();
|
|
1526
|
-
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1579
|
+
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker, transposeTracker);
|
|
1527
1580
|
// A forward-repeat (or any `location="left"`) barline belongs at the END of
|
|
1528
1581
|
// the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
|
|
1529
1582
|
// Append it to the previous measure's first voice; if this is the first
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { StemDirection, Accidental, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
|
|
8
8
|
import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
|
|
9
9
|
import { parseStaffLayout } from "./staffLayout.js";
|
|
10
|
+
import { clefBaseName, clefSuffixSemitones, clefSuffixTransposition } from "./clefTransposition.js";
|
|
10
11
|
// === Constants and Reverse Mappings ===
|
|
11
12
|
// Phonet to MusicXML step
|
|
12
13
|
const PHONET_TO_STEP = {
|
|
@@ -210,13 +211,44 @@ const encodeAttributes = (level, options) => {
|
|
|
210
211
|
xml += `${indent(level + 1)}<staves>${options.staves}</staves>\n`;
|
|
211
212
|
}
|
|
212
213
|
if (options.clef) {
|
|
213
|
-
|
|
214
|
+
// Look the sign/line up under the BASE name. A lilylet clef may carry a
|
|
215
|
+
// transposition suffix ("treble_8", "treble_m3"), and keying the table on the
|
|
216
|
+
// whole string missed every one of them — `clefInfo` came back undefined and
|
|
217
|
+
// the <clef> element was dropped entirely, sign and line with it.
|
|
218
|
+
const clefStr = options.clef;
|
|
219
|
+
const clefInfo = CLEF_TO_SIGN[clefBaseName(clefStr)];
|
|
214
220
|
if (clefInfo) {
|
|
215
221
|
xml += `${indent(level + 1)}<clef>\n`;
|
|
216
222
|
xml += `${indent(level + 2)}<sign>${clefInfo.sign}</sign>\n`;
|
|
217
223
|
xml += `${indent(level + 2)}<line>${clefInfo.line}</line>\n`;
|
|
224
|
+
// A whole-octave shift also has a notational form: the "8" drawn under or
|
|
225
|
+
// over the clef. This is the glyph only — sounding pitch rides on
|
|
226
|
+
// <transpose> below, exactly as in LilyPond.
|
|
227
|
+
const semitones = clefSuffixSemitones(clefStr);
|
|
228
|
+
if (semitones % 12 === 0 && semitones !== 0)
|
|
229
|
+
xml += `${indent(level + 2)}<clef-octave-change>${semitones / 12}</clef-octave-change>\n`;
|
|
218
230
|
xml += `${indent(level + 1)}</clef>\n`;
|
|
219
231
|
}
|
|
232
|
+
// The written->sounding transposition. MusicXML carries the diatonic step count
|
|
233
|
+
// and the chromatic semitone count separately, which is what lets a minor third
|
|
234
|
+
// (-2 steps, -3 semitones) be expressed exactly. <transpose> follows <clef> in
|
|
235
|
+
// the attributes content order.
|
|
236
|
+
const trans = clefSuffixTransposition(clefStr);
|
|
237
|
+
if (trans) {
|
|
238
|
+
// <diatonic> and <chromatic> hold the WITHIN-OCTAVE remainder and whole
|
|
239
|
+
// octaves go in <octave-change>, so both must be reduced by the same octave
|
|
240
|
+
// count: 7 diatonic steps and 12 semitones each. Reducing only the semitones
|
|
241
|
+
// would make an octave clef read as two octaves.
|
|
242
|
+
const octaveChange = Math.trunc(trans.semi / 12);
|
|
243
|
+
const diatonic = trans.diat - 7 * octaveChange;
|
|
244
|
+
const chromatic = trans.semi - 12 * octaveChange;
|
|
245
|
+
xml += `${indent(level + 1)}<transpose>\n`;
|
|
246
|
+
xml += `${indent(level + 2)}<diatonic>${diatonic}</diatonic>\n`;
|
|
247
|
+
xml += `${indent(level + 2)}<chromatic>${chromatic}</chromatic>\n`;
|
|
248
|
+
if (octaveChange !== 0)
|
|
249
|
+
xml += `${indent(level + 2)}<octave-change>${octaveChange}</octave-change>\n`;
|
|
250
|
+
xml += `${indent(level + 1)}</transpose>\n`;
|
|
251
|
+
}
|
|
220
252
|
}
|
|
221
253
|
xml += `${indent(level)}</attributes>\n`;
|
|
222
254
|
return xml;
|
|
@@ -31,6 +31,11 @@ export interface MusicXmlClef {
|
|
|
31
31
|
line?: number;
|
|
32
32
|
clefOctaveChange?: number;
|
|
33
33
|
}
|
|
34
|
+
export interface MusicXmlTranspose {
|
|
35
|
+
diatonic?: number;
|
|
36
|
+
chromatic: number;
|
|
37
|
+
octaveChange?: number;
|
|
38
|
+
}
|
|
34
39
|
export interface MusicXmlAttributes {
|
|
35
40
|
divisions?: number;
|
|
36
41
|
key?: MusicXmlKey;
|
|
@@ -40,6 +45,7 @@ export interface MusicXmlAttributes {
|
|
|
40
45
|
clef: MusicXmlClef;
|
|
41
46
|
}[];
|
|
42
47
|
staves?: number;
|
|
48
|
+
transpose?: MusicXmlTranspose;
|
|
43
49
|
}
|
|
44
50
|
export type MusicXmlStemDirection = 'up' | 'down';
|
|
45
51
|
export type MusicXmlLyricSyllabic = 'single' | 'begin' | 'middle' | 'end';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.90",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"test:clef-onset": "tsx ./tests/unit/clefOnsetTranspose.test.ts",
|
|
58
58
|
"test:abc-transpose": "tsx ./tests/unit/abcTransposingClef.test.ts",
|
|
59
59
|
"test:clef-transposition": "tsx ./tests/unit/clefTransposition.test.ts",
|
|
60
|
+
"test:clef-translation": "tsx ./tests/unit/clefTranslation.test.ts",
|
|
60
61
|
"test:fine-rhythm-onset": "tsx ./tests/unit/fineRhythmOnset.test.ts",
|
|
61
62
|
"test:transpose": "tsx ./tests/unit/astTranspose.test.ts",
|
|
62
63
|
"build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
|
|
@@ -147,3 +147,51 @@ export const withClefTransposition = (base: string, semitones: number): string |
|
|
|
147
147
|
const suffix = semitonesToClefSuffix(semitones);
|
|
148
148
|
return suffix === undefined ? undefined : base + suffix;
|
|
149
149
|
};
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether LilyPond itself accepts this suffix on a clef name. LilyPond's grammar is
|
|
154
|
+
* `([_^])([^0-9a-zA-Z]*)([1-9][0-9]*)` — a sign then a bare number — so it has no
|
|
155
|
+
* place for the `m` that selects a minor interval, and `\clef "treble_m3"` is
|
|
156
|
+
* rejected outright as an unknown clef type.
|
|
157
|
+
*/
|
|
158
|
+
export const isLilyPondClefSuffix = (clefStr: string): boolean => {
|
|
159
|
+
const parsed = parseClefSuffix(clefStr);
|
|
160
|
+
return parsed !== undefined && !parsed.minor;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Note names for the twelve semitones above c, in ENGLISH spelling (`bf`, not Dutch
|
|
164
|
+
// `bes`) — lilypondEncoder emits `\language "english"`, under which a Dutch name is
|
|
165
|
+
// rejected as "wrong type for argument 1, expecting pitch". Flats throughout: a
|
|
166
|
+
// transposing instrument is named by its flat spelling.
|
|
167
|
+
const ENGLISH_PITCH_NAMES = ["c", "df", "d", "ef", "e", "f", "gf", "g", "af", "a", "bf", "b"];
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The LilyPond `\transposition` pitch that declares a written→sounding shift of
|
|
172
|
+
* `semitones`, or undefined when the shift is beyond the two octaves either side of
|
|
173
|
+
* c' that instrument transpositions occupy.
|
|
174
|
+
*
|
|
175
|
+
* This is the mechanism that carries sounding pitch in LilyPond: `\transposition`
|
|
176
|
+
* takes a pitch, reaches every semitone, and DOES move MIDI, while the clef suffix
|
|
177
|
+
* is purely notational and moves nothing. The two are orthogonal, so a lilylet clef
|
|
178
|
+
* whose suffix means a sounding shift translates to BOTH — the suffix for the glyph
|
|
179
|
+
* (when LilyPond accepts it) and `\transposition` for the sound.
|
|
180
|
+
*
|
|
181
|
+
* The pitch is spelled relative to c', the no-transposition reference: an octave
|
|
182
|
+
* mark `'` per octave above, a `,` per octave below. Compound intervals are real —
|
|
183
|
+
* a major ninth down (-14) is the written form of some orchestral parts — so the
|
|
184
|
+
* range is not limited to a single octave.
|
|
185
|
+
*/
|
|
186
|
+
export const lilyPondTranspositionPitch = (semitones: number): string | undefined => {
|
|
187
|
+
if (!Number.isInteger(semitones) || Math.abs(semitones) > 24) return undefined;
|
|
188
|
+
|
|
189
|
+
// Reduce to a pitch class plus an octave count, measuring from c'.
|
|
190
|
+
const within = ((semitones % 12) + 12) % 12;
|
|
191
|
+
const octaves = Math.floor(semitones / 12);
|
|
192
|
+
const name = ENGLISH_PITCH_NAMES[within];
|
|
193
|
+
|
|
194
|
+
// c' is the reference and carries one `'`; each octave adds or removes one.
|
|
195
|
+
const marks = 1 + octaves;
|
|
196
|
+
return name + (marks >= 0 ? "'".repeat(marks) : ",".repeat(-marks));
|
|
197
|
+
};
|
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
Tempo,
|
|
44
44
|
} from "./types";
|
|
45
45
|
|
|
46
|
+
import { clefBaseName, parseClefSuffix, withClefTransposition } from "./clefTransposition";
|
|
47
|
+
|
|
46
48
|
|
|
47
49
|
// Phonet names mapping
|
|
48
50
|
const PHONET_NAMES: Record<number, Phonet> = {
|
|
@@ -77,6 +79,72 @@ const LILYPOND_CLEF_MAP: Record<string, Clef> = {
|
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fold a standing `\transposition` shift onto a clef as a lilylet suffix.
|
|
84
|
+
*
|
|
85
|
+
* The two carry different things in LilyPond and must not be added: a clef suffix
|
|
86
|
+
* there is notational and moves no pitch, while `\transposition` moves pitch and
|
|
87
|
+
* draws nothing. lilylet's suffix means sounding shift, so `\transposition` is the
|
|
88
|
+
* authority whenever it is present — a file with BOTH `\clef "treble_8"` and
|
|
89
|
+
* `\transposition c` (which is what lilypondEncoder emits for an octave clef) states
|
|
90
|
+
* one octave, not two.
|
|
91
|
+
*
|
|
92
|
+
* With no `\transposition`, a suffix already on the clef is kept as-is: that is the
|
|
93
|
+
* plain octave-clef part, where the notation is taken to imply its own shift.
|
|
94
|
+
*/
|
|
95
|
+
const applyTransposition = (clef: Clef | undefined, semitones: number): Clef | undefined => {
|
|
96
|
+
if (!clef || semitones === 0) return clef;
|
|
97
|
+
const base = clefBaseName(clef as string);
|
|
98
|
+
const shifted = withClefTransposition(base, semitones);
|
|
99
|
+
if (shifted === undefined) {
|
|
100
|
+
console.warn(`lilypondDecoder: \\transposition of ${semitones} semitones has no exact clef suffix; dropped for clef "${clef}"`);
|
|
101
|
+
return clef;
|
|
102
|
+
}
|
|
103
|
+
return shifted as Clef;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Semitones above c for each diatonic step index (0 = c ... 6 = b).
|
|
107
|
+
const PHONET_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Semitone offset from c' of a `\transposition` pitch term, which is how LilyPond
|
|
111
|
+
* states a transposing instrument's written->sounding shift (the clef suffix there is
|
|
112
|
+
* notational and states nothing). Returns 0 for a pitch that cannot be read.
|
|
113
|
+
*/
|
|
114
|
+
const transpositionSemitones = (term: any): number => {
|
|
115
|
+
const el = term?.args?.[0];
|
|
116
|
+
if (!el) return 0;
|
|
117
|
+
const step = el.phonetStep;
|
|
118
|
+
const octave = el.absolutePitch?.octave;
|
|
119
|
+
if (typeof step !== 'number' || typeof octave !== 'number') return 0;
|
|
120
|
+
// c' is the no-transposition reference, and sits at 12 in this scale.
|
|
121
|
+
return (PHONET_SEMITONES[step] + (el.alterValue || 0) + 12 * octave) - 12;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A LilyPond clef name to a lilylet Clef, keeping any transposing suffix.
|
|
126
|
+
*
|
|
127
|
+
* The map is keyed on BASE names, so looking up the whole string missed every
|
|
128
|
+
* suffixed clef ("treble_8" is not a key) and `if (clef)` then dropped the clef
|
|
129
|
+
* event altogether — the transposition went with it. Resolve the base through the
|
|
130
|
+
* map and reattach the suffix, which is the form lilylet's own model uses.
|
|
131
|
+
*
|
|
132
|
+
* A suffix LilyPond cannot write (the `m` minor-interval form) cannot appear in
|
|
133
|
+
* LilyPond input, so nothing here has to handle it; parseClefSuffix simply returns
|
|
134
|
+
* undefined for an unsuffixed name and the base is used as-is.
|
|
135
|
+
*/
|
|
136
|
+
const resolveLilyPondClef = (clefName: string): Clef | undefined => {
|
|
137
|
+
const direct = LILYPOND_CLEF_MAP[clefName];
|
|
138
|
+
if (direct) return direct;
|
|
139
|
+
|
|
140
|
+
const parsed = parseClefSuffix(clefName);
|
|
141
|
+
if (!parsed) return undefined;
|
|
142
|
+
const base = LILYPOND_CLEF_MAP[parsed.base];
|
|
143
|
+
if (!base) return undefined;
|
|
144
|
+
return (base + clefName.slice(parsed.base.length)) as Clef;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
|
|
80
148
|
// Key signature fifths to pitch/accidental mapping
|
|
81
149
|
const KEY_FIFTHS_MAP: Record<number, { pitch: Phonet; accidental?: Accidental; mode: 'major' | 'minor' }> = {
|
|
82
150
|
[-7]: { pitch: Phonet.c, accidental: Accidental.flat, mode: 'major' },
|
|
@@ -569,6 +637,9 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
569
637
|
let lastKey: number | undefined = undefined; // Track value changes (key fifths)
|
|
570
638
|
let lastTimeSig: string | undefined = undefined; // Track value changes (as string for comparison)
|
|
571
639
|
let lastClef: Clef | undefined = undefined; // Track value changes
|
|
640
|
+
// \transposition is a standing declaration: it applies to every clef emitted
|
|
641
|
+
// after it, and lilylet folds the shift into the clef suffix.
|
|
642
|
+
let transposeSemis = 0;
|
|
572
643
|
let lastOttava: number | undefined = undefined; // Track value changes
|
|
573
644
|
let lastStemDirection: string | undefined = undefined; // Track value changes
|
|
574
645
|
let partialEmitted = false; // Emit \partial context once per track
|
|
@@ -669,7 +740,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
669
740
|
|
|
670
741
|
// Handle clef context change (emit when value changes)
|
|
671
742
|
if (context.clef) {
|
|
672
|
-
const clef =
|
|
743
|
+
const clef = applyTransposition(resolveLilyPondClef(context.clef.clefName), transposeSemis);
|
|
673
744
|
if (clef && clef !== lastClef) {
|
|
674
745
|
voice.events.push({
|
|
675
746
|
type: 'context',
|
|
@@ -796,9 +867,25 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
796
867
|
}
|
|
797
868
|
}
|
|
798
869
|
}
|
|
870
|
+
else if (term instanceof lilyParser.LilyTerms.Transposition) {
|
|
871
|
+
const semis = transpositionSemitones(term);
|
|
872
|
+
if (semis !== transposeSemis) {
|
|
873
|
+
transposeSemis = semis;
|
|
874
|
+
// A `\transposition` AFTER the clef still governs it, so re-emit the
|
|
875
|
+
// clef with the shift folded in rather than let the already-emitted
|
|
876
|
+
// plain clef stand. Order between the two is not fixed in the wild.
|
|
877
|
+
if (lastClef) {
|
|
878
|
+
const corrected = applyTransposition(clefBaseName(lastClef as string) as Clef, transposeSemis);
|
|
879
|
+
if (corrected && corrected !== lastClef) {
|
|
880
|
+
voice.events.push({ type: 'context', clef: corrected });
|
|
881
|
+
lastClef = corrected;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
799
886
|
// Handle standalone clef (emit when value changes)
|
|
800
887
|
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
801
|
-
const clef =
|
|
888
|
+
const clef = applyTransposition(resolveLilyPondClef(term.clefName), transposeSemis);
|
|
802
889
|
if (clef && clef !== lastClef) {
|
|
803
890
|
voice.events.push({
|
|
804
891
|
type: 'context',
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
Placement,
|
|
39
39
|
} from "./types";
|
|
40
40
|
|
|
41
|
+
import { clefBaseName, clefSuffixSemitones, isLilyPondClefSuffix, lilyPondTranspositionPitch } from "./clefTransposition";
|
|
42
|
+
|
|
41
43
|
|
|
42
44
|
// === Constants and Mappings ===
|
|
43
45
|
|
|
@@ -306,10 +308,42 @@ const encodeTimeSig = (timeSig: { numerator: number; denominator: number; symbol
|
|
|
306
308
|
|
|
307
309
|
|
|
308
310
|
/**
|
|
309
|
-
* Encode clef to LilyPond
|
|
311
|
+
* Encode clef to LilyPond.
|
|
312
|
+
*
|
|
313
|
+
* A lilylet clef suffix carries a written→sounding shift; LilyPond's does not —
|
|
314
|
+
* there the suffix only repositions middle C on the staff and MIDI is untouched,
|
|
315
|
+
* and sounding pitch comes from the separate `\transposition <pitch>`. The two are
|
|
316
|
+
* orthogonal, so a transposing clef translates to BOTH: the suffix for the glyph,
|
|
317
|
+
* `\transposition` for the sound. Emitting only the suffix (what this did before)
|
|
318
|
+
* produced a file whose notation was right and whose MIDI was a transposition out.
|
|
319
|
+
*
|
|
320
|
+
* The clef string is quoted. LilyPond accepts a bare name unquoted, but `_m3`
|
|
321
|
+
* lexes as `_` followed by `m3` and breaks the parse; quoting is valid for every
|
|
322
|
+
* form. A `m` suffix has no LilyPond glyph at all (unknown clef type), so the base
|
|
323
|
+
* clef is emitted and the shift rides entirely on `\transposition`.
|
|
310
324
|
*/
|
|
311
325
|
const encodeClef = (clef: Clef): string => {
|
|
312
|
-
|
|
326
|
+
const clefStr = clef as string;
|
|
327
|
+
const shift = clefSuffixSemitones(clefStr);
|
|
328
|
+
|
|
329
|
+
// Keep the suffix only when LilyPond can render it; otherwise fall back to the
|
|
330
|
+
// base name rather than emit a clef LilyPond rejects.
|
|
331
|
+
const name = (shift === 0 || isLilyPondClefSuffix(clefStr)) ? clefStr : clefBaseName(clefStr);
|
|
332
|
+
const mapped = CLEF_MAP[name] || name;
|
|
333
|
+
const parts: string[] = [];
|
|
334
|
+
|
|
335
|
+
// \transposition comes FIRST so that a reader tracking it as a standing
|
|
336
|
+
// declaration has the shift in hand by the time the clef arrives.
|
|
337
|
+
if (shift !== 0) {
|
|
338
|
+
const pitch = lilyPondTranspositionPitch(shift);
|
|
339
|
+
if (pitch)
|
|
340
|
+
parts.push(`\\transposition ${pitch}`);
|
|
341
|
+
else
|
|
342
|
+
console.warn(`lilypondEncoder: clef "${clefStr}" shift ${shift} has no \\transposition pitch; sounding pitch not declared`);
|
|
343
|
+
}
|
|
344
|
+
parts.push(`\\clef "${mapped}"`);
|
|
345
|
+
|
|
346
|
+
return parts.join(' ');
|
|
313
347
|
};
|
|
314
348
|
|
|
315
349
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { DOMParser } from '@xmldom/xmldom';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
Clef,
|
|
11
12
|
LilyletDoc,
|
|
12
13
|
Measure,
|
|
13
14
|
Part,
|
|
@@ -49,6 +50,8 @@ import {
|
|
|
49
50
|
MusicXmlLyric,
|
|
50
51
|
} from './musicXmlTypes';
|
|
51
52
|
|
|
53
|
+
import { withClefTransposition } from './clefTransposition';
|
|
54
|
+
|
|
52
55
|
import {
|
|
53
56
|
getElementText,
|
|
54
57
|
getElementInt,
|
|
@@ -647,6 +650,21 @@ const parseAttributes = (attrEl: Element): MusicXmlAttributes => {
|
|
|
647
650
|
}
|
|
648
651
|
}
|
|
649
652
|
|
|
653
|
+
// Instrument transposition. This is what carries a transposing part's sounding
|
|
654
|
+
// pitch in MusicXML (<clef-octave-change> is only the glyph), and it was never
|
|
655
|
+
// read, so every transposing instrument decoded as sounding-as-written.
|
|
656
|
+
const transposeEl = getElements(attrEl, 'transpose')[0];
|
|
657
|
+
if (transposeEl) {
|
|
658
|
+
const chromatic = getElementInt(transposeEl, 'chromatic');
|
|
659
|
+
if (chromatic != null) {
|
|
660
|
+
result.transpose = {
|
|
661
|
+
diatonic: getElementInt(transposeEl, 'diatonic') ?? undefined,
|
|
662
|
+
chromatic,
|
|
663
|
+
octaveChange: getElementInt(transposeEl, 'octave-change') ?? undefined,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
650
668
|
// Staves
|
|
651
669
|
const staves = getElementInt(attrEl, 'staves');
|
|
652
670
|
if (staves !== undefined) {
|
|
@@ -1260,6 +1278,38 @@ const forwardGapToRests = (gapTicks: number, divisions: number): RestEvent[] =>
|
|
|
1260
1278
|
return rests;
|
|
1261
1279
|
};
|
|
1262
1280
|
|
|
1281
|
+
/**
|
|
1282
|
+
* Fold a MusicXML transposition onto a lilylet clef as a suffix.
|
|
1283
|
+
*
|
|
1284
|
+
* MusicXML keeps the two apart: <transpose> is the sounding shift and moves pitch,
|
|
1285
|
+
* <clef-octave-change> is the "8" glyph under the clef and moves nothing. A lilylet
|
|
1286
|
+
* suffix means sounding shift, so only ONE of them may be folded in — adding both
|
|
1287
|
+
* would double an octave clef that also declares <transpose> (the shape this
|
|
1288
|
+
* encoder itself now emits) into two octaves.
|
|
1289
|
+
*
|
|
1290
|
+
* <transpose> wins when present, being the explicit statement of sounding pitch.
|
|
1291
|
+
* Absent it, an octave clef is read as implying its own shift, which is what a
|
|
1292
|
+
* tenor-voice or guitar treble_8 part means.
|
|
1293
|
+
*
|
|
1294
|
+
* A shift with no exact suffix (only the tritone) leaves the clef plain rather than
|
|
1295
|
+
* rounding: a silently wrong sounding pitch is worse than a visibly absent shift.
|
|
1296
|
+
*/
|
|
1297
|
+
const clefWithShift = (
|
|
1298
|
+
clef: Clef | undefined,
|
|
1299
|
+
transposeSemitones: number,
|
|
1300
|
+
clefOctaveChange?: number,
|
|
1301
|
+
): Clef | undefined => {
|
|
1302
|
+
if (!clef) return clef;
|
|
1303
|
+
const semitones = transposeSemitones !== 0 ? transposeSemitones : 12 * (clefOctaveChange ?? 0);
|
|
1304
|
+
if (semitones === 0) return clef;
|
|
1305
|
+
const shifted = withClefTransposition(clef as string, semitones);
|
|
1306
|
+
if (shifted === undefined) {
|
|
1307
|
+
console.warn(`musicXmlDecoder: transposition ${semitones} has no exact clef suffix; dropped for clef "${clef}"`);
|
|
1308
|
+
return clef;
|
|
1309
|
+
}
|
|
1310
|
+
return shifted as Clef;
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1263
1313
|
/**
|
|
1264
1314
|
* Total time a TupletEvent advances the voice, in voiceTracker duration units.
|
|
1265
1315
|
* Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
|
|
@@ -1281,7 +1331,10 @@ const convertMeasure = (
|
|
|
1281
1331
|
voiceTracker: VoiceTracker,
|
|
1282
1332
|
spannerTracker: SpannerTracker,
|
|
1283
1333
|
ottavaTracker: { current: number },
|
|
1284
|
-
tupletTracker: TupletTracker
|
|
1334
|
+
tupletTracker: TupletTracker,
|
|
1335
|
+
// A part's <transpose> is declared once, usually in measure 1, and stays in force
|
|
1336
|
+
// for the whole part, so it has to survive across measures like the ottava does.
|
|
1337
|
+
transposeTracker: { semitones: number }
|
|
1285
1338
|
): MeasureConversionResult => {
|
|
1286
1339
|
let key: KeySignature | undefined;
|
|
1287
1340
|
let timeSig: Fraction | undefined;
|
|
@@ -1369,10 +1422,20 @@ const convertMeasure = (
|
|
|
1369
1422
|
timeSig = createFraction(attrs.time.beats, attrs.time.beatType);
|
|
1370
1423
|
}
|
|
1371
1424
|
|
|
1425
|
+
// Instrument transposition, in force from here to the end of the part.
|
|
1426
|
+
if (attrs.transpose) {
|
|
1427
|
+
const { chromatic, octaveChange } = attrs.transpose;
|
|
1428
|
+
transposeTracker.semitones = chromatic + 12 * (octaveChange ?? 0);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1372
1431
|
// Clefs - store by staff number
|
|
1373
1432
|
if (attrs.clefs) {
|
|
1374
1433
|
for (const clefEntry of attrs.clefs) {
|
|
1375
|
-
const clef =
|
|
1434
|
+
const clef = clefWithShift(
|
|
1435
|
+
convertClef(clefEntry.clef.sign, clefEntry.clef.line),
|
|
1436
|
+
transposeTracker.semitones,
|
|
1437
|
+
clefEntry.clef.clefOctaveChange,
|
|
1438
|
+
);
|
|
1376
1439
|
if (clef) {
|
|
1377
1440
|
const staff = clefEntry.staff;
|
|
1378
1441
|
if (staffHasNotes.has(staff)) {
|
|
@@ -1761,6 +1824,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1761
1824
|
const voiceTracker = new VoiceTracker();
|
|
1762
1825
|
const spannerTracker = new SpannerTracker();
|
|
1763
1826
|
const ottavaTracker = { current: 0 };
|
|
1827
|
+
const transposeTracker = { semitones: 0 };
|
|
1764
1828
|
const tupletTracker = new TupletTracker();
|
|
1765
1829
|
|
|
1766
1830
|
let lastKey: KeySignature | undefined;
|
|
@@ -1779,7 +1843,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1779
1843
|
|
|
1780
1844
|
for (const measureEl of measureEls) {
|
|
1781
1845
|
voiceTracker.reset();
|
|
1782
|
-
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1846
|
+
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker, transposeTracker);
|
|
1783
1847
|
|
|
1784
1848
|
// A forward-repeat (or any `location="left"`) barline belongs at the END of
|
|
1785
1849
|
// the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
|
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
|
|
49
49
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
50
50
|
|
|
51
|
+
import { clefBaseName, clefSuffixSemitones, clefSuffixTransposition } from "./clefTransposition";
|
|
52
|
+
|
|
51
53
|
|
|
52
54
|
// === Constants and Reverse Mappings ===
|
|
53
55
|
|
|
@@ -304,13 +306,45 @@ const encodeAttributes = (
|
|
|
304
306
|
}
|
|
305
307
|
|
|
306
308
|
if (options.clef) {
|
|
307
|
-
|
|
309
|
+
// Look the sign/line up under the BASE name. A lilylet clef may carry a
|
|
310
|
+
// transposition suffix ("treble_8", "treble_m3"), and keying the table on the
|
|
311
|
+
// whole string missed every one of them — `clefInfo` came back undefined and
|
|
312
|
+
// the <clef> element was dropped entirely, sign and line with it.
|
|
313
|
+
const clefStr = options.clef as string;
|
|
314
|
+
const clefInfo = CLEF_TO_SIGN[clefBaseName(clefStr)];
|
|
308
315
|
if (clefInfo) {
|
|
309
316
|
xml += `${indent(level + 1)}<clef>\n`;
|
|
310
317
|
xml += `${indent(level + 2)}<sign>${clefInfo.sign}</sign>\n`;
|
|
311
318
|
xml += `${indent(level + 2)}<line>${clefInfo.line}</line>\n`;
|
|
319
|
+
// A whole-octave shift also has a notational form: the "8" drawn under or
|
|
320
|
+
// over the clef. This is the glyph only — sounding pitch rides on
|
|
321
|
+
// <transpose> below, exactly as in LilyPond.
|
|
322
|
+
const semitones = clefSuffixSemitones(clefStr);
|
|
323
|
+
if (semitones % 12 === 0 && semitones !== 0)
|
|
324
|
+
xml += `${indent(level + 2)}<clef-octave-change>${semitones / 12}</clef-octave-change>\n`;
|
|
312
325
|
xml += `${indent(level + 1)}</clef>\n`;
|
|
313
326
|
}
|
|
327
|
+
|
|
328
|
+
// The written->sounding transposition. MusicXML carries the diatonic step count
|
|
329
|
+
// and the chromatic semitone count separately, which is what lets a minor third
|
|
330
|
+
// (-2 steps, -3 semitones) be expressed exactly. <transpose> follows <clef> in
|
|
331
|
+
// the attributes content order.
|
|
332
|
+
const trans = clefSuffixTransposition(clefStr);
|
|
333
|
+
if (trans) {
|
|
334
|
+
// <diatonic> and <chromatic> hold the WITHIN-OCTAVE remainder and whole
|
|
335
|
+
// octaves go in <octave-change>, so both must be reduced by the same octave
|
|
336
|
+
// count: 7 diatonic steps and 12 semitones each. Reducing only the semitones
|
|
337
|
+
// would make an octave clef read as two octaves.
|
|
338
|
+
const octaveChange = Math.trunc(trans.semi / 12);
|
|
339
|
+
const diatonic = trans.diat - 7 * octaveChange;
|
|
340
|
+
const chromatic = trans.semi - 12 * octaveChange;
|
|
341
|
+
xml += `${indent(level + 1)}<transpose>\n`;
|
|
342
|
+
xml += `${indent(level + 2)}<diatonic>${diatonic}</diatonic>\n`;
|
|
343
|
+
xml += `${indent(level + 2)}<chromatic>${chromatic}</chromatic>\n`;
|
|
344
|
+
if (octaveChange !== 0)
|
|
345
|
+
xml += `${indent(level + 2)}<octave-change>${octaveChange}</octave-change>\n`;
|
|
346
|
+
xml += `${indent(level + 1)}</transpose>\n`;
|
|
347
|
+
}
|
|
314
348
|
}
|
|
315
349
|
|
|
316
350
|
xml += `${indent(level)}</attributes>\n`;
|
|
@@ -41,12 +41,22 @@ export interface MusicXmlClef {
|
|
|
41
41
|
clefOctaveChange?: number; // -1, 0, 1
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// A part's written->sounding transposition. diatonic/chromatic hold the
|
|
45
|
+
// within-octave remainder and octaveChange the whole octaves, so the total shift is
|
|
46
|
+
// chromatic + 12 * octaveChange semitones.
|
|
47
|
+
export interface MusicXmlTranspose {
|
|
48
|
+
diatonic?: number;
|
|
49
|
+
chromatic: number;
|
|
50
|
+
octaveChange?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
export interface MusicXmlAttributes {
|
|
45
54
|
divisions?: number; // Divisions per quarter note
|
|
46
55
|
key?: MusicXmlKey;
|
|
47
56
|
time?: MusicXmlTime;
|
|
48
57
|
clefs?: { staff: number; clef: MusicXmlClef }[]; // Clefs by staff number
|
|
49
58
|
staves?: number; // Number of staves
|
|
59
|
+
transpose?: MusicXmlTranspose; // Instrument transposition, folded into the clef suffix
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
// ============ Note Types ============
|