@k-l-lambda/lilylet 0.1.86 → 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.
- 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 +87 -0
- package/lib/lilylet/clefTransposition.js +133 -0
- package/lib/lilylet/index.d.ts +2 -0
- package/lib/lilylet/index.js +2 -0
- package/lib/lilylet/meiEncoder.js +11 -19
- package/lib/lilylet/onsets.d.ts +5 -4
- package/lib/lilylet/onsets.js +7 -14
- package/lib/lilylet/transposition.d.ts +5 -0
- package/lib/lilylet/transposition.js +238 -0
- package/lib/transposition.d.ts +1 -0
- package/lib/transposition.js +1 -0
- 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 +149 -0
- package/source/lilylet/index.ts +2 -0
- package/source/lilylet/meiEncoder.ts +13 -18
- package/source/lilylet/onsets.ts +7 -14
- package/source/lilylet/transposition.ts +280 -0
|
@@ -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.
|
|
3
|
+
"version": "0.1.89",
|
|
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",
|
|
@@ -55,7 +55,10 @@
|
|
|
55
55
|
"test:abc-instrument": "tsx ./tests/unit/abcInstrumentName.test.ts",
|
|
56
56
|
"test:abc-partial": "tsx ./tests/unit/abcPartialPickup.test.ts",
|
|
57
57
|
"test:clef-onset": "tsx ./tests/unit/clefOnsetTranspose.test.ts",
|
|
58
|
+
"test:abc-transpose": "tsx ./tests/unit/abcTransposingClef.test.ts",
|
|
59
|
+
"test:clef-transposition": "tsx ./tests/unit/clefTransposition.test.ts",
|
|
58
60
|
"test:fine-rhythm-onset": "tsx ./tests/unit/fineRhythmOnset.test.ts",
|
|
61
|
+
"test:transpose": "tsx ./tests/unit/astTranspose.test.ts",
|
|
59
62
|
"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
63
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
61
64
|
"ts": "tsx"
|
package/source/abc/abc.jison
CHANGED
|
@@ -213,6 +213,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
213
213
|
|
|
214
214
|
^[T][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'T:'; } this.unput(yytext.slice(1)); yytext = 'T'; return 'T'; }
|
|
215
215
|
^[C][:][\s]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); return 'C:'; } this.unput(yytext.slice(1)); yytext = 'C'; return 'A'; }
|
|
216
|
+
^[ABDFGHINORSWZ][:](?![|])[ \t]* { var pre = this.matched.slice(0, this.matched.length - yytext.length); if (pre === '' || pre.slice(-1) === '\n') { this.pushState('title_string'); yytext = yytext[0]; return 'TEXT_FIELD'; } this.unput(yytext.slice(1)); yytext = yytext[0]; return 'H'; }
|
|
216
217
|
<title_string>\n { this.popState(); }
|
|
217
218
|
<title_string>[^\n]+ return 'STR_CONTENT'
|
|
218
219
|
|
|
@@ -379,6 +380,7 @@ head_line
|
|
|
379
380
|
| 'K:' key_signature -> header('K', $2)
|
|
380
381
|
| 'V:' header_value -> header('V', $2)
|
|
381
382
|
| H ':' header_value -> header($1, $3)
|
|
383
|
+
| TEXT_FIELD string_content -> header($1, $2)
|
|
382
384
|
;
|
|
383
385
|
|
|
384
386
|
header_value
|