@k-l-lambda/lilylet 0.1.85 → 0.1.88

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.
@@ -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 "./transposition";
7
8
  import * as meiEncoder from "./meiEncoder";
8
9
  import * as musicXmlDecoder from "./musicXmlDecoder";
9
10
  import * as lilypondEncoder from "./lilypondEncoder";
@@ -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 "./transposition.js";
7
8
  import * as meiEncoder from "./meiEncoder.js";
8
9
  import * as musicXmlDecoder from "./musicXmlDecoder.js";
9
10
  import * as lilypondEncoder from "./lilypondEncoder.js";
@@ -0,0 +1,5 @@
1
+ import { LilyletDoc, Pitch } from "./types";
2
+ /** Transpose one resolved Lilylet pitch by an integer number of semitones. */
3
+ export declare const transposePitch: (pitch: Pitch, semitones: number) => Pitch;
4
+ /** Return a new Lilylet document with every AST pitch shifted chromatically. */
5
+ export declare const transposeDoc: (doc: LilyletDoc, semitones: number) => LilyletDoc;
@@ -0,0 +1,238 @@
1
+ import { Accidental, Phonet, } from "./types.js";
2
+ const PHONET_SEMITONE = {
3
+ [Phonet.c]: 0,
4
+ [Phonet.d]: 2,
5
+ [Phonet.e]: 4,
6
+ [Phonet.f]: 5,
7
+ [Phonet.g]: 7,
8
+ [Phonet.a]: 9,
9
+ [Phonet.b]: 11,
10
+ };
11
+ const ACCIDENTAL_SEMITONE = {
12
+ [Accidental.natural]: 0,
13
+ [Accidental.sharp]: 1,
14
+ [Accidental.flat]: -1,
15
+ [Accidental.doubleSharp]: 2,
16
+ [Accidental.doubleFlat]: -2,
17
+ };
18
+ // Chromatic pitches have several enharmonic names. Follow the transposition
19
+ // direction where a choice exists: upward motion prefers sharps, downward
20
+ // motion prefers flats. Both tables use only accidentals supported by Lilylet.
21
+ const SHARP_SPELLINGS = [
22
+ { phonet: Phonet.c },
23
+ { phonet: Phonet.c, accidental: Accidental.sharp },
24
+ { phonet: Phonet.d },
25
+ { phonet: Phonet.d, accidental: Accidental.sharp },
26
+ { phonet: Phonet.e },
27
+ { phonet: Phonet.f },
28
+ { phonet: Phonet.f, accidental: Accidental.sharp },
29
+ { phonet: Phonet.g },
30
+ { phonet: Phonet.g, accidental: Accidental.sharp },
31
+ { phonet: Phonet.a },
32
+ { phonet: Phonet.a, accidental: Accidental.sharp },
33
+ { phonet: Phonet.b },
34
+ ];
35
+ const FLAT_SPELLINGS = [
36
+ { phonet: Phonet.c },
37
+ { phonet: Phonet.d, accidental: Accidental.flat },
38
+ { phonet: Phonet.d },
39
+ { phonet: Phonet.e, accidental: Accidental.flat },
40
+ { phonet: Phonet.e },
41
+ { phonet: Phonet.f },
42
+ { phonet: Phonet.g, accidental: Accidental.flat },
43
+ { phonet: Phonet.g },
44
+ { phonet: Phonet.a, accidental: Accidental.flat },
45
+ { phonet: Phonet.a },
46
+ { phonet: Phonet.b, accidental: Accidental.flat },
47
+ { phonet: Phonet.b },
48
+ ];
49
+ const MAJOR_KEY_BY_FIFTHS = {
50
+ [-6]: { phonet: Phonet.g, accidental: Accidental.flat },
51
+ [-5]: { phonet: Phonet.d, accidental: Accidental.flat },
52
+ [-4]: { phonet: Phonet.a, accidental: Accidental.flat },
53
+ [-3]: { phonet: Phonet.e, accidental: Accidental.flat },
54
+ [-2]: { phonet: Phonet.b, accidental: Accidental.flat },
55
+ [-1]: { phonet: Phonet.f },
56
+ [0]: { phonet: Phonet.c },
57
+ [1]: { phonet: Phonet.g },
58
+ [2]: { phonet: Phonet.d },
59
+ [3]: { phonet: Phonet.a },
60
+ [4]: { phonet: Phonet.e },
61
+ [5]: { phonet: Phonet.b },
62
+ [6]: { phonet: Phonet.f, accidental: Accidental.sharp },
63
+ };
64
+ const MINOR_KEY_BY_FIFTHS = {
65
+ [-6]: { phonet: Phonet.e, accidental: Accidental.flat },
66
+ [-5]: { phonet: Phonet.b, accidental: Accidental.flat },
67
+ [-4]: { phonet: Phonet.f },
68
+ [-3]: { phonet: Phonet.c },
69
+ [-2]: { phonet: Phonet.g },
70
+ [-1]: { phonet: Phonet.d },
71
+ [0]: { phonet: Phonet.a },
72
+ [1]: { phonet: Phonet.e },
73
+ [2]: { phonet: Phonet.b },
74
+ [3]: { phonet: Phonet.f, accidental: Accidental.sharp },
75
+ [4]: { phonet: Phonet.c, accidental: Accidental.sharp },
76
+ [5]: { phonet: Phonet.g, accidental: Accidental.sharp },
77
+ [6]: { phonet: Phonet.d, accidental: Accidental.sharp },
78
+ };
79
+ const DIATONIC_INDEX = {
80
+ [Phonet.c]: 0,
81
+ [Phonet.d]: 1,
82
+ [Phonet.e]: 2,
83
+ [Phonet.f]: 3,
84
+ [Phonet.g]: 4,
85
+ [Phonet.a]: 5,
86
+ [Phonet.b]: 6,
87
+ };
88
+ const NATURAL_MAJOR_FIFTHS = {
89
+ [Phonet.c]: 0, [Phonet.d]: 2, [Phonet.e]: 4, [Phonet.f]: -1,
90
+ [Phonet.g]: 1, [Phonet.a]: 3, [Phonet.b]: 5,
91
+ };
92
+ const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11];
93
+ const MINOR_SCALE = [0, 2, 3, 5, 7, 8, 10];
94
+ const mod = (value, divisor) => ((value % divisor) + divisor) % divisor;
95
+ const pitchClass = (pitch) => PHONET_SEMITONE[pitch.phonet] + (pitch.accidental ? ACCIDENTAL_SEMITONE[pitch.accidental] : 0);
96
+ const keyDegreePitchClass = (key, degree) => {
97
+ const scale = key.mode === "minor" ? MINOR_SCALE : MAJOR_SCALE;
98
+ return mod(keyPitchClass(key) + scale[degree], 12);
99
+ };
100
+ const signedAlteration = (actual, expected) => {
101
+ let alteration = mod(actual - expected + 6, 12) - 6;
102
+ if (alteration === -6)
103
+ alteration = 6;
104
+ return alteration;
105
+ };
106
+ const accidentalFor = (alteration) => ({
107
+ [-2]: Accidental.doubleFlat,
108
+ [-1]: Accidental.flat,
109
+ [0]: undefined,
110
+ [1]: Accidental.sharp,
111
+ [2]: Accidental.doubleSharp,
112
+ }[alteration]);
113
+ const transposePitchInKey = (pitch, sourceKey, targetKey, semitones) => {
114
+ const sourceDegree = mod(DIATONIC_INDEX[pitch.phonet] - DIATONIC_INDEX[sourceKey.pitch], 7);
115
+ const alteration = signedAlteration(pitchClass(pitch), keyDegreePitchClass(sourceKey, sourceDegree));
116
+ const targetIndex = mod(DIATONIC_INDEX[targetKey.pitch] + sourceDegree, 7);
117
+ const targetPhonet = Object.keys(DIATONIC_INDEX).find(phonet => DIATONIC_INDEX[phonet] === targetIndex);
118
+ const targetExpected = keyDegreePitchClass(targetKey, sourceDegree);
119
+ const writtenAccidental = signedAlteration(targetExpected + alteration, PHONET_SEMITONE[targetPhonet]);
120
+ const accidental = accidentalFor(writtenAccidental);
121
+ if (accidental === undefined && writtenAccidental !== 0)
122
+ return undefined;
123
+ const targetAbsolute = pitch.octave * 12 + pitchClass(pitch) + semitones;
124
+ const rawTargetClass = PHONET_SEMITONE[targetPhonet] + writtenAccidental;
125
+ const octave = Math.floor((targetAbsolute - rawTargetClass) / 12);
126
+ return {
127
+ phonet: targetPhonet,
128
+ ...(accidental ? { accidental } : {}),
129
+ octave,
130
+ ...(pitch.courtesy !== undefined ? { courtesy: pitch.courtesy } : {}),
131
+ };
132
+ };
133
+ const spellingFor = (pitchClass, semitones) => (semitones < 0 ? FLAT_SPELLINGS : SHARP_SPELLINGS)[mod(pitchClass, 12)];
134
+ const keyFifths = (key) => {
135
+ let fifths = NATURAL_MAJOR_FIFTHS[key.pitch];
136
+ if (key.accidental === Accidental.sharp)
137
+ fifths += 7;
138
+ else if (key.accidental === Accidental.flat)
139
+ fifths -= 7;
140
+ if (key.mode === "minor")
141
+ fifths -= 3;
142
+ return fifths;
143
+ };
144
+ const spellingPitchClass = (spelling) => mod(PHONET_SEMITONE[spelling.phonet] + (spelling.accidental ? ACCIDENTAL_SEMITONE[spelling.accidental] : 0), 12);
145
+ const keyPitchClass = (key) => spellingPitchClass({ phonet: key.pitch, accidental: key.accidental });
146
+ const normalizedKeyFor = (pitchClass, mode, semitones) => {
147
+ const candidates = Object.entries(mode === "minor" ? MINOR_KEY_BY_FIFTHS : MAJOR_KEY_BY_FIFTHS)
148
+ .map(([fifths, spelling]) => ({ fifths: Number(fifths), spelling }))
149
+ .filter(candidate => spellingPitchClass(candidate.spelling) === mod(pitchClass, 12));
150
+ const preferred = candidates.find(candidate => semitones < 0 ? candidate.fifths < 0 : candidate.fifths >= 0)
151
+ ?? candidates[0];
152
+ if (!preferred)
153
+ throw new Error(`No normalized ${mode} key for pitch class ${mod(pitchClass, 12)}`);
154
+ return {
155
+ pitch: preferred.spelling.phonet,
156
+ ...(preferred.spelling.accidental ? { accidental: preferred.spelling.accidental } : {}),
157
+ mode,
158
+ };
159
+ };
160
+ /** Transpose one resolved Lilylet pitch by an integer number of semitones. */
161
+ export const transposePitch = (pitch, semitones) => {
162
+ if (semitones === 0)
163
+ return { ...pitch };
164
+ const absolute = pitch.octave * 12 + pitchClass(pitch) + semitones;
165
+ const octave = Math.floor(absolute / 12);
166
+ const spelling = spellingFor(absolute, semitones);
167
+ return {
168
+ phonet: spelling.phonet,
169
+ ...(spelling.accidental ? { accidental: spelling.accidental } : {}),
170
+ octave,
171
+ ...(pitch.courtesy !== undefined ? { courtesy: pitch.courtesy } : {}),
172
+ };
173
+ };
174
+ const transposeKey = (key, semitones) => {
175
+ const sourceClass = keyPitchClass(key);
176
+ const target = spellingFor(sourceClass + semitones, semitones);
177
+ const directional = {
178
+ pitch: target.phonet,
179
+ ...(target.accidental ? { accidental: target.accidental } : {}),
180
+ mode: key.mode,
181
+ };
182
+ return Math.abs(keyFifths(directional)) <= 6
183
+ ? directional
184
+ : normalizedKeyFor(sourceClass + semitones, key.mode, semitones);
185
+ };
186
+ const transposePitchWithKeys = (pitch, sourceKey, targetKey, semitones) => transposePitchInKey(pitch, sourceKey, targetKey, semitones) ?? transposePitch(pitch, semitones);
187
+ const transposeEvent = (event, semitones, state) => {
188
+ switch (event.type) {
189
+ case "note":
190
+ return { ...event, pitches: event.pitches.map(pitch => transposePitchWithKeys(pitch, state.source, state.target, semitones)) };
191
+ case "rest":
192
+ return { ...event, ...(event.pitch ? { pitch: transposePitchWithKeys(event.pitch, state.source, state.target, semitones) } : {}) };
193
+ case "context": {
194
+ const context = event;
195
+ if (context.key) {
196
+ state.source = context.key;
197
+ state.target = transposeKey(context.key, semitones);
198
+ }
199
+ return { ...context, ...(context.key ? { key: state.target } : {}) };
200
+ }
201
+ case "tremolo":
202
+ return { ...event,
203
+ pitchA: event.pitchA.map(p => transposePitchWithKeys(p, state.source, state.target, semitones)),
204
+ pitchB: event.pitchB.map(p => transposePitchWithKeys(p, state.source, state.target, semitones)) };
205
+ case "tuplet":
206
+ case "times":
207
+ return { ...event, events: event.events.map(inner => transposeEvent(inner, semitones, state)) };
208
+ default:
209
+ return { ...event };
210
+ }
211
+ };
212
+ const transposeVoice = (voice, semitones, measureKey) => {
213
+ const state = { source: measureKey, target: transposeKey(measureKey, semitones) };
214
+ return { ...voice, events: voice.events.map(event => transposeEvent(event, semitones, state)) };
215
+ };
216
+ const transposePart = (part, semitones, measureKey) => ({
217
+ ...part,
218
+ voices: part.voices.map(voice => transposeVoice(voice, semitones, measureKey)),
219
+ });
220
+ const transposeMeasure = (measure, semitones, inheritedKey) => {
221
+ const sourceKey = measure.key ?? inheritedKey;
222
+ const targetKey = transposeKey(sourceKey, semitones);
223
+ return {
224
+ ...measure,
225
+ ...(measure.key ? { key: targetKey } : {}),
226
+ parts: measure.parts.map(part => transposePart(part, semitones, sourceKey)),
227
+ };
228
+ };
229
+ /** Return a new Lilylet document with every AST pitch shifted chromatically. */
230
+ export const transposeDoc = (doc, semitones) => {
231
+ if (!Number.isFinite(semitones) || !Number.isInteger(semitones)) {
232
+ throw new Error("transposeDoc semitones must be a finite integer");
233
+ }
234
+ return {
235
+ ...doc,
236
+ measures: doc.measures.map(measure => transposeMeasure(measure, semitones, { pitch: Phonet.c, mode: "major" })),
237
+ };
238
+ };
@@ -0,0 +1 @@
1
+ export * from "./lilylet/transposition.js";
@@ -0,0 +1 @@
1
+ export * from "./lilylet/transposition.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.85",
3
+ "version": "0.1.88",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -56,6 +56,7 @@
56
56
  "test:abc-partial": "tsx ./tests/unit/abcPartialPickup.test.ts",
