@k-l-lambda/lilylet 0.1.88 → 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.
@@ -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 ============
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { calculateDuration } from "./musicXmlUtils";
13
13
  import { Accidental } from "./types";
14
+ import { clefSuffixSemitones } from "./clefTransposition";
14
15
 
15
16
  // Onset resolution in ticks per quarter note. calculateDuration() rounds its result to an
16
17
  // integer, so it must run at a resolution fine enough to represent every note value exactly:
@@ -25,22 +26,14 @@ const ACCIDENTAL_SEMITONE: Record<string, number> = {
25
26
  [Accidental.doubleSharp]: 2, [Accidental.doubleFlat]: -2,
26
27
  };
27
28
 
28
- // Diatonic-step -> semitones within an octave (unison..seventh), for "_N"/"^N" clef suffixes.
29
- const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
30
-
31
29
  /**
32
- * The written->sounding semitone shift a clef string declares. Per the LilyPond convention a
33
- * "_N"/"^N" suffix transposes DOWN/UP by the diatonic interval N: "treble_8" = octave down
34
- * (-12), "treble^8" = +12, "treble_15" = two octaves down (-24), "treble_5" = fifth down (-7).
35
- * A plain clef (no suffix) declares 0.
30
+ * The written->sounding semitone shift a clef string declares. A "_N"/"^N" suffix transposes
31
+ * DOWN/UP by the diatonic interval N ("treble_8" = octave down, -12; "treble_5" = fifth down,
32
+ * -7), and an "m" before the number selects that interval's MINOR form ("treble_m3" = minor
33
+ * third down, -3). A plain clef declares 0. See clefTransposition.ts for the grammar; this is
34
+ * a thin alias kept because clefShift is the name the onset API and its tests already use.
36
35
  */
37
- export const clefShift = (clefStr: string): number => {
38
- const m = /^.*?([_^])(\d+)$/.exec(clefStr || "");
39
- if (!m) return 0;
40
- const k = parseInt(m[2], 10) - 1; // diatonic steps above unison
41
- const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
42
- return (m[1] === "^" ? 1 : -1) * semis;
43
- };
36
+ export const clefShift = (clefStr: string): number => clefSuffixSemitones(clefStr);
44
37
 
45
38
  /**
46
39
  * Sounding MIDI pitch of a resolved (absolute-octave) Lilylet pitch. Octave 0 = middle-C