@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.
- package/lib/abc/grammar.jison.js +218 -213
- package/lib/clefTransposition.d.ts +1 -0
- package/lib/clefTransposition.js +1 -0
- package/lib/lilylet/abcDecoder.js +60 -35
- package/lib/lilylet/clefTransposition.d.ts +111 -0
- package/lib/lilylet/clefTransposition.js +175 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/lilypondDecoder.js +88 -2
- package/lib/lilylet/lilypondEncoder.js +32 -2
- package/lib/lilylet/meiEncoder.js +11 -19
- package/lib/lilylet/musicXmlDecoder.js +56 -3
- package/lib/lilylet/musicXmlEncoder.js +33 -1
- package/lib/lilylet/musicXmlTypes.d.ts +6 -0
- package/lib/lilylet/onsets.d.ts +5 -4
- package/lib/lilylet/onsets.js +7 -14
- package/package.json +4 -1
- package/source/abc/abc.jison +2 -0
- package/source/abc/grammar.jison.js +218 -213
- package/source/lilylet/abcDecoder.ts +60 -35
- package/source/lilylet/clefTransposition.ts +197 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilypondDecoder.ts +89 -2
- package/source/lilylet/lilypondEncoder.ts +36 -2
- package/source/lilylet/meiEncoder.ts +13 -18
- package/source/lilylet/musicXmlDecoder.ts +67 -3
- package/source/lilylet/musicXmlEncoder.ts +35 -1
- package/source/lilylet/musicXmlTypes.ts +10 -0
- package/source/lilylet/onsets.ts +7 -14
|
@@ -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
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
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
|
|
491
|
-
// stacking another shift on top is not meaningful for ABC sources.
|
|
492
|
-
if (
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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,197 @@
|
|
|
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
|
+
};
|
|
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
|
+
};
|
package/source/lilylet/index.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
Tempo,
|
|
44
44
|
} from "./types";
|
|
45
45
|
|
|
46
|
+
import { clefBaseName, parseClefSuffix, withClefTransposition } from "./clefTransposition";
|
|
47
|
+
|
|
46
48
|
|
|
47
49
|
// Phonet names mapping
|
|
48
50
|
const PHONET_NAMES: Record<number, Phonet> = {
|
|
@@ -77,6 +79,72 @@ const LILYPOND_CLEF_MAP: Record<string, Clef> = {
|
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fold a standing `\transposition` shift onto a clef as a lilylet suffix.
|
|
84
|
+
*
|
|
85
|
+
* The two carry different things in LilyPond and must not be added: a clef suffix
|
|
86
|
+
* there is notational and moves no pitch, while `\transposition` moves pitch and
|
|
87
|
+
* draws nothing. lilylet's suffix means sounding shift, so `\transposition` is the
|
|
88
|
+
* authority whenever it is present — a file with BOTH `\clef "treble_8"` and
|
|
89
|
+
* `\transposition c` (which is what lilypondEncoder emits for an octave clef) states
|
|
90
|
+
* one octave, not two.
|
|
91
|
+
*
|
|
92
|
+
* With no `\transposition`, a suffix already on the clef is kept as-is: that is the
|
|
93
|
+
* plain octave-clef part, where the notation is taken to imply its own shift.
|
|
94
|
+
*/
|
|
95
|
+
const applyTransposition = (clef: Clef | undefined, semitones: number): Clef | undefined => {
|
|
96
|
+
if (!clef || semitones === 0) return clef;
|
|
97
|
+
const base = clefBaseName(clef as string);
|
|
98
|
+
const shifted = withClefTransposition(base, semitones);
|
|
99
|
+
if (shifted === undefined) {
|
|
100
|
+
console.warn(`lilypondDecoder: \\transposition of ${semitones} semitones has no exact clef suffix; dropped for clef "${clef}"`);
|
|
101
|
+
return clef;
|
|
102
|
+
}
|
|
103
|
+
return shifted as Clef;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Semitones above c for each diatonic step index (0 = c ... 6 = b).
|
|
107
|
+
const PHONET_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Semitone offset from c' of a `\transposition` pitch term, which is how LilyPond
|
|
111
|
+
* states a transposing instrument's written->sounding shift (the clef suffix there is
|
|
112
|
+
* notational and states nothing). Returns 0 for a pitch that cannot be read.
|
|
113
|
+
*/
|
|
114
|
+
const transpositionSemitones = (term: any): number => {
|
|
115
|
+
const el = term?.args?.[0];
|
|
116
|
+
if (!el) return 0;
|
|
117
|
+
const step = el.phonetStep;
|
|
118
|
+
const octave = el.absolutePitch?.octave;
|
|
119
|
+
if (typeof step !== 'number' || typeof octave !== 'number') return 0;
|
|
120
|
+
// c' is the no-transposition reference, and sits at 12 in this scale.
|
|
121
|
+
return (PHONET_SEMITONES[step] + (el.alterValue || 0) + 12 * octave) - 12;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A LilyPond clef name to a lilylet Clef, keeping any transposing suffix.
|
|
126
|
+
*
|
|
127
|
+
* The map is keyed on BASE names, so looking up the whole string missed every
|
|
128
|
+
* suffixed clef ("treble_8" is not a key) and `if (clef)` then dropped the clef
|
|
129
|
+
* event altogether — the transposition went with it. Resolve the base through the
|
|
130
|
+
* map and reattach the suffix, which is the form lilylet's own model uses.
|
|
131
|
+
*
|
|
132
|
+
* A suffix LilyPond cannot write (the `m` minor-interval form) cannot appear in
|
|
133
|
+
* LilyPond input, so nothing here has to handle it; parseClefSuffix simply returns
|
|
134
|
+
* undefined for an unsuffixed name and the base is used as-is.
|
|
135
|
+
*/
|
|
136
|
+
const resolveLilyPondClef = (clefName: string): Clef | undefined => {
|
|
137
|
+
const direct = LILYPOND_CLEF_MAP[clefName];
|
|
138
|
+
if (direct) return direct;
|
|
139
|
+
|
|
140
|
+
const parsed = parseClefSuffix(clefName);
|
|
141
|
+
if (!parsed) return undefined;
|
|
142
|
+
const base = LILYPOND_CLEF_MAP[parsed.base];
|
|
143
|
+
if (!base) return undefined;
|
|
144
|
+
return (base + clefName.slice(parsed.base.length)) as Clef;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
|
|
80
148
|
// Key signature fifths to pitch/accidental mapping
|
|
81
149
|
const KEY_FIFTHS_MAP: Record<number, { pitch: Phonet; accidental?: Accidental; mode: 'major' | 'minor' }> = {
|
|
82
150
|
[-7]: { pitch: Phonet.c, accidental: Accidental.flat, mode: 'major' },
|
|
@@ -569,6 +637,9 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
569
637
|
let lastKey: number | undefined = undefined; // Track value changes (key fifths)
|
|
570
638
|
let lastTimeSig: string | undefined = undefined; // Track value changes (as string for comparison)
|
|
571
639
|
let lastClef: Clef | undefined = undefined; // Track value changes
|
|
640
|
+
// \transposition is a standing declaration: it applies to every clef emitted
|
|
641
|
+
// after it, and lilylet folds the shift into the clef suffix.
|
|
642
|
+
let transposeSemis = 0;
|
|
572
643
|
let lastOttava: number | undefined = undefined; // Track value changes
|
|
573
644
|
let lastStemDirection: string | undefined = undefined; // Track value changes
|
|
574
645
|
let partialEmitted = false; // Emit \partial context once per track
|
|
@@ -669,7 +740,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
669
740
|
|
|
670
741
|
// Handle clef context change (emit when value changes)
|
|
671
742
|
if (context.clef) {
|
|
672
|
-
const clef =
|
|
743
|
+
const clef = applyTransposition(resolveLilyPondClef(context.clef.clefName), transposeSemis);
|
|
673
744
|
if (clef && clef !== lastClef) {
|
|
674
745
|
voice.events.push({
|
|
675
746
|
type: 'context',
|
|
@@ -796,9 +867,25 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
796
867
|
}
|
|
797
868
|
}
|
|
798
869
|
}
|
|
870
|
+
else if (term instanceof lilyParser.LilyTerms.Transposition) {
|
|
871
|
+
const semis = transpositionSemitones(term);
|
|
872
|
+
if (semis !== transposeSemis) {
|
|
873
|
+
transposeSemis = semis;
|
|
874
|
+
// A `\transposition` AFTER the clef still governs it, so re-emit the
|
|
875
|
+
// clef with the shift folded in rather than let the already-emitted
|
|
876
|
+
// plain clef stand. Order between the two is not fixed in the wild.
|
|
877
|
+
if (lastClef) {
|
|
878
|
+
const corrected = applyTransposition(clefBaseName(lastClef as string) as Clef, transposeSemis);
|
|
879
|
+
if (corrected && corrected !== lastClef) {
|
|
880
|
+
voice.events.push({ type: 'context', clef: corrected });
|
|
881
|
+
lastClef = corrected;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
799
886
|
// Handle standalone clef (emit when value changes)
|
|
800
887
|
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
801
|
-
const clef =
|
|
888
|
+
const clef = applyTransposition(resolveLilyPondClef(term.clefName), transposeSemis);
|
|
802
889
|
if (clef && clef !== lastClef) {
|
|
803
890
|
voice.events.push({
|
|
804
891
|
type: 'context',
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
Placement,
|
|
39
39
|
} from "./types";
|
|
40
40
|
|
|
41
|
+
import { clefBaseName, clefSuffixSemitones, isLilyPondClefSuffix, lilyPondTranspositionPitch } from "./clefTransposition";
|
|
42
|
+
|
|
41
43
|
|
|
42
44
|
// === Constants and Mappings ===
|
|
43
45
|
|
|
@@ -306,10 +308,42 @@ const encodeTimeSig = (timeSig: { numerator: number; denominator: number; symbol
|
|
|
306
308
|
|
|
307
309
|
|
|
308
310
|
/**
|
|
309
|
-
* Encode clef to LilyPond
|
|
311
|
+
* Encode clef to LilyPond.
|
|
312
|
+
*
|
|
313
|
+
* A lilylet clef suffix carries a written→sounding shift; LilyPond's does not —
|
|
314
|
+
* there the suffix only repositions middle C on the staff and MIDI is untouched,
|
|
315
|
+
* and sounding pitch comes from the separate `\transposition <pitch>`. The two are
|
|
316
|
+
* orthogonal, so a transposing clef translates to BOTH: the suffix for the glyph,
|
|
317
|
+
* `\transposition` for the sound. Emitting only the suffix (what this did before)
|
|
318
|
+
* produced a file whose notation was right and whose MIDI was a transposition out.
|
|
319
|
+
*
|
|
320
|
+
* The clef string is quoted. LilyPond accepts a bare name unquoted, but `_m3`
|
|
321
|
+
* lexes as `_` followed by `m3` and breaks the parse; quoting is valid for every
|
|
322
|
+
* form. A `m` suffix has no LilyPond glyph at all (unknown clef type), so the base
|
|
323
|
+
* clef is emitted and the shift rides entirely on `\transposition`.
|
|
310
324
|
*/
|
|
311
325
|
const encodeClef = (clef: Clef): string => {
|
|
312
|
-
|
|
326
|
+
const clefStr = clef as string;
|
|
327
|
+
const shift = clefSuffixSemitones(clefStr);
|
|
328
|
+
|
|
329
|
+
// Keep the suffix only when LilyPond can render it; otherwise fall back to the
|
|
330
|
+
// base name rather than emit a clef LilyPond rejects.
|
|
331
|
+
const name = (shift === 0 || isLilyPondClefSuffix(clefStr)) ? clefStr : clefBaseName(clefStr);
|
|
332
|
+
const mapped = CLEF_MAP[name] || name;
|
|
333
|
+
const parts: string[] = [];
|
|
334
|
+
|
|
335
|
+
// \transposition comes FIRST so that a reader tracking it as a standing
|
|
336
|
+
// declaration has the shift in hand by the time the clef arrives.
|
|
337
|
+
if (shift !== 0) {
|
|
338
|
+
const pitch = lilyPondTranspositionPitch(shift);
|
|
339
|
+
if (pitch)
|
|
340
|
+
parts.push(`\\transposition ${pitch}`);
|
|
341
|
+
else
|
|
342
|
+
console.warn(`lilypondEncoder: clef "${clefStr}" shift ${shift} has no \\transposition pitch; sounding pitch not declared`);
|
|
343
|
+
}
|
|
344
|
+
parts.push(`\\clef "${mapped}"`);
|
|
345
|
+
|
|
346
|
+
return parts.join(' ');
|
|
313
347
|
};
|
|
314
348
|
|
|
315
349
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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
|
|
114
|
-
const
|
|
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
|
|