57
57
  "test:clef-onset": "tsx ./tests/unit/clefOnsetTranspose.test.ts",
58
58
  "test:fine-rhythm-onset": "tsx ./tests/unit/fineRhythmOnset.test.ts",
59
+ "test:transpose": "tsx ./tests/unit/astTranspose.test.ts",
59
60
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
60
61
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
61
62
  "ts": "tsx"
@@ -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 "./transposition";
8
9
 
9
10
  import * as meiEncoder from "./meiEncoder";
10
11
  import * as musicXmlDecoder from "./musicXmlDecoder";
@@ -0,0 +1,280 @@
1
+ import {
2
+ Accidental,
3
+ ContextChange,
4
+ Event,
5
+ KeySignature,
6
+ LilyletDoc,
7
+ Measure,
8
+ Phonet,
9
+ Pitch,
10
+ Part,
11
+ Voice,
12
+ } from "./types";
13
+
14
+ const PHONET_SEMITONE: Record<Phonet, number> = {
15
+ [Phonet.c]: 0,
16
+ [Phonet.d]: 2,
17
+ [Phonet.e]: 4,
18
+ [Phonet.f]: 5,
19
+ [Phonet.g]: 7,
20
+ [Phonet.a]: 9,
21
+ [Phonet.b]: 11,
22
+ };
23
+
24
+ const ACCIDENTAL_SEMITONE: Record<Accidental, number> = {
25
+ [Accidental.natural]: 0,
26
+ [Accidental.sharp]: 1,
27
+ [Accidental.flat]: -1,
28
+ [Accidental.doubleSharp]: 2,
29
+ [Accidental.doubleFlat]: -2,
30
+ };
31
+
32
+ type Spelling = { phonet: Phonet; accidental?: Accidental };
33
+
34
+ // Chromatic pitches have several enharmonic names. Follow the transposition
35
+ // direction where a choice exists: upward motion prefers sharps, downward
36
+ // motion prefers flats. Both tables use only accidentals supported by Lilylet.
37
+ const SHARP_SPELLINGS: Spelling[] = [
38
+ { phonet: Phonet.c },
39
+ { phonet: Phonet.c, accidental: Accidental.sharp },
40
+ { phonet: Phonet.d },
41
+ { phonet: Phonet.d, accidental: Accidental.sharp },
42
+ { phonet: Phonet.e },
43
+ { phonet: Phonet.f },
44
+ { phonet: Phonet.f, accidental: Accidental.sharp },
45
+ { phonet: Phonet.g },
46
+ { phonet: Phonet.g, accidental: Accidental.sharp },
47
+ { phonet: Phonet.a },
48
+ { phonet: Phonet.a, accidental: Accidental.sharp },
49
+ { phonet: Phonet.b },
50
+ ];
51
+
52
+ const FLAT_SPELLINGS: Spelling[] = [
53
+ { phonet: Phonet.c },
54
+ { phonet: Phonet.d, accidental: Accidental.flat },
55
+ { phonet: Phonet.d },
56
+ { phonet: Phonet.e, accidental: Accidental.flat },
57
+ { phonet: Phonet.e },
58
+ { phonet: Phonet.f },
59
+ { phonet: Phonet.g, accidental: Accidental.flat },
60
+ { phonet: Phonet.g },
61
+ { phonet: Phonet.a, accidental: Accidental.flat },
62
+ { phonet: Phonet.a },
63
+ { phonet: Phonet.b, accidental: Accidental.flat },
64
+ { phonet: Phonet.b },
65
+ ];
66
+
67
+ const MAJOR_KEY_BY_FIFTHS: Record<number, Spelling> = {
68
+ [-6]: { phonet: Phonet.g, accidental: Accidental.flat },
69
+ [-5]: { phonet: Phonet.d, accidental: Accidental.flat },
70
+ [-4]: { phonet: Phonet.a, accidental: Accidental.flat },
71
+ [-3]: { phonet: Phonet.e, accidental: Accidental.flat },
72
+ [-2]: { phonet: Phonet.b, accidental: Accidental.flat },
73
+ [-1]: { phonet: Phonet.f },
74
+ [0]: { phonet: Phonet.c },
75
+ [1]: { phonet: Phonet.g },
76
+ [2]: { phonet: Phonet.d },
77
+ [3]: { phonet: Phonet.a },
78
+ [4]: { phonet: Phonet.e },
79
+ [5]: { phonet: Phonet.b },
80
+ [6]: { phonet: Phonet.f, accidental: Accidental.sharp },
81
+ };
82
+
83
+ const MINOR_KEY_BY_FIFTHS: Record<number, Spelling> = {
84
+ [-6]: { phonet: Phonet.e, accidental: Accidental.flat },
85
+ [-5]: { phonet: Phonet.b, accidental: Accidental.flat },
86
+ [-4]: { phonet: Phonet.f },
87
+ [-3]: { phonet: Phonet.c },
88
+ [-2]: { phonet: Phonet.g },
89
+ [-1]: { phonet: Phonet.d },
90
+ [0]: { phonet: Phonet.a },
91
+ [1]: { phonet: Phonet.e },
92
+ [2]: { phonet: Phonet.b },
93
+ [3]: { phonet: Phonet.f, accidental: Accidental.sharp },
94
+ [4]: { phonet: Phonet.c, accidental: Accidental.sharp },
95
+ [5]: { phonet: Phonet.g, accidental: Accidental.sharp },
96
+ [6]: { phonet: Phonet.d, accidental: Accidental.sharp },
97
+ };
98
+
99
+ const DIATONIC_INDEX: Record<Phonet, number> = {
100
+ [Phonet.c]: 0,
101
+ [Phonet.d]: 1,
102
+ [Phonet.e]: 2,
103
+ [Phonet.f]: 3,
104
+ [Phonet.g]: 4,
105
+ [Phonet.a]: 5,
106
+ [Phonet.b]: 6,
107
+ };
108
+
109
+ const NATURAL_MAJOR_FIFTHS: Record<Phonet, number> = {
110
+ [Phonet.c]: 0, [Phonet.d]: 2, [Phonet.e]: 4, [Phonet.f]: -1,
111
+ [Phonet.g]: 1, [Phonet.a]: 3, [Phonet.b]: 5,
112
+ };
113
+
114
+ const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11];
115
+ const MINOR_SCALE = [0, 2, 3, 5, 7, 8, 10];
116
+
117
+ const mod = (value: number, divisor: number): number => ((value % divisor) + divisor) % divisor;
118
+
119
+ const pitchClass = (pitch: Pitch): number =>
120
+ PHONET_SEMITONE[pitch.phonet] + (pitch.accidental ? ACCIDENTAL_SEMITONE[pitch.accidental] : 0);
121
+
122
+ const keyDegreePitchClass = (key: KeySignature, degree: number): number => {
123
+ const scale = key.mode === "minor" ? MINOR_SCALE : MAJOR_SCALE;
124
+ return mod(keyPitchClass(key) + scale[degree], 12);
125
+ };
126
+
127
+ const signedAlteration = (actual: number, expected: number): number => {
128
+ let alteration = mod(actual - expected + 6, 12) - 6;
129
+ if (alteration === -6) alteration = 6;
130
+ return alteration;
131
+ };
132
+
133
+ const accidentalFor = (alteration: number): Accidental | undefined => ({
134
+ [-2]: Accidental.doubleFlat,
135
+ [-1]: Accidental.flat,
136
+ [0]: undefined,
137
+ [1]: Accidental.sharp,
138
+ [2]: Accidental.doubleSharp,
139
+ } as Record<number, Accidental | undefined>)[alteration];
140
+
141
+ const transposePitchInKey = (pitch: Pitch, sourceKey: KeySignature, targetKey: KeySignature, semitones: number): Pitch | undefined => {
142
+ const sourceDegree = mod(DIATONIC_INDEX[pitch.phonet] - DIATONIC_INDEX[sourceKey.pitch], 7);
143
+ const alteration = signedAlteration(pitchClass(pitch), keyDegreePitchClass(sourceKey, sourceDegree));
144
+ const targetIndex = mod(DIATONIC_INDEX[targetKey.pitch] + sourceDegree, 7);
145
+ const targetPhonet = (Object.keys(DIATONIC_INDEX) as Phonet[]).find(phonet => DIATONIC_INDEX[phonet] === targetIndex)!;
146
+ const targetExpected = keyDegreePitchClass(targetKey, sourceDegree);
147
+ const writtenAccidental = signedAlteration(targetExpected + alteration, PHONET_SEMITONE[targetPhonet]);
148
+ const accidental = accidentalFor(writtenAccidental);
149
+ if (accidental === undefined && writtenAccidental !== 0) return undefined;
150
+ const targetAbsolute = pitch.octave * 12 + pitchClass(pitch) + semitones;
151
+ const rawTargetClass = PHONET_SEMITONE[targetPhonet] + writtenAccidental;
152
+ const octave = Math.floor((targetAbsolute - rawTargetClass) / 12);
153
+ return {
154
+ phonet: targetPhonet,
155
+ ...(accidental ? { accidental } : {}),
156
+ octave,
157
+ ...(pitch.courtesy !== undefined ? { courtesy: pitch.courtesy } : {}),
158
+ };
159
+ };
160
+
161
+ const spellingFor = (pitchClass: number, semitones: number): Spelling =>
162
+ (semitones < 0 ? FLAT_SPELLINGS : SHARP_SPELLINGS)[mod(pitchClass, 12)];
163
+
164
+ const keyFifths = (key: KeySignature): number => {
165
+ let fifths = NATURAL_MAJOR_FIFTHS[key.pitch];
166
+ if (key.accidental === Accidental.sharp) fifths += 7;
167
+ else if (key.accidental === Accidental.flat) fifths -= 7;
168
+ if (key.mode === "minor") fifths -= 3;
169
+ return fifths;
170
+ };
171
+
172
+ const spellingPitchClass = (spelling: Spelling): number =>
173
+ mod(PHONET_SEMITONE[spelling.phonet] + (spelling.accidental ? ACCIDENTAL_SEMITONE[spelling.accidental] : 0), 12);
174
+
175
+ const keyPitchClass = (key: KeySignature): number =>
176
+ spellingPitchClass({ phonet: key.pitch, accidental: key.accidental });
177
+
178
+ const normalizedKeyFor = (pitchClass: number, mode: KeySignature["mode"], semitones: number): KeySignature => {
179
+ const candidates = Object.entries(mode === "minor" ? MINOR_KEY_BY_FIFTHS : MAJOR_KEY_BY_FIFTHS)
180
+ .map(([fifths, spelling]) => ({ fifths: Number(fifths), spelling }))
181
+ .filter(candidate => spellingPitchClass(candidate.spelling) === mod(pitchClass, 12));
182
+ const preferred = candidates.find(candidate => semitones < 0 ? candidate.fifths < 0 : candidate.fifths >= 0)
183
+ ?? candidates[0];
184
+ if (!preferred) throw new Error(`No normalized ${mode} key for pitch class ${mod(pitchClass, 12)}`);
185
+ return {
186
+ pitch: preferred.spelling.phonet,
187
+ ...(preferred.spelling.accidental ? { accidental: preferred.spelling.accidental } : {}),
188
+ mode,
189
+ };
190
+ };
191
+
192
+ /** Transpose one resolved Lilylet pitch by an integer number of semitones. */
193
+ export const transposePitch = (pitch: Pitch, semitones: number): Pitch => {
194
+ if (semitones === 0) return { ...pitch };
195
+ const absolute = pitch.octave * 12 + pitchClass(pitch) + semitones;
196
+ const octave = Math.floor(absolute / 12);
197
+ const spelling = spellingFor(absolute, semitones);
198
+ return {
199
+ phonet: spelling.phonet,
200
+ ...(spelling.accidental ? { accidental: spelling.accidental } : {}),
201
+ octave,
202
+ ...(pitch.courtesy !== undefined ? { courtesy: pitch.courtesy } : {}),
203
+ };
204
+ };
205
+
206
+ const transposeKey = (key: KeySignature, semitones: number): KeySignature => {
207
+ const sourceClass = keyPitchClass(key);
208
+ const target = spellingFor(sourceClass + semitones, semitones);
209
+ const directional: KeySignature = {
210
+ pitch: target.phonet,
211
+ ...(target.accidental ? { accidental: target.accidental } : {}),
212
+ mode: key.mode,
213
+ };
214
+ return Math.abs(keyFifths(directional)) <= 6
215
+ ? directional
216
+ : normalizedKeyFor(sourceClass + semitones, key.mode, semitones);
217
+ };
218
+
219
+ const transposePitchWithKeys = (pitch: Pitch, sourceKey: KeySignature, targetKey: KeySignature, semitones: number): Pitch =>
220
+ transposePitchInKey(pitch, sourceKey, targetKey, semitones) ?? transposePitch(pitch, semitones);
221
+
222
+ interface KeyState { source: KeySignature; target: KeySignature }
223
+
224
+ const transposeEvent = (event: Event, semitones: number, state: KeyState): Event => {
225
+ switch (event.type) {
226
+ case "note":
227
+ return { ...event, pitches: event.pitches.map(pitch => transposePitchWithKeys(pitch, state.source, state.target, semitones)) };
228
+ case "rest":
229
+ return { ...event, ...(event.pitch ? { pitch: transposePitchWithKeys(event.pitch, state.source, state.target, semitones) } : {}) };
230
+ case "context": {
231
+ const context = event as ContextChange;
232
+ if (context.key) {
233
+ state.source = context.key;
234
+ state.target = transposeKey(context.key, semitones);
235
+ }
236
+ return { ...context, ...(context.key ? { key: state.target } : {}) };
237
+ }
238
+ case "tremolo":
239
+ return { ...event,
240
+ pitchA: event.pitchA.map(p => transposePitchWithKeys(p, state.source, state.target, semitones)),
241
+ pitchB: event.pitchB.map(p => transposePitchWithKeys(p, state.source, state.target, semitones)) };
242
+ case "tuplet":
243
+ case "times":
244
+ return { ...event, events: event.events.map(inner => transposeEvent(inner as Event, semitones, state) as typeof inner) };
245
+ default:
246
+ return { ...event } as Event;
247
+ }
248
+ };
249
+
250
+ const transposeVoice = (voice: Voice, semitones: number, measureKey: KeySignature): Voice => {
251
+ const state: KeyState = { source: measureKey, target: transposeKey(measureKey, semitones) };
252
+ return { ...voice, events: voice.events.map(event => transposeEvent(event, semitones, state)) };
253
+ };
254
+
255
+ const transposePart = (part: Part, semitones: number, measureKey: KeySignature): Part => ({
256
+ ...part,
257
+ voices: part.voices.map(voice => transposeVoice(voice, semitones, measureKey)),
258
+ });
259
+
260
+ const transposeMeasure = (measure: Measure, semitones: number, inheritedKey: KeySignature): Measure => {
261
+ const sourceKey = measure.key ?? inheritedKey;
262
+ const targetKey = transposeKey(sourceKey, semitones);
263
+ return {
264
+ ...measure,
265
+ ...(measure.key ? { key: targetKey } : {}),
266
+ parts: measure.parts.map(part => transposePart(part, semitones, sourceKey)),
267
+ };
268
+ };
269
+
270
+
271
+ /** Return a new Lilylet document with every AST pitch shifted chromatically. */
272
+ export const transposeDoc = (doc: LilyletDoc, semitones: number): LilyletDoc => {
273
+ if (!Number.isFinite(semitones) || !Number.isInteger(semitones)) {
274
+ throw new Error("transposeDoc semitones must be a finite integer");
275
+ }
276
+ return {
277
+ ...doc,
278
+ measures: doc.measures.map(measure => transposeMeasure(measure, semitones, { pitch: Phonet.c, mode: "major" })),
279
+ };
280
+ };