@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.
@@ -0,0 +1 @@
1
+ export * from "./lilylet/clefTransposition.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/clefTransposition.js";
@@ -7,6 +7,7 @@ import parse from "../abc/parser.js";
7
7
  import { Phonet, Accidental, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
8
8
  import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
9
9
  import { buildMeasureLayout, applyNavText } from "./measureLayoutFromXml.js";
10
+ import { parseClefSuffix, withClefTransposition } from "./clefTransposition.js";
10
11
  // ============ Constants ============
11
12
  // NotaGen catalog tags appear as leading single-% comments in ABC files,
12
13
  // in the order: period, composer, instrumentation. They are mapped to
@@ -426,28 +427,34 @@ const convertClef = (clefStr) => {
426
427
  };
427
428
  /**
428
429
  * Fold an ABC voice `transpose=N` property (a written→sounding shift in
429
- * SEMITONES) into the Lilylet clef-suffix form `_M` / `^M`, where M is a
430
- * diatonic interval number. ABC carries only semitones, so the diatonic
431
- * interval is approximated by the nearest scale-step count
432
- * (steps = round(semi * 7/12), interval number = |steps| + 1); `_` lowers,
433
- * `^` raises. The Lilylet→MEI encoder later expands the suffix back into
434
- * trans.diat / trans.semi (see meiEncoder.resolveClef). This is exact when the
435
- * semitone count is a major/perfect interval (e.g. -2, -9, +2, ±12) and a
436
- * nearest-interval approximation otherwise.
430
+ * SEMITONES) into the Lilylet clef suffix, which the →MEI encoder later expands
431
+ * into trans.diat / trans.semi (see meiEncoder.resolveClef).
432
+ *
433
+ * The conversion is EXACT: clefTransposition.semitonesToClefSuffix returns the
434
+ * suffix for the interval that spans exactly this many semitones, using the `m`
435
+ * (minor) form where the major/perfect interval does not fit — so -3 becomes
436
+ * `_m3` rather than the nearest major third `_3`. It previously approximated with
437
+ * round(semi * 7/12), which put a one-semitone error on the sounding pitch of
438
+ * every minor-interval transposition (-3 A clarinet, -8, -10 E-flat instruments:
439
+ * 37% of transposing voices in the corpora checked).
440
+ *
441
+ * A semitone count with no exact suffix (only the tritone, ±6, within an octave)
442
+ * is dropped with a warning rather than rounded: a silent one-semitone pitch error
443
+ * is worse than a visibly absent transposition.
437
444
  */
438
445
  const transposeClefSuffix = (clef, semitones) => {
439
446
  if (!clef || !semitones)
440
447
  return clef;
441
- // A clef that already carries an octave suffix (treble_8 etc.) is left as-is;
442
- // stacking another shift on top is not meaningful for ABC sources.
443
- if (/[_^]\d+$/.test(clef))
448
+ // A clef that already carries a transposition suffix (treble_8 etc.) is left
449
+ // as-is; stacking another shift on top is not meaningful for ABC sources.
450
+ if (parseClefSuffix(clef))
444
451
  return clef;
445
- const steps = Math.round((semitones * 7) / 12);
446
- if (steps === 0)
452
+ const transposed = withClefTransposition(clef, semitones);
453
+ if (transposed === undefined) {
454
+ console.warn(`abcDecoder: transpose=${semitones} has no exact clef suffix; transposition dropped for clef "${clef}"`);
447
455
  return clef;
448
- const num = Math.abs(steps) + 1;
449
- const suffix = (steps < 0 ? "_" : "^") + num;
450
- return (clef + suffix);
456
+ }
457
+ return transposed;
451
458
  };
452
459
  /**
453
460
  * Convert ABC barline to Lilylet barline style
@@ -1450,6 +1457,14 @@ const decodeTune = (tune, options = {}) => {
1450
1457
  // [M:...] change). A bar whose played length is SHORTER than this gets a \partial (see
1451
1458
  // the pickup/anacrusis handling below the per-measure assembly).
1452
1459
  let curTime = timeSig;
1460
+ // ABC voice numbers whose declared clef has already been emitted. A voice gets its
1461
+ // clef on the measure where it FIRST carries music, which is not always measure 0:
1462
+ // a header field the grammar does not consume (e.g. `I:percmap`) degenerates into a
1463
+ // phantom measure 0 holding a single patch with no voice number, and the emission
1464
+ // was previously gated on `mi === 0`, so every real voice lost its clef — and with
1465
+ // it its transposition. Keying on the voice number instead of the measure index
1466
+ // makes the emission independent of what measure 0 happens to contain.
1467
+ const emittedClefVoices = new Set();
1453
1468
  for (let mi = 0; mi < measures.length; mi++) {
1454
1469
  const abcMeasure = measures[mi];
1455
1470
  // Group patches by voice number
@@ -1585,29 +1600,39 @@ const decodeTune = (tune, options = {}) => {
1585
1600
  for (const pi of sortedPartIndices) {
1586
1601
  const voicesMap = partVoicesMap.get(pi);
1587
1602
  const voices = [];
1603
+ // Voice -> the ABC voice number it came from, recovered from the map key
1604
+ // (voiceKey = staffInPart * 1000 + voiceNum). Voice itself carries no voice
1605
+ // number, and the clef below is a per-voice property, so the association has
1606
+ // to be kept explicitly rather than searched for.
1607
+ const voiceNumOf = new Map();
1588
1608
  const sortedKeys = Array.from(voicesMap.keys()).sort((a, b) => a - b);
1589
1609
  for (const key of sortedKeys) {
1590
- voices.push(voicesMap.get(key));
1610
+ const voice = voicesMap.get(key);
1611
+ voices.push(voice);
1612
+ voiceNumOf.set(voice, key % 1000);
1591
1613
  }
1592
1614
  const part = { voices };
1593
- // Add clef context to first voice of each staff on first measure
1594
- if (mi === 0) {
1595
- for (const voice of voices) {
1596
- // Find voices for this part's staff and add initial clef
1597
- const voiceNums = Array.from(voicePatches.keys());
1598
- for (const vn of voiceNums) {
1599
- if (scoreLayout) {
1600
- const assign = scoreLayout.get(vn);
1601
- if (assign && assign.partIndex === pi && assign.staffInPart === voice.staff) {
1602
- const clef = voiceClefs.get(vn);
1603
- if (clef) {
1604
- voice.events.unshift({ type: "context", clef });
1605
- break;
1606
- }
1607
- }
1608
- }
1609
- }
1610
- }
1615
+ // Give each voice its OWN declared clef on the measure where it first
1616
+ // appears. A clef must be per voice, not per staff: an ABC voice folds its
1617
+ // `transpose=` into the clef suffix, so two voices sharing a staff can
1618
+ // legitimately differ, and lilylet's model is per-voice throughout
1619
+ // (onsets.measureOnsets tracks clefByVoice, serializer.findVoiceClef reads
1620
+ // one clef per Voice).
1621
+ //
1622
+ // This used to scan for the FIRST voice whose scoreLayout assignment matched
1623
+ // (partIndex, staff) and break, so every voice on a shared staff inherited
1624
+ // the first one's clef — the WRONG clef, taking its transposition with it.
1625
+ // A `%%score` arc such as `( 1 2 )` puts both voices on one staff and is
1626
+ // exactly that shape.
1627
+ for (const voice of voices) {
1628
+ const vn = voiceNumOf.get(voice);
1629
+ if (emittedClefVoices.has(vn))
1630
+ continue;
1631
+ const clef = voiceClefs.get(vn);
1632
+ if (!clef)
1633
+ continue;
1634
+ voice.events.unshift({ type: "context", clef });
1635
+ emittedClefVoices.add(vn);
1611
1636
  }
1612
1637
  parts.push(part);
1613
1638
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The canonical grammar for Lilylet's transposing-clef suffix, and the only place
3
+ * that converts between a suffix and a semitone count.
4
+ *
5
+ * A Lilylet clef may carry a written→sounding transposition as a suffix:
6
+ *
7
+ * treble_8 an octave down (-12)
8
+ * treble^2 a major second up (+2)
9
+ * treble_5 a perfect fifth down (-7)
10
+ * treble_m3 a MINOR third down (-3)
11
+ * treble_m7 a minor seventh down (-10)
12
+ *
13
+ * `_` lowers, `^` raises, the number is a diatonic interval, and an `m` before the
14
+ * number selects the MINOR form of that interval. Without `m` the interval is the
15
+ * major/perfect one, which is what every suffix written before the `m` form existed
16
+ * means — so plain `_N`/`^N` keeps its original value and old documents are read
17
+ * exactly as before.
18
+ *
19
+ * Why `m` exists: the diatonic interval number alone cannot express a minor
20
+ * interval, so the semitone counts 1, 3, 8 and 10 were unreachable. Those are not
21
+ * exotic — a minor third is the A clarinet and a minor seventh is a common brass
22
+ * transposition, so an ABC `transpose=-3` had no exact suffix and was rounded to
23
+ * `_3` (-4), putting a one-semitone error on the sounding pitch. With `m` the only
24
+ * remaining gap is 6 semitones, the tritone, which is neither major/perfect nor
25
+ * minor (it is an augmented fourth or diminished fifth). No instrument transposes
26
+ * by a tritone and the shape does not occur in any corpus checked, so it is left
27
+ * unrepresentable rather than given a spelling that would have to be arbitrary.
28
+ *
29
+ * NOTE ON LILYPOND: LilyPond's own `\clef "treble_8"` suffix is a NOTATIONAL
30
+ * device — it repositions middle C on the staff and does not change sounding pitch
31
+ * at all (LilyPond expresses instrument transposition with the separate
32
+ * `\transposition <pitch>`, which takes a pitch and so reaches every semitone).
33
+ * Lilylet deliberately diverges: here the suffix DOES carry the sounding shift, and
34
+ * `m` is the extension that makes that carrying lossless. LilyPond rejects `_m3` as
35
+ * an unknown clef type, so a Lilylet clef using `m` is not portable to LilyPond
36
+ * as-is.
37
+ */
38
+ /** A parsed transposing-clef suffix. */
39
+ export interface ClefSuffix {
40
+ /** The clef name with the suffix removed, e.g. "treble". */
41
+ base: string;
42
+ /** Diatonic interval number as written: 8 for an octave, 3 for a third. */
43
+ interval: number;
44
+ /** True when the suffix selected the minor form (`m` present). */
45
+ minor: boolean;
46
+ /** true = `^` (up), false = `_` (down). */
47
+ up: boolean;
48
+ }
49
+ /**
50
+ * Parse a clef string into its base name and transposition suffix.
51
+ * Returns undefined when the clef carries no suffix, or when the suffix is
52
+ * malformed (including `m` on a perfect interval such as `_m4`).
53
+ */
54
+ export declare const parseClefSuffix: (clefStr: string) => ClefSuffix | undefined;
55
+ /** The clef name with any transposition suffix stripped. */
56
+ export declare const clefBaseName: (clefStr: string) => string;
57
+ /**
58
+ * The written→sounding semitone shift a clef string declares; 0 when it declares
59
+ * none (no suffix, or a suffix that cannot be interpreted).
60
+ */
61
+ export declare const clefSuffixSemitones: (clefStr: string) => number;
62
+ /**
63
+ * The written→sounding transposition as MEI's att.transposition pair, in the
64
+ * `{diat, semi}` form: diatonic steps and semitones, both signed. A minor third
65
+ * down is `{diat: -2, semi: -3}` — the two differ, which is exactly the
66
+ * information the diatonic number alone cannot carry.
67
+ */
68
+ export declare const clefSuffixTransposition: (clefStr: string) => {
69
+ diat: number;
70
+ semi: number;
71
+ } | undefined;
72
+ /**
73
+ * The suffix that expresses `semitones` EXACTLY, or undefined when no suffix can.
74
+ * Never approximates: a caller that gets undefined must handle the gap rather than
75
+ * emit a shift it cannot honour. The only unrepresentable interval within an octave
76
+ * is 6 semitones (the tritone) — see the module comment.
77
+ *
78
+ * Returns the suffix alone (e.g. "_m3"), to be appended to a clef base name.
79
+ */
80
+ export declare const semitonesToClefSuffix: (semitones: number) => string | undefined;
81
+ /**
82
+ * Apply a semitone transposition to a clef name, returning the suffixed clef.
83
+ * Returns undefined when the shift has no exact suffix, so the caller can decide
84
+ * between dropping the transposition and reporting it — silently rounding is what
85
+ * produced wrong sounding pitches before this module existed.
86
+ */
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;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The canonical grammar for Lilylet's transposing-clef suffix, and the only place
3
+ * that converts between a suffix and a semitone count.
4
+ *
5
+ * A Lilylet clef may carry a written→sounding transposition as a suffix:
6
+ *
7
+ * treble_8 an octave down (-12)
8
+ * treble^2 a major second up (+2)
9
+ * treble_5 a perfect fifth down (-7)
10
+ * treble_m3 a MINOR third down (-3)
11
+ * treble_m7 a minor seventh down (-10)
12
+ *
13
+ * `_` lowers, `^` raises, the number is a diatonic interval, and an `m` before the
14
+ * number selects the MINOR form of that interval. Without `m` the interval is the
15
+ * major/perfect one, which is what every suffix written before the `m` form existed
16
+ * means — so plain `_N`/`^N` keeps its original value and old documents are read
17
+ * exactly as before.
18
+ *
19
+ * Why `m` exists: the diatonic interval number alone cannot express a minor
20
+ * interval, so the semitone counts 1, 3, 8 and 10 were unreachable. Those are not
21
+ * exotic — a minor third is the A clarinet and a minor seventh is a common brass
22
+ * transposition, so an ABC `transpose=-3` had no exact suffix and was rounded to
23
+ * `_3` (-4), putting a one-semitone error on the sounding pitch. With `m` the only
24
+ * remaining gap is 6 semitones, the tritone, which is neither major/perfect nor
25
+ * minor (it is an augmented fourth or diminished fifth). No instrument transposes
26
+ * by a tritone and the shape does not occur in any corpus checked, so it is left
27
+ * unrepresentable rather than given a spelling that would have to be arbitrary.
28
+ *
29
+ * NOTE ON LILYPOND: LilyPond's own `\clef "treble_8"` suffix is a NOTATIONAL
30
+ * device — it repositions middle C on the staff and does not change sounding pitch
31
+ * at all (LilyPond expresses instrument transposition with the separate
32
+ * `\transposition <pitch>`, which takes a pitch and so reaches every semitone).
33
+ * Lilylet deliberately diverges: here the suffix DOES carry the sounding shift, and
34
+ * `m` is the extension that makes that carrying lossless. LilyPond rejects `_m3` as
35
+ * an unknown clef type, so a Lilylet clef using `m` is not portable to LilyPond
36
+ * as-is.
37
+ */
38
+ // Semitones spanned by the MAJOR/PERFECT form of each diatonic step count
39
+ // (0 = unison, 1 = second, … 6 = seventh).
40
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
41
+ // Diatonic step counts that have a minor form: seconds, thirds, sixths, sevenths.
42
+ // Unison, fourth and fifth are perfect and take no `m`.
43
+ const MINOR_CAPABLE_STEPS = new Set([1, 2, 5, 6]);
44
+ // `_`/`^`, an optional `m`, then a positive interval number with no leading zero.
45
+ const SUFFIX_RE = /^(.*?)([_^])(m?)([1-9][0-9]*)$/;
46
+ /** Semitones spanned by a diatonic interval number, or undefined if `m` is invalid for it. */
47
+ const intervalSemitones = (interval, minor) => {
48
+ const steps = interval - 1; // diatonic steps above unison
49
+ const within = steps % 7;
50
+ if (minor && !MINOR_CAPABLE_STEPS.has(within))
51
+ return undefined;
52
+ const semis = DIATONIC_SEMITONES[within] + 12 * Math.floor(steps / 7);
53
+ return minor ? semis - 1 : semis;
54
+ };
55
+ /**
56
+ * Parse a clef string into its base name and transposition suffix.
57
+ * Returns undefined when the clef carries no suffix, or when the suffix is
58
+ * malformed (including `m` on a perfect interval such as `_m4`).
59
+ */
60
+ export const parseClefSuffix = (clefStr) => {
61
+ const match = SUFFIX_RE.exec(clefStr || "");
62
+ if (!match)
63
+ return undefined;
64
+ const interval = parseInt(match[4], 10);
65
+ const minor = match[3] === "m";
66
+ // Reject a suffix we cannot interpret rather than silently mis-reading it.
67
+ if (intervalSemitones(interval, minor) === undefined)
68
+ return undefined;
69
+ return { base: match[1], interval, minor, up: match[2] === "^" };
70
+ };
71
+ /** The clef name with any transposition suffix stripped. */
72
+ export const clefBaseName = (clefStr) => parseClefSuffix(clefStr)?.base ?? clefStr;
73
+ /**
74
+ * The written→sounding semitone shift a clef string declares; 0 when it declares
75
+ * none (no suffix, or a suffix that cannot be interpreted).
76
+ */
77
+ export const clefSuffixSemitones = (clefStr) => {
78
+ const parsed = parseClefSuffix(clefStr);
79
+ if (!parsed)
80
+ return 0;
81
+ const semis = intervalSemitones(parsed.interval, parsed.minor);
82
+ return parsed.up ? semis : -semis;
83
+ };
84
+ /**
85
+ * The written→sounding transposition as MEI's att.transposition pair, in the
86
+ * `{diat, semi}` form: diatonic steps and semitones, both signed. A minor third
87
+ * down is `{diat: -2, semi: -3}` — the two differ, which is exactly the
88
+ * information the diatonic number alone cannot carry.
89
+ */
90
+ export const clefSuffixTransposition = (clefStr) => {
91
+ const parsed = parseClefSuffix(clefStr);
92
+ if (!parsed)
93
+ return undefined;
94
+ const semis = intervalSemitones(parsed.interval, parsed.minor);
95
+ const sign = parsed.up ? 1 : -1;
96
+ return { diat: sign * (parsed.interval - 1), semi: sign * semis };
97
+ };
98
+ /**
99
+ * The suffix that expresses `semitones` EXACTLY, or undefined when no suffix can.
100
+ * Never approximates: a caller that gets undefined must handle the gap rather than
101
+ * emit a shift it cannot honour. The only unrepresentable interval within an octave
102
+ * is 6 semitones (the tritone) — see the module comment.
103
+ *
104
+ * Returns the suffix alone (e.g. "_m3"), to be appended to a clef base name.
105
+ */
106
+ export const semitonesToClefSuffix = (semitones) => {
107
+ if (!Number.isInteger(semitones) || semitones === 0)
108
+ return undefined;
109
+ const magnitude = Math.abs(semitones);
110
+ const sign = semitones < 0 ? "_" : "^";
111
+ // Search interval numbers covering the requested magnitude; octaves shift by 7
112
+ // diatonic steps, so an octave-and-a-bit needs interval numbers beyond 8.
113
+ const maxInterval = Math.floor(magnitude / 12) * 7 + 8;
114
+ for (let interval = 1; interval <= maxInterval; interval++) {
115
+ for (const minor of [false, true]) {
116
+ if (intervalSemitones(interval, minor) === magnitude)
117
+ return `${sign}${minor ? "m" : ""}${interval}`;
118
+ }
119
+ }
120
+ return undefined;
121
+ };
122
+ /**
123
+ * Apply a semitone transposition to a clef name, returning the suffixed clef.
124
+ * Returns undefined when the shift has no exact suffix, so the caller can decide
125
+ * between dropping the transposition and reporting it — silently rounding is what
126
+ * produced wrong sounding pitches before this module existed.
127
+ */
128
+ export const withClefTransposition = (base, semitones) => {
129
+ if (semitones === 0)
130
+ return base;
131
+ const suffix = semitonesToClefSuffix(semitones);
132
+ return suffix === undefined ? undefined : base + suffix;
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
+ };
@@ -4,6 +4,7 @@ export * from "./serializer";
4
4
  export * from "./staffLayout";
5
5
  export * from "./measureLayout";
6
6
  export * from "./onsets";
7
+ export * from "./clefTransposition";
7
8
  export * from "./transposition";
8
9
  import * as meiEncoder from "./meiEncoder";
9
10
  import * as musicXmlDecoder from "./musicXmlDecoder";
@@ -4,6 +4,7 @@ export * from "./serializer.js";
4
4
  export * from "./staffLayout.js";
5
5
  export * from "./measureLayout.js";
6
6
  export * from "./onsets.js";
7
+ export * from "./clefTransposition.js";
7
8
  export * from "./transposition.js";
8
9
  import * as meiEncoder from "./meiEncoder.js";
9
10
  import * as musicXmlDecoder from "./musicXmlDecoder.js";
@@ -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