@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.
@@ -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 = LILYPOND_CLEF_MAP[context.clef.clefName];
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 = LILYPOND_CLEF_MAP[term.clefName];
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
- return `\\clef ${CLEF_MAP[clef] || clef}`;
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
- const clefInfo = CLEF_TO_SIGN[options.clef];
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.89",
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 = LILYPOND_CLEF_MAP[context.clef.clefName];
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 = LILYPOND_CLEF_MAP[term.clefName];
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
- return `\\clef ${CLEF_MAP[clef] || clef}`;
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 = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
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
- const clefInfo = CLEF_TO_SIGN[options.clef];
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 ============