@k-l-lambda/lilylet 0.1.88 → 0.1.89

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.
@@ -42,6 +42,7 @@ import {
42
42
  } from "./types";
43
43
  import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
44
44
  import { MeasureRepeatInfo, buildMeasureLayout, applyNavText } from "./measureLayoutFromXml";
45
+ import { parseClefSuffix, withClefTransposition } from "./clefTransposition";
45
46
 
46
47
 
47
48
  // ============ Constants ============
@@ -476,25 +477,32 @@ const convertClef = (clefStr: string): Clef | undefined => {
476
477
 
477
478
  /**
478
479
  * Fold an ABC voice `transpose=N` property (a written→sounding shift in
479
- * SEMITONES) into the Lilylet clef-suffix form `_M` / `^M`, where M is a
480
- * diatonic interval number. ABC carries only semitones, so the diatonic
481
- * interval is approximated by the nearest scale-step count
482
- * (steps = round(semi * 7/12), interval number = |steps| + 1); `_` lowers,
483
- * `^` raises. The Lilylet→MEI encoder later expands the suffix back into
484
- * trans.diat / trans.semi (see meiEncoder.resolveClef). This is exact when the
485
- * semitone count is a major/perfect interval (e.g. -2, -9, +2, ±12) and a
486
- * nearest-interval approximation otherwise.
480
+ * SEMITONES) into the Lilylet clef suffix, which the →MEI encoder later expands
481
+ * into trans.diat / trans.semi (see meiEncoder.resolveClef).
482
+ *
483
+ * The conversion is EXACT: clefTransposition.semitonesToClefSuffix returns the
484
+ * suffix for the interval that spans exactly this many semitones, using the `m`
485
+ * (minor) form where the major/perfect interval does not fit — so -3 becomes
486
+ * `_m3` rather than the nearest major third `_3`. It previously approximated with
487
+ * round(semi * 7/12), which put a one-semitone error on the sounding pitch of
488
+ * every minor-interval transposition (-3 A clarinet, -8, -10 E-flat instruments:
489
+ * 37% of transposing voices in the corpora checked).
490
+ *
491
+ * A semitone count with no exact suffix (only the tritone, ±6, within an octave)
492
+ * is dropped with a warning rather than rounded: a silent one-semitone pitch error
493
+ * is worse than a visibly absent transposition.
487
494
  */
488
495
  const transposeClefSuffix = (clef: Clef | undefined, semitones: number): Clef | undefined => {
489
496
  if (!clef || !semitones) return clef;
490
- // A clef that already carries an octave suffix (treble_8 etc.) is left as-is;
491
- // stacking another shift on top is not meaningful for ABC sources.
492
- if (/[_^]\d+$/.test(clef as string)) return clef;
493
- const steps = Math.round((semitones * 7) / 12);
494
- if (steps === 0) return clef;
495
- const num = Math.abs(steps) + 1;
496
- const suffix = (steps < 0 ? "_" : "^") + num;
497
- return (clef + suffix) as Clef;
497
+ // A clef that already carries a transposition suffix (treble_8 etc.) is left
498
+ // as-is; stacking another shift on top is not meaningful for ABC sources.
499
+ if (parseClefSuffix(clef as string)) return clef;
500
+ const transposed = withClefTransposition(clef as string, semitones);
501
+ if (transposed === undefined) {
502
+ console.warn(`abcDecoder: transpose=${semitones} has no exact clef suffix; transposition dropped for clef "${clef}"`);
503
+ return clef;
504
+ }
505
+ return transposed as Clef;
498
506
  };
499
507
 
500
508
  /**
@@ -1599,6 +1607,15 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1599
1607
  // the pickup/anacrusis handling below the per-measure assembly).
1600
1608
  let curTime: TimeSig | undefined = timeSig;
1601
1609
 
1610
+ // ABC voice numbers whose declared clef has already been emitted. A voice gets its
1611
+ // clef on the measure where it FIRST carries music, which is not always measure 0:
1612
+ // a header field the grammar does not consume (e.g. `I:percmap`) degenerates into a
1613
+ // phantom measure 0 holding a single patch with no voice number, and the emission
1614
+ // was previously gated on `mi === 0`, so every real voice lost its clef — and with
1615
+ // it its transposition. Keying on the voice number instead of the measure index
1616
+ // makes the emission independent of what measure 0 happens to contain.
1617
+ const emittedClefVoices = new Set<number>();
1618
+
1602
1619
  for (let mi = 0; mi < measures.length; mi++) {
1603
1620
  const abcMeasure = measures[mi];
1604
1621
 
@@ -1745,31 +1762,39 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1745
1762
  for (const pi of sortedPartIndices) {
1746
1763
  const voicesMap = partVoicesMap.get(pi)!;
1747
1764
  const voices: Voice[] = [];
1765
+ // Voice -> the ABC voice number it came from, recovered from the map key
1766
+ // (voiceKey = staffInPart * 1000 + voiceNum). Voice itself carries no voice
1767
+ // number, and the clef below is a per-voice property, so the association has
1768
+ // to be kept explicitly rather than searched for.
1769
+ const voiceNumOf = new Map<Voice, number>();
1748
1770
  const sortedKeys = Array.from(voicesMap.keys()).sort((a, b) => a - b);
1749
1771
  for (const key of sortedKeys) {
1750
- voices.push(voicesMap.get(key)!);
1772
+ const voice = voicesMap.get(key)!;
1773
+ voices.push(voice);
1774
+ voiceNumOf.set(voice, key % 1000);
1751
1775
  }
1752
1776
 
1753
1777
  const part: Part = { voices };
1754
1778
 
1755
- // Add clef context to first voice of each staff on first measure
1756
- if (mi === 0) {
1757
- for (const voice of voices) {
1758
- // Find voices for this part's staff and add initial clef
1759
- const voiceNums = Array.from(voicePatches.keys());
1760
- for (const vn of voiceNums) {
1761
- if (scoreLayout) {
1762
- const assign = scoreLayout.get(vn);
1763
- if (assign && assign.partIndex === pi && assign.staffInPart === voice.staff) {
1764
- const clef = voiceClefs.get(vn);
1765
- if (clef) {
1766
- voice.events.unshift({ type: "context", clef } as ContextChange);
1767
- break;
1768
- }
1769
- }
1770
- }
1771
- }
1772
- }
1779
+ // Give each voice its OWN declared clef on the measure where it first
1780
+ // appears. A clef must be per voice, not per staff: an ABC voice folds its
1781
+ // `transpose=` into the clef suffix, so two voices sharing a staff can
1782
+ // legitimately differ, and lilylet's model is per-voice throughout
1783
+ // (onsets.measureOnsets tracks clefByVoice, serializer.findVoiceClef reads
1784
+ // one clef per Voice).
1785
+ //
1786
+ // This used to scan for the FIRST voice whose scoreLayout assignment matched
1787
+ // (partIndex, staff) and break, so every voice on a shared staff inherited
1788
+ // the first one's clef — the WRONG clef, taking its transposition with it.
1789
+ // A `%%score` arc such as `( 1 2 )` puts both voices on one staff and is
1790
+ // exactly that shape.
1791
+ for (const voice of voices) {
1792
+ const vn = voiceNumOf.get(voice)!;
1793
+ if (emittedClefVoices.has(vn)) continue;
1794
+ const clef = voiceClefs.get(vn);
1795
+ if (!clef) continue;
1796
+ voice.events.unshift({ type: "context", clef } as ContextChange);
1797
+ emittedClefVoices.add(vn);
1773
1798
  }
1774
1799
 
1775
1800
  parts.push(part);
@@ -0,0 +1,149 @@
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
+
39
+ // Semitones spanned by the MAJOR/PERFECT form of each diatonic step count
40
+ // (0 = unison, 1 = second, … 6 = seventh).
41
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
42
+
43
+ // Diatonic step counts that have a minor form: seconds, thirds, sixths, sevenths.
44
+ // Unison, fourth and fifth are perfect and take no `m`.
45
+ const MINOR_CAPABLE_STEPS = new Set([1, 2, 5, 6]);
46
+
47
+ /** A parsed transposing-clef suffix. */
48
+ export interface ClefSuffix {
49
+ /** The clef name with the suffix removed, e.g. "treble". */
50
+ base: string;
51
+ /** Diatonic interval number as written: 8 for an octave, 3 for a third. */
52
+ interval: number;
53
+ /** True when the suffix selected the minor form (`m` present). */
54
+ minor: boolean;
55
+ /** true = `^` (up), false = `_` (down). */
56
+ up: boolean;
57
+ }
58
+
59
+ // `_`/`^`, an optional `m`, then a positive interval number with no leading zero.
60
+ const SUFFIX_RE = /^(.*?)([_^])(m?)([1-9][0-9]*)$/;
61
+
62
+ /** Semitones spanned by a diatonic interval number, or undefined if `m` is invalid for it. */
63
+ const intervalSemitones = (interval: number, minor: boolean): number | undefined => {
64
+ const steps = interval - 1; // diatonic steps above unison
65
+ const within = steps % 7;
66
+ if (minor && !MINOR_CAPABLE_STEPS.has(within)) return undefined;
67
+ const semis = DIATONIC_SEMITONES[within] + 12 * Math.floor(steps / 7);
68
+ return minor ? semis - 1 : semis;
69
+ };
70
+
71
+ /**
72
+ * Parse a clef string into its base name and transposition suffix.
73
+ * Returns undefined when the clef carries no suffix, or when the suffix is
74
+ * malformed (including `m` on a perfect interval such as `_m4`).
75
+ */
76
+ export const parseClefSuffix = (clefStr: string): ClefSuffix | undefined => {
77
+ const match = SUFFIX_RE.exec(clefStr || "");
78
+ if (!match) return undefined;
79
+ const interval = parseInt(match[4], 10);
80
+ const minor = match[3] === "m";
81
+ // Reject a suffix we cannot interpret rather than silently mis-reading it.
82
+ if (intervalSemitones(interval, minor) === undefined) return undefined;
83
+ return { base: match[1], interval, minor, up: match[2] === "^" };
84
+ };
85
+
86
+ /** The clef name with any transposition suffix stripped. */
87
+ export const clefBaseName = (clefStr: string): string =>
88
+ parseClefSuffix(clefStr)?.base ?? clefStr;
89
+
90
+ /**
91
+ * The written→sounding semitone shift a clef string declares; 0 when it declares
92
+ * none (no suffix, or a suffix that cannot be interpreted).
93
+ */
94
+ export const clefSuffixSemitones = (clefStr: string): number => {
95
+ const parsed = parseClefSuffix(clefStr);
96
+ if (!parsed) return 0;
97
+ const semis = intervalSemitones(parsed.interval, parsed.minor)!;
98
+ return parsed.up ? semis : -semis;
99
+ };
100
+
101
+ /**
102
+ * The written→sounding transposition as MEI's att.transposition pair, in the
103
+ * `{diat, semi}` form: diatonic steps and semitones, both signed. A minor third
104
+ * down is `{diat: -2, semi: -3}` — the two differ, which is exactly the
105
+ * information the diatonic number alone cannot carry.
106
+ */
107
+ export const clefSuffixTransposition = (clefStr: string): { diat: number; semi: number } | undefined => {
108
+ const parsed = parseClefSuffix(clefStr);
109
+ if (!parsed) return undefined;
110
+ const semis = intervalSemitones(parsed.interval, parsed.minor)!;
111
+ const sign = parsed.up ? 1 : -1;
112
+ return { diat: sign * (parsed.interval - 1), semi: sign * semis };
113
+ };
114
+
115
+ /**
116
+ * The suffix that expresses `semitones` EXACTLY, or undefined when no suffix can.
117
+ * Never approximates: a caller that gets undefined must handle the gap rather than
118
+ * emit a shift it cannot honour. The only unrepresentable interval within an octave
119
+ * is 6 semitones (the tritone) — see the module comment.
120
+ *
121
+ * Returns the suffix alone (e.g. "_m3"), to be appended to a clef base name.
122
+ */
123
+ export const semitonesToClefSuffix = (semitones: number): string | undefined => {
124
+ if (!Number.isInteger(semitones) || semitones === 0) return undefined;
125
+ const magnitude = Math.abs(semitones);
126
+ const sign = semitones < 0 ? "_" : "^";
127
+ // Search interval numbers covering the requested magnitude; octaves shift by 7
128
+ // diatonic steps, so an octave-and-a-bit needs interval numbers beyond 8.
129
+ const maxInterval = Math.floor(magnitude / 12) * 7 + 8;
130
+ for (let interval = 1; interval <= maxInterval; interval++) {
131
+ for (const minor of [false, true]) {
132
+ if (intervalSemitones(interval, minor) === magnitude)
133
+ return `${sign}${minor ? "m" : ""}${interval}`;
134
+ }
135
+ }
136
+ return undefined;
137
+ };
138
+
139
+ /**
140
+ * Apply a semitone transposition to a clef name, returning the suffixed clef.
141
+ * Returns undefined when the shift has no exact suffix, so the caller can decide
142
+ * between dropping the transposition and reporting it — silently rounding is what
143
+ * produced wrong sounding pitches before this module existed.
144
+ */
145
+ export const withClefTransposition = (base: string, semitones: number): string | undefined => {
146
+ if (semitones === 0) return base;
147
+ const suffix = semitonesToClefSuffix(semitones);
148
+ return suffix === undefined ? undefined : base + suffix;
149
+ };
@@ -5,6 +5,7 @@ export * from "./serializer";
5
5
  export * from "./staffLayout";
6
6
  export * from "./measureLayout";
7
7
  export * from "./onsets";
8
+ export * from "./clefTransposition";
8
9
  export * from "./transposition";
9
10
 
10
11
  import * as meiEncoder from "./meiEncoder";
@@ -32,6 +32,7 @@ import {
32
32
  import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
33
33
  import { gmProgramOf } from "./gmInstruments";
34
34
  import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
35
+ import { clefBaseName, clefSuffixTransposition } from "./clefTransposition";
35
36
 
36
37
 
37
38
  // MEI key signatures: positive = sharps, negative = flats
@@ -88,7 +89,7 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
88
89
 
89
90
  // Semitone offsets of the major/perfect intervals within one diatonic octave,
90
91
  // indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
91
- const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
92
+
92
93
 
93
94
  // Convert a LilyPond clef interval-number suffix into an MEI written→sounding
94
95
  // transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
@@ -96,29 +97,23 @@ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
96
97
  // raises it. Returns { diat, semi } where diat is the diatonic step shift
97
98
  // (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
98
99
  // extended octave-wise for compound intervals.
99
- const clefTransposition = (intervalNumber: number, up: boolean): { diat: number; semi: number } => {
100
- const k = intervalNumber - 1; // diatonic steps
101
- const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
102
- const sign = up ? 1 : -1;
103
- return { diat: sign * k, semi: sign * semis };
104
- };
100
+
105
101
 
106
102
  // Resolve a clef string into MEI shape/line plus optional written→sounding
107
- // transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
108
- // clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
109
- // "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
110
- // displacement (8|15|22), so all clef transposition — octaves included — is
111
- // encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
103
+ // transposition. A "_N"/"^N" suffix transposes the clef down/up by the diatonic
104
+ // interval N ("treble_8" octave down, "treble_5" fifth down), and "_mN"/"^mN"
105
+ // selects that interval's minor form ("treble_m3" = minor third down) — see
106
+ // clefTransposition.ts. MEI's clef.dis only covers octave displacement
107
+ // (8|15|22), so all clef transposition — octaves included — is encoded uniformly
108
+ // via att.transposition (trans.diat / trans.semi) on staffDef. A minor interval is
109
+ // exactly why both fields are needed: a minor third is {diat:-2, semi:-3}.
112
110
  const resolveClef = (clefStr: string): { shape: string; line: number; trans?: { diat: number; semi: number } } => {
113
- const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
114
- const base = match ? match[1] : clefStr;
115
- const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
116
- if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
117
- const trans = clefTransposition(Number(match[3]), match[2] === "^");
111
+ const clefInfo = CLEF_SHAPES[clefBaseName(clefStr)] || CLEF_SHAPES.treble;
112
+ const trans = clefSuffixTransposition(clefStr);
118
113
  return {
119
114
  shape: clefInfo.shape,
120
115
  line: clefInfo.line,
121
- trans,
116
+ ...(trans ? { trans } : {}),
122
117
  };
123
118
  };
124
119
 
@@ -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