tonus 0.1.0
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/BIBLIOGRAPHY.md +99 -0
- package/LICENSE +29 -0
- package/README.md +108 -0
- package/dist/data/cal.d.ts +10 -0
- package/dist/data/cal.js +3862 -0
- package/dist/data/commune.d.ts +17 -0
- package/dist/data/commune.js +1333 -0
- package/dist/data/gr.d.ts +5 -0
- package/dist/data/gr.js +13449 -0
- package/dist/data/kyriale.d.ts +11 -0
- package/dist/data/kyriale.js +971 -0
- package/dist/data/la.d.ts +5 -0
- package/dist/data/la.js +14229 -0
- package/dist/data/lh.d.ts +5 -0
- package/dist/data/lh.js +3619 -0
- package/dist/data/lu.d.ts +5 -0
- package/dist/data/lu.js +23779 -0
- package/dist/data/masses.d.ts +18 -0
- package/dist/data/masses.js +297 -0
- package/dist/data/office-roman.d.ts +19 -0
- package/dist/data/office-roman.js +13792 -0
- package/dist/data/office.d.ts +12 -0
- package/dist/data/office.js +13052 -0
- package/dist/data/propers.d.ts +13 -0
- package/dist/data/propers.js +7584 -0
- package/dist/data/psalms.d.ts +4 -0
- package/dist/data/psalms.js +10 -0
- package/dist/data/psalms.json +22918 -0
- package/dist/data/tones.d.ts +20 -0
- package/dist/data/tones.js +153 -0
- package/dist/data/types.d.ts +3 -0
- package/dist/data/types.js +2 -0
- package/dist/engines/cal/calendar.d.ts +21 -0
- package/dist/engines/cal/calendar.js +265 -0
- package/dist/engines/cal/date.d.ts +31 -0
- package/dist/engines/cal/date.js +141 -0
- package/dist/engines/cal/types.d.ts +66 -0
- package/dist/engines/cal/types.js +189 -0
- package/dist/engines/chant/chant.d.ts +10 -0
- package/dist/engines/chant/chant.js +135 -0
- package/dist/engines/chant/hour.d.ts +8 -0
- package/dist/engines/chant/hour.js +135 -0
- package/dist/engines/chant/intone.d.ts +8 -0
- package/dist/engines/chant/intone.js +84 -0
- package/dist/engines/chant/ordinary.d.ts +7 -0
- package/dist/engines/chant/ordinary.js +232 -0
- package/dist/engines/chant/propers.d.ts +8 -0
- package/dist/engines/chant/propers.js +107 -0
- package/dist/engines/chant/psalm.d.ts +7 -0
- package/dist/engines/chant/psalm.js +60 -0
- package/dist/engines/chant/syllabify.d.ts +20 -0
- package/dist/engines/chant/syllabify.js +192 -0
- package/dist/engines/chant/types.d.ts +76 -0
- package/dist/engines/chant/types.js +34 -0
- package/dist/engines/epoch.d.ts +2 -0
- package/dist/engines/epoch.js +14 -0
- package/dist/engines/harmonia/api.d.ts +35 -0
- package/dist/engines/harmonia/api.js +90 -0
- package/dist/engines/harmonia/aspects.d.ts +8 -0
- package/dist/engines/harmonia/aspects.js +15 -0
- package/dist/engines/harmonia/data/doctrines.d.ts +16 -0
- package/dist/engines/harmonia/data/doctrines.js +154 -0
- package/dist/engines/harmonia/data/vowels.d.ts +10 -0
- package/dist/engines/harmonia/data/vowels.js +21 -0
- package/dist/engines/harmonia/presence.d.ts +13 -0
- package/dist/engines/harmonia/presence.js +48 -0
- package/dist/engines/harmonia/tabula.d.ts +28 -0
- package/dist/engines/harmonia/tabula.js +32 -0
- package/dist/engines/harmonia/voice.d.ts +19 -0
- package/dist/engines/harmonia/voice.js +51 -0
- package/dist/engines/imprint.d.ts +30 -0
- package/dist/engines/imprint.js +152 -0
- package/dist/engines/planet/appearance.d.ts +40 -0
- package/dist/engines/planet/appearance.js +84 -0
- package/dist/engines/planet/aspects.d.ts +5 -0
- package/dist/engines/planet/aspects.js +41 -0
- package/dist/engines/planet/math.d.ts +13 -0
- package/dist/engines/planet/math.js +56 -0
- package/dist/engines/planet/orbital.d.ts +25 -0
- package/dist/engines/planet/orbital.js +223 -0
- package/dist/engines/planet/planet.d.ts +13 -0
- package/dist/engines/planet/planet.js +198 -0
- package/dist/engines/planet/position.d.ts +62 -0
- package/dist/engines/planet/position.js +156 -0
- package/dist/engines/planet/types.d.ts +61 -0
- package/dist/engines/planet/types.js +14 -0
- package/dist/engines/score/api.d.ts +54 -0
- package/dist/engines/score/api.js +87 -0
- package/dist/engines/score/articulation.d.ts +6 -0
- package/dist/engines/score/articulation.js +112 -0
- package/dist/engines/score/emitters/midi.d.ts +65 -0
- package/dist/engines/score/emitters/midi.js +158 -0
- package/dist/engines/score/emitters/musicxml.d.ts +18 -0
- package/dist/engines/score/emitters/musicxml.js +166 -0
- package/dist/engines/score/infer.d.ts +4 -0
- package/dist/engines/score/infer.js +77 -0
- package/dist/engines/score/ir.d.ts +4 -0
- package/dist/engines/score/ir.js +177 -0
- package/dist/engines/score/meta.d.ts +19 -0
- package/dist/engines/score/meta.js +34 -0
- package/dist/engines/score/neume.d.ts +3 -0
- package/dist/engines/score/neume.js +26 -0
- package/dist/engines/score/parse.d.ts +3 -0
- package/dist/engines/score/parse.js +359 -0
- package/dist/engines/score/phrasing.d.ts +24 -0
- package/dist/engines/score/phrasing.js +257 -0
- package/dist/engines/score/prosody.d.ts +35 -0
- package/dist/engines/score/prosody.js +109 -0
- package/dist/engines/score/tabula.d.ts +70 -0
- package/dist/engines/score/tabula.js +109 -0
- package/dist/engines/score/types.d.ts +159 -0
- package/dist/engines/score/types.js +2 -0
- package/dist/engines/temper/api.d.ts +60 -0
- package/dist/engines/temper/api.js +130 -0
- package/dist/engines/temper/data/constants.d.ts +27 -0
- package/dist/engines/temper/data/constants.js +150 -0
- package/dist/engines/temper/data/guido.d.ts +14 -0
- package/dist/engines/temper/data/guido.js +29 -0
- package/dist/engines/temper/data/modes.d.ts +38 -0
- package/dist/engines/temper/data/modes.js +158 -0
- package/dist/engines/temper/gabc.d.ts +5 -0
- package/dist/engines/temper/gabc.js +53 -0
- package/dist/engines/temper/gamut.d.ts +9 -0
- package/dist/engines/temper/gamut.js +24 -0
- package/dist/engines/temper/guido.d.ts +16 -0
- package/dist/engines/temper/guido.js +48 -0
- package/dist/engines/temper/interval.d.ts +15 -0
- package/dist/engines/temper/interval.js +31 -0
- package/dist/engines/temper/modes.d.ts +6 -0
- package/dist/engines/temper/modes.js +13 -0
- package/dist/engines/temper/neume.d.ts +14 -0
- package/dist/engines/temper/neume.js +59 -0
- package/dist/engines/temper/pitch.d.ts +40 -0
- package/dist/engines/temper/pitch.js +129 -0
- package/dist/engines/temper/scale.d.ts +37 -0
- package/dist/engines/temper/scale.js +217 -0
- package/dist/engines/temper/step.d.ts +23 -0
- package/dist/engines/temper/step.js +53 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +27 -0
- package/package.json +60 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { buildArticulation } from "./articulation.js";
|
|
2
|
+
import { detectVowelAccent } from "../chant/syllabify.js";
|
|
3
|
+
// Constants
|
|
4
|
+
const DEFAULT_OPTIONS = {
|
|
5
|
+
oct: 3,
|
|
6
|
+
useVowelAccent: true,
|
|
7
|
+
};
|
|
8
|
+
const CLEF_OFFSETS = new Map([
|
|
9
|
+
["c1", -3],
|
|
10
|
+
["c2", -1],
|
|
11
|
+
["c3", 1],
|
|
12
|
+
["c4", 3],
|
|
13
|
+
["f1", 1],
|
|
14
|
+
["f2", 3],
|
|
15
|
+
["f3", 5],
|
|
16
|
+
["f4", 7],
|
|
17
|
+
["cb1", -3],
|
|
18
|
+
["cb2", -1],
|
|
19
|
+
["cb3", 1],
|
|
20
|
+
["cb4", 3],
|
|
21
|
+
["fb1", 1],
|
|
22
|
+
["fb2", 3],
|
|
23
|
+
["fb3", 5],
|
|
24
|
+
["fb4", 7],
|
|
25
|
+
]);
|
|
26
|
+
const DIVISIO_DURATIONS = new Map([
|
|
27
|
+
[",", 0.54],
|
|
28
|
+
["`", 0.33],
|
|
29
|
+
[";", 0.8],
|
|
30
|
+
[":", 1.1],
|
|
31
|
+
["::", 1.8],
|
|
32
|
+
]);
|
|
33
|
+
const STAFF_STEPS = [0, 2, 4, 5, 7, 9, 11];
|
|
34
|
+
const SYLLABLES_REGEX = /(?=.)((?:[^(])*)(?:\(?([^)]*)\)?)?/g;
|
|
35
|
+
const NOTATIONS_REGEX = /z0|z|Z|::|:|[,;][1-6]?|`|[cf][1-4]|cb[1-4]|fb[1-4]|\/+| |\!|-?[a-mA-M][oOwWvVrRsxy#~\+><_\.'012345]*(?:\[[^\]]*\]?)*|\{([^}]+)\}?/g;
|
|
36
|
+
// Helpers
|
|
37
|
+
function initialAccidentalState(clef) {
|
|
38
|
+
const state = new Map();
|
|
39
|
+
if (clef.includes("b"))
|
|
40
|
+
state.set(6, -1); // B-flat key signature
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
function isSkippable(token) {
|
|
44
|
+
return (token === " " ||
|
|
45
|
+
token === "z" ||
|
|
46
|
+
token === "Z" ||
|
|
47
|
+
token === "z0" ||
|
|
48
|
+
token === "\r" ||
|
|
49
|
+
token.startsWith("{") ||
|
|
50
|
+
token.includes("+"));
|
|
51
|
+
}
|
|
52
|
+
// parseNeume
|
|
53
|
+
function parseNeume(notation, context) {
|
|
54
|
+
const { lyric, clef, oct, syllableIndex, accent, accidentalState, profile } = context;
|
|
55
|
+
const weights = profile.weights;
|
|
56
|
+
const ruleGain = profile.ruleGain ?? 1.0;
|
|
57
|
+
const contourScale = profile.contourScale ?? 0.2;
|
|
58
|
+
const neumeArch = profile.neumeArch ?? 0.5;
|
|
59
|
+
const durArch = profile.durArch ?? 0.08;
|
|
60
|
+
const ictusBoost = profile.ictusBoost ?? 1.08;
|
|
61
|
+
// bmolle: set from clef key sig, updated by x/y modifiers within tokens
|
|
62
|
+
let bmolle = clef.includes("b");
|
|
63
|
+
// Count note tokens for initio/melisma detection
|
|
64
|
+
const noteTokenCount = notation.filter((t) => /^-?[a-mA-M]/.test(t ?? "")).length;
|
|
65
|
+
const breaks = [];
|
|
66
|
+
const intermed = [];
|
|
67
|
+
// neumeGroup: 0-based index of the neume figure within this syllable. GABC
|
|
68
|
+
// glyph separators (!, /, //) start a new figure; MusicXML slurs each figure.
|
|
69
|
+
let neumeGroup = 0;
|
|
70
|
+
notation.forEach((rawToken, i) => {
|
|
71
|
+
if (!rawToken || rawToken.length < 1)
|
|
72
|
+
return;
|
|
73
|
+
// Break markers — end the current neume figure, begin the next.
|
|
74
|
+
if (rawToken === "!" || rawToken === "/" || rawToken === "//") {
|
|
75
|
+
breaks.push(i);
|
|
76
|
+
neumeGroup++;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let token = rawToken;
|
|
80
|
+
let w = 0;
|
|
81
|
+
let durWeight = 0;
|
|
82
|
+
let ictus = false;
|
|
83
|
+
let isQuilisma = false;
|
|
84
|
+
let isLiquescent = false;
|
|
85
|
+
let isStrophicus = false;
|
|
86
|
+
let isDoubleEpisema = false;
|
|
87
|
+
// Dash prefix (weak note)
|
|
88
|
+
if (token[0] === "-") {
|
|
89
|
+
token = token.slice(1);
|
|
90
|
+
w += weights.dashWeight; // negative weight
|
|
91
|
+
durWeight += weights.dashDuration;
|
|
92
|
+
}
|
|
93
|
+
const letter = token[0]?.toLowerCase();
|
|
94
|
+
if (!letter)
|
|
95
|
+
return;
|
|
96
|
+
const pitchOffset = letter.charCodeAt(0) - "a".charCodeAt(0);
|
|
97
|
+
if (pitchOffset < 0 || pitchOffset > 12)
|
|
98
|
+
return;
|
|
99
|
+
const clefOffset = CLEF_OFFSETS.get(clef) ?? 0;
|
|
100
|
+
const pos = pitchOffset - 6 - clefOffset;
|
|
101
|
+
const octave = Math.floor(pos / 7) + oct + 1;
|
|
102
|
+
const degree = ((pos % 7) + 7) % 7;
|
|
103
|
+
const baseStep = STAFF_STEPS[degree];
|
|
104
|
+
// bmolle / accidental modifier extraction
|
|
105
|
+
// Modifiers appear after the pitch letter in the token string
|
|
106
|
+
const modifiers = token.slice(1);
|
|
107
|
+
let explicitAccidental = null;
|
|
108
|
+
if (modifiers.includes("x")) {
|
|
109
|
+
bmolle = true;
|
|
110
|
+
explicitAccidental = -1;
|
|
111
|
+
accidentalState.set(degree, -1);
|
|
112
|
+
}
|
|
113
|
+
else if (modifiers.includes("y")) {
|
|
114
|
+
bmolle = false;
|
|
115
|
+
explicitAccidental = 0;
|
|
116
|
+
accidentalState.set(degree, 0);
|
|
117
|
+
}
|
|
118
|
+
else if (modifiers.includes("#")) {
|
|
119
|
+
explicitAccidental = 1;
|
|
120
|
+
accidentalState.set(degree, 1);
|
|
121
|
+
}
|
|
122
|
+
// Apply bmolle to B (degree 6 = B natural step 11 → B-flat step 10)
|
|
123
|
+
let step = baseStep;
|
|
124
|
+
if (bmolle && degree === 6)
|
|
125
|
+
step -= 1;
|
|
126
|
+
// Apply chromatic accidentals from state (on top of bmolle)
|
|
127
|
+
const stateAccidental = accidentalState.get(degree) ?? 0;
|
|
128
|
+
const activeAccidental = explicitAccidental ?? stateAccidental;
|
|
129
|
+
// If bmolle already lowered B, don't double-apply flat from state
|
|
130
|
+
if (!(bmolle && degree === 6 && activeAccidental === -1)) {
|
|
131
|
+
step += activeAccidental;
|
|
132
|
+
}
|
|
133
|
+
step += octave * 12;
|
|
134
|
+
const accidentalSource = explicitAccidental !== null
|
|
135
|
+
? "explicit"
|
|
136
|
+
: accidentalState.has(degree)
|
|
137
|
+
? "state"
|
|
138
|
+
: "none";
|
|
139
|
+
// Ictus markers (' and _)
|
|
140
|
+
if (modifiers.includes("'") || modifiers.includes("_")) {
|
|
141
|
+
w += weights.ictusWeight;
|
|
142
|
+
durWeight += weights.ictusDuration;
|
|
143
|
+
ictus = true;
|
|
144
|
+
}
|
|
145
|
+
// Episema (horizontal lengthening '.')
|
|
146
|
+
if (modifiers.includes(".")) {
|
|
147
|
+
w += weights.episemaWeight;
|
|
148
|
+
durWeight += weights.episemaDuration;
|
|
149
|
+
ictus = true;
|
|
150
|
+
// Double episema '..' — boost first note of neume
|
|
151
|
+
if (modifiers.includes("..")) {
|
|
152
|
+
isDoubleEpisema = true;
|
|
153
|
+
if (intermed[0])
|
|
154
|
+
intermed[0]._durWeight += weights.episemaDoubleDuration;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Strophicus (ss or vv = repeated/tremolo notes)
|
|
158
|
+
if (modifiers.includes("ss") || modifiers.includes("vv")) {
|
|
159
|
+
w += weights.strophicusWeight;
|
|
160
|
+
durWeight += weights.strophicusDuration;
|
|
161
|
+
ictus = true;
|
|
162
|
+
isStrophicus = true;
|
|
163
|
+
if (modifiers.includes("sss") || modifiers.includes("vvv")) {
|
|
164
|
+
durWeight += weights.strophicusTripleDuration;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Quilisma (w = wavy ornamental note; boosts preceding note)
|
|
168
|
+
if (modifiers.toLowerCase().includes("w")) {
|
|
169
|
+
const prev = intermed.length > 0 ? intermed[intermed.length - 1] : null;
|
|
170
|
+
if (prev)
|
|
171
|
+
prev._weight += weights.quilismaPrevWeight;
|
|
172
|
+
w += weights.quilismaWeight; // reduces this note's weight
|
|
173
|
+
isQuilisma = true;
|
|
174
|
+
}
|
|
175
|
+
// First note of neume (initio debilis treatment)
|
|
176
|
+
if (i === 0 || (accent && i === 2)) {
|
|
177
|
+
w += weights.initioWeight;
|
|
178
|
+
if (noteTokenCount > 1) {
|
|
179
|
+
ictus = true;
|
|
180
|
+
w += weights.initioMelismaWeight;
|
|
181
|
+
durWeight += weights.initioMelismaDuration;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Accented syllable
|
|
185
|
+
if (accent) {
|
|
186
|
+
w += weights.accentWeight;
|
|
187
|
+
if (i === 0 || i === 2)
|
|
188
|
+
ictus = true;
|
|
189
|
+
}
|
|
190
|
+
// Liquescent (softened ending: ~, <, >)
|
|
191
|
+
if (modifiers.includes("~") ||
|
|
192
|
+
modifiers.includes("<") ||
|
|
193
|
+
modifiers.includes(">")) {
|
|
194
|
+
w += weights.liquescentWeight;
|
|
195
|
+
durWeight += weights.liquescentDuration;
|
|
196
|
+
isLiquescent = true;
|
|
197
|
+
}
|
|
198
|
+
// Upper case = light/weak note
|
|
199
|
+
if (token[0] === token[0]?.toUpperCase()) {
|
|
200
|
+
w += weights.uppercaseWeight;
|
|
201
|
+
durWeight += weights.uppercaseDuration;
|
|
202
|
+
}
|
|
203
|
+
// Repercussion (same pitch as previous note)
|
|
204
|
+
const prev = intermed.length > 0 ? intermed[intermed.length - 1] : null;
|
|
205
|
+
if (prev && step === prev.step) {
|
|
206
|
+
prev._weight += weights.repercussionPrevWeight;
|
|
207
|
+
prev._durWeight += weights.repercussionPrevDuration;
|
|
208
|
+
if (modifiers.includes("o")) {
|
|
209
|
+
prev._weight += weights.repercussionOriscusWeight;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
intermed.push({
|
|
213
|
+
step,
|
|
214
|
+
degree,
|
|
215
|
+
lyric,
|
|
216
|
+
syllableIndex,
|
|
217
|
+
neumeGroup,
|
|
218
|
+
ictus,
|
|
219
|
+
accidental: activeAccidental,
|
|
220
|
+
accidentalSource,
|
|
221
|
+
quilisma: isQuilisma,
|
|
222
|
+
liquescent: isLiquescent,
|
|
223
|
+
strophicus: isStrophicus,
|
|
224
|
+
doubleEpisema: isDoubleEpisema,
|
|
225
|
+
_weight: w,
|
|
226
|
+
_durWeight: durWeight,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
// Apply break markers to corresponding notes
|
|
230
|
+
breaks.forEach((b, bi) => {
|
|
231
|
+
const idx = b - bi;
|
|
232
|
+
const target = intermed[idx];
|
|
233
|
+
if (target) {
|
|
234
|
+
target._weight += weights.breakWeight;
|
|
235
|
+
target.ictus = true;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
// Finalization pass: arch + melodic contour + tanh saturation
|
|
239
|
+
const result = intermed.map((note, idx) => {
|
|
240
|
+
const t = (idx + 0.5) / (intermed.length + 0.0001);
|
|
241
|
+
const arch = Math.sin(Math.PI * t);
|
|
242
|
+
// Melodic contour: peak/valley emphasis
|
|
243
|
+
const prev = intermed[idx - 1];
|
|
244
|
+
const next = intermed[idx + 1];
|
|
245
|
+
let contour = 0;
|
|
246
|
+
if (prev && next) {
|
|
247
|
+
if (note.step > prev.step && note.step > next.step) {
|
|
248
|
+
contour = 0.4 * (contourScale / 0.2); // melodic peak
|
|
249
|
+
}
|
|
250
|
+
else if (note.step < prev.step && note.step < next.step) {
|
|
251
|
+
contour = -0.3 * (contourScale / 0.2); // melodic valley
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const weight = profile.weightBase +
|
|
255
|
+
profile.weightGain *
|
|
256
|
+
ruleGain *
|
|
257
|
+
Math.tanh((note._weight + contour) / profile.weightSaturation) +
|
|
258
|
+
arch * neumeArch;
|
|
259
|
+
let duration = profile.durationBase * (1 + profile.durationGain * Math.tanh(note._durWeight));
|
|
260
|
+
duration *= 1 + arch * durArch;
|
|
261
|
+
if (note.ictus)
|
|
262
|
+
duration *= ictusBoost;
|
|
263
|
+
duration = Math.max(profile.durationMin, Math.min(profile.durationMax, duration));
|
|
264
|
+
return {
|
|
265
|
+
type: "note",
|
|
266
|
+
step: note.step,
|
|
267
|
+
lyric: note.lyric,
|
|
268
|
+
syllableIndex: note.syllableIndex,
|
|
269
|
+
neumeGroup: note.neumeGroup,
|
|
270
|
+
ictus: note.ictus,
|
|
271
|
+
weight,
|
|
272
|
+
duration,
|
|
273
|
+
accidental: note.accidental,
|
|
274
|
+
accidentalSource: note.accidentalSource,
|
|
275
|
+
quilisma: note.quilisma,
|
|
276
|
+
liquescent: note.liquescent,
|
|
277
|
+
strophicus: note.strophicus,
|
|
278
|
+
doubleEpisema: note.doubleEpisema,
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
// parseGABC — main entry point
|
|
284
|
+
export function parseGABC(gabc, options = {}) {
|
|
285
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
286
|
+
const interpretation = options.interpretation ?? {};
|
|
287
|
+
const articulation = buildArticulation(interpretation.articulation ?? "balanced", { overrides: interpretation.articulationOverrides });
|
|
288
|
+
const errors = [];
|
|
289
|
+
const events = [];
|
|
290
|
+
const source = (gabc || "").trim();
|
|
291
|
+
if (!source) {
|
|
292
|
+
return {
|
|
293
|
+
events,
|
|
294
|
+
errors: [{ message: "Empty GABC input" }],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
let currentClef = "c3";
|
|
298
|
+
let accidentalState = initialAccidentalState(currentClef);
|
|
299
|
+
const split = source.replace(/\)\s(?=[^\)]*(?:\(|$))/g, ")\n").split(/\n/g);
|
|
300
|
+
split.forEach((word) => {
|
|
301
|
+
if (!word)
|
|
302
|
+
return;
|
|
303
|
+
SYLLABLES_REGEX.lastIndex = 0;
|
|
304
|
+
let match;
|
|
305
|
+
let syllableIndex = 0;
|
|
306
|
+
while ((match = SYLLABLES_REGEX.exec(word)) !== null) {
|
|
307
|
+
const text = (match[1] ? match[1].trim().split("|")[0] : "") || "";
|
|
308
|
+
const notation = match[2] ? match[2].match(NOTATIONS_REGEX) : null;
|
|
309
|
+
if (!notation || notation.length === 0) {
|
|
310
|
+
syllableIndex += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
// Single-pass: classify tokens, collect note tokens in order
|
|
314
|
+
const noteTokens = [];
|
|
315
|
+
let divisioToken = null;
|
|
316
|
+
for (const token of notation) {
|
|
317
|
+
if (CLEF_OFFSETS.has(token)) {
|
|
318
|
+
currentClef = token;
|
|
319
|
+
accidentalState = initialAccidentalState(currentClef);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (DIVISIO_DURATIONS.has(token)) {
|
|
323
|
+
if (!divisioToken)
|
|
324
|
+
divisioToken = token;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (isSkippable(token))
|
|
328
|
+
continue;
|
|
329
|
+
noteTokens.push(token);
|
|
330
|
+
}
|
|
331
|
+
if (noteTokens.length > 0) {
|
|
332
|
+
const accent = opts.useVowelAccent ? detectVowelAccent(text) : false;
|
|
333
|
+
events.push(...parseNeume(noteTokens, {
|
|
334
|
+
lyric: text,
|
|
335
|
+
clef: currentClef,
|
|
336
|
+
oct: opts.oct,
|
|
337
|
+
syllableIndex,
|
|
338
|
+
accent,
|
|
339
|
+
accidentalState,
|
|
340
|
+
profile: articulation,
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
if (divisioToken) {
|
|
344
|
+
events.push({
|
|
345
|
+
type: "rest",
|
|
346
|
+
divisio: divisioToken,
|
|
347
|
+
duration: DIVISIO_DURATIONS.get(divisioToken) ?? 0.5,
|
|
348
|
+
});
|
|
349
|
+
accidentalState = initialAccidentalState(currentClef);
|
|
350
|
+
}
|
|
351
|
+
syllableIndex += 1;
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
if (events.length === 0) {
|
|
355
|
+
errors.push({ message: "No parseable notation found" });
|
|
356
|
+
}
|
|
357
|
+
return { events, errors };
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=parse.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Note, PhrasingProfile, PhrasingType } from "./types.js";
|
|
2
|
+
import type { ModeData } from "../temper/modes.js";
|
|
3
|
+
export type { PhrasingProfile } from "./types.js";
|
|
4
|
+
export interface ShapedNote extends Note {
|
|
5
|
+
shapedDuration: number;
|
|
6
|
+
}
|
|
7
|
+
export interface BuildPhrasingOptions {
|
|
8
|
+
overrides?: Partial<PhrasingProfile>;
|
|
9
|
+
}
|
|
10
|
+
export interface ShapePhrasingForModeOptions {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
strength?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildPhrasing(type?: PhrasingType, options?: BuildPhrasingOptions): PhrasingProfile;
|
|
15
|
+
export declare function shapePhrasingForMode(profile: PhrasingProfile, modeData?: ModeData, options?: ShapePhrasingForModeOptions): PhrasingProfile;
|
|
16
|
+
export type PhrasingInputEvent = (Note & {
|
|
17
|
+
type?: "note";
|
|
18
|
+
}) | {
|
|
19
|
+
type: "rest";
|
|
20
|
+
divisio: string;
|
|
21
|
+
duration: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function applyPhrasing(events: PhrasingInputEvent[], profile: PhrasingProfile, tenorPc?: number): ShapedNote[];
|
|
24
|
+
//# sourceMappingURL=phrasing.d.ts.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// engines/score/phrasing — performance-shaping profiles
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
const PHRASING_PROFILES = {
|
|
5
|
+
recitative: {
|
|
6
|
+
curve: 0.1,
|
|
7
|
+
accent: 0.5,
|
|
8
|
+
cadence: 0.1,
|
|
9
|
+
tenor: 2.3,
|
|
10
|
+
baseVelocity: 0.65,
|
|
11
|
+
contourVel: 0.08,
|
|
12
|
+
contourDur: 0.05,
|
|
13
|
+
velSpread: 0.35,
|
|
14
|
+
ictusBoost: 1.06,
|
|
15
|
+
neumeArch: 0.2,
|
|
16
|
+
durArch: 0.04,
|
|
17
|
+
},
|
|
18
|
+
lyrical: {
|
|
19
|
+
curve: 0.35,
|
|
20
|
+
accent: 0.8,
|
|
21
|
+
cadence: 0.15,
|
|
22
|
+
tenor: 1.2,
|
|
23
|
+
baseVelocity: 0.68,
|
|
24
|
+
contourVel: 0.18,
|
|
25
|
+
contourDur: 0.11,
|
|
26
|
+
velSpread: 0.5,
|
|
27
|
+
ictusBoost: 1.08,
|
|
28
|
+
neumeArch: 0.5,
|
|
29
|
+
durArch: 0.08,
|
|
30
|
+
},
|
|
31
|
+
hymnic: {
|
|
32
|
+
curve: 0.25,
|
|
33
|
+
accent: 0.6,
|
|
34
|
+
cadence: 0.12,
|
|
35
|
+
tenor: 0.9,
|
|
36
|
+
baseVelocity: 0.69,
|
|
37
|
+
contourVel: 0.14,
|
|
38
|
+
contourDur: 0.08,
|
|
39
|
+
velSpread: 0.45,
|
|
40
|
+
ictusBoost: 1.08,
|
|
41
|
+
neumeArch: 0.38,
|
|
42
|
+
durArch: 0.06,
|
|
43
|
+
},
|
|
44
|
+
solemn: {
|
|
45
|
+
curve: 0.5,
|
|
46
|
+
accent: 1,
|
|
47
|
+
cadence: 0.2,
|
|
48
|
+
tenor: 1.7,
|
|
49
|
+
baseVelocity: 0.71,
|
|
50
|
+
contourVel: 0.22,
|
|
51
|
+
contourDur: 0.13,
|
|
52
|
+
velSpread: 0.55,
|
|
53
|
+
ictusBoost: 1.12,
|
|
54
|
+
neumeArch: 0.62,
|
|
55
|
+
durArch: 0.1,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
export function buildPhrasing(type = "lyrical", options = {}) {
|
|
59
|
+
const base = PHRASING_PROFILES[type] ?? PHRASING_PROFILES.lyrical;
|
|
60
|
+
return {
|
|
61
|
+
...base,
|
|
62
|
+
...(options.overrides ?? {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const MODE_TYPE_TENOR_MULTIPLIER = {
|
|
66
|
+
authentic: 1.08,
|
|
67
|
+
plagal: 0.92,
|
|
68
|
+
};
|
|
69
|
+
const MODE_TYPE_CADENCE_MULTIPLIER = {
|
|
70
|
+
authentic: 1.05,
|
|
71
|
+
plagal: 0.95,
|
|
72
|
+
};
|
|
73
|
+
const MODE_TENDENCY_CADENCE_MULTIPLIER = {
|
|
74
|
+
melismatic: 1.06,
|
|
75
|
+
neumatic: 1.0,
|
|
76
|
+
syllabic: 0.96,
|
|
77
|
+
neutral: 1.0,
|
|
78
|
+
};
|
|
79
|
+
const MODE_MOOD_BASE_VELOCITY_DELTA = {
|
|
80
|
+
neutral: 0,
|
|
81
|
+
serious: -0.01,
|
|
82
|
+
sad: -0.03,
|
|
83
|
+
mystic: -0.01,
|
|
84
|
+
harmonious: 0.01,
|
|
85
|
+
happy: 0.04,
|
|
86
|
+
devout: 0.01,
|
|
87
|
+
angelical: 0.03,
|
|
88
|
+
perfect: 0.02,
|
|
89
|
+
};
|
|
90
|
+
const MODE_MOOD_CURVE_DELTA = {
|
|
91
|
+
neutral: 0,
|
|
92
|
+
serious: 0.01,
|
|
93
|
+
sad: 0.02,
|
|
94
|
+
mystic: 0.015,
|
|
95
|
+
harmonious: 0.005,
|
|
96
|
+
happy: -0.01,
|
|
97
|
+
devout: 0.01,
|
|
98
|
+
angelical: -0.015,
|
|
99
|
+
perfect: -0.005,
|
|
100
|
+
};
|
|
101
|
+
const MODE_STRENGTH_DEFAULT = 0.5;
|
|
102
|
+
const MODE_STRENGTH_MIN = 0;
|
|
103
|
+
const MODE_STRENGTH_MAX = 1;
|
|
104
|
+
const TENOR_MIN = 0.4;
|
|
105
|
+
const TENOR_MAX = 2.8;
|
|
106
|
+
const CADENCE_MIN = 0.05;
|
|
107
|
+
const CADENCE_MAX = 0.35;
|
|
108
|
+
const CURVE_MIN = 0.05;
|
|
109
|
+
const CURVE_MAX = 0.65;
|
|
110
|
+
function clamp(value, min, max) {
|
|
111
|
+
return Math.max(min, Math.min(max, value));
|
|
112
|
+
}
|
|
113
|
+
function blendMultiplier(multiplier, strength) {
|
|
114
|
+
return 1 + (multiplier - 1) * strength;
|
|
115
|
+
}
|
|
116
|
+
export function shapePhrasingForMode(profile, modeData, options = {}) {
|
|
117
|
+
if (!modeData || options.enabled === false)
|
|
118
|
+
return { ...profile };
|
|
119
|
+
const strength = clamp(options.strength ?? MODE_STRENGTH_DEFAULT, MODE_STRENGTH_MIN, MODE_STRENGTH_MAX);
|
|
120
|
+
const mood = modeData.profile.mood ?? "neutral";
|
|
121
|
+
const tendency = modeData.profile.tendency ?? "neutral";
|
|
122
|
+
const tenor = clamp(profile.tenor *
|
|
123
|
+
blendMultiplier(MODE_TYPE_TENOR_MULTIPLIER[modeData.type], strength), TENOR_MIN, TENOR_MAX);
|
|
124
|
+
const cadence = clamp(profile.cadence *
|
|
125
|
+
blendMultiplier(MODE_TYPE_CADENCE_MULTIPLIER[modeData.type], strength) *
|
|
126
|
+
blendMultiplier(MODE_TENDENCY_CADENCE_MULTIPLIER[tendency], strength), CADENCE_MIN, CADENCE_MAX);
|
|
127
|
+
const baseVelocity = clamp(profile.baseVelocity + (MODE_MOOD_BASE_VELOCITY_DELTA[mood] ?? 0) * strength, 0.4, 0.9);
|
|
128
|
+
const curve = clamp(profile.curve + (MODE_MOOD_CURVE_DELTA[mood] ?? 0) * strength, CURVE_MIN, CURVE_MAX);
|
|
129
|
+
return {
|
|
130
|
+
...profile,
|
|
131
|
+
tenor,
|
|
132
|
+
cadence,
|
|
133
|
+
baseVelocity,
|
|
134
|
+
curve,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const VELOCITY_MIN = 0.1;
|
|
138
|
+
const VELOCITY_MAX = 1.0;
|
|
139
|
+
const VELOCITY_SPREAD_MULTIPLIER = 2;
|
|
140
|
+
const VELOCITY_CENTER = 0.5;
|
|
141
|
+
const DURATION_BASE_FACTOR = 0.85;
|
|
142
|
+
const DURATION_ARSIS_FACTOR = 0.25;
|
|
143
|
+
const DURATION_ARCH_FACTOR = 0.2;
|
|
144
|
+
const DURATION_MIN = 0.2;
|
|
145
|
+
const DURATION_MAX = 4.0;
|
|
146
|
+
const TENOR_GAIN = 0.05;
|
|
147
|
+
const TENOR_DISTANCE_DIVISOR = 6;
|
|
148
|
+
const CADENCE_VELOCITY_FACTOR = 0.5;
|
|
149
|
+
const CADENCE_DURATION_FACTOR = 0.6;
|
|
150
|
+
const DIVISIO_STRENGTH = {
|
|
151
|
+
"::": 1.0,
|
|
152
|
+
":": 0.7,
|
|
153
|
+
";": 0.7,
|
|
154
|
+
",": 0.4,
|
|
155
|
+
"`": 0.0,
|
|
156
|
+
};
|
|
157
|
+
function getDivisioStrength(divisio) {
|
|
158
|
+
if (!divisio)
|
|
159
|
+
return 0;
|
|
160
|
+
return DIVISIO_STRENGTH[divisio] ?? 0;
|
|
161
|
+
}
|
|
162
|
+
function pitchClassDistance(pc1, pc2) {
|
|
163
|
+
const forward = (12 + pc2 - pc1) % 12;
|
|
164
|
+
const backward = (12 + pc1 - pc2) % 12;
|
|
165
|
+
return Math.min(forward, backward);
|
|
166
|
+
}
|
|
167
|
+
export function applyPhrasing(events, profile, tenorPc) {
|
|
168
|
+
const shaped = [];
|
|
169
|
+
const phrases = [];
|
|
170
|
+
let currentPhrase = [];
|
|
171
|
+
for (let i = 0; i < events.length; i++) {
|
|
172
|
+
const ev = events[i];
|
|
173
|
+
if (ev.type === "rest") {
|
|
174
|
+
if (currentPhrase.length > 0) {
|
|
175
|
+
const last = currentPhrase[currentPhrase.length - 1];
|
|
176
|
+
currentPhrase[currentPhrase.length - 1] = {
|
|
177
|
+
...last,
|
|
178
|
+
nextDivisio: ev.divisio,
|
|
179
|
+
};
|
|
180
|
+
phrases.push(currentPhrase);
|
|
181
|
+
currentPhrase = [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
currentPhrase.push({ ev, nextDivisio: undefined });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (currentPhrase.length > 0)
|
|
189
|
+
phrases.push(currentPhrase);
|
|
190
|
+
for (const phrase of phrases) {
|
|
191
|
+
const noteEntries = phrase;
|
|
192
|
+
if (noteEntries.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
let minArsis = Infinity, maxArsis = -Infinity;
|
|
195
|
+
let minStep = Infinity, maxStep = -Infinity;
|
|
196
|
+
for (const { ev } of noteEntries) {
|
|
197
|
+
// ev is always a Note here (rest events never enter currentPhrase)
|
|
198
|
+
const note = ev;
|
|
199
|
+
const arsis = note.context.weight;
|
|
200
|
+
if (arsis < minArsis)
|
|
201
|
+
minArsis = arsis;
|
|
202
|
+
if (arsis > maxArsis)
|
|
203
|
+
maxArsis = arsis;
|
|
204
|
+
const midi = note.pitch.midi;
|
|
205
|
+
if (midi < minStep)
|
|
206
|
+
minStep = midi;
|
|
207
|
+
if (midi > maxStep)
|
|
208
|
+
maxStep = midi;
|
|
209
|
+
}
|
|
210
|
+
const arsisSpan = maxArsis - minArsis || 1;
|
|
211
|
+
const stepSpan = maxStep - minStep || 1;
|
|
212
|
+
noteEntries.forEach(({ ev, nextDivisio }, order) => {
|
|
213
|
+
const note = ev;
|
|
214
|
+
const arsis = note.context.weight;
|
|
215
|
+
const arsisRelative = (arsis - minArsis) / arsisSpan;
|
|
216
|
+
const contourRelative = (note.pitch.midi - minStep) / stepSpan;
|
|
217
|
+
const t = (order + 0.5) / (noteEntries.length + 0.0001);
|
|
218
|
+
const arch = 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
|
|
219
|
+
let velocity = arsisRelative * (1 - profile.curve) + arch * profile.curve;
|
|
220
|
+
velocity += (contourRelative - VELOCITY_CENTER) * profile.contourVel;
|
|
221
|
+
velocity =
|
|
222
|
+
VELOCITY_CENTER +
|
|
223
|
+
(velocity - VELOCITY_CENTER) *
|
|
224
|
+
(profile.velSpread * VELOCITY_SPREAD_MULTIPLIER);
|
|
225
|
+
velocity *= profile.accent;
|
|
226
|
+
if (profile.tenor && typeof tenorPc === "number") {
|
|
227
|
+
const pc = note.pitch.pc;
|
|
228
|
+
const dist = pitchClassDistance(pc, tenorPc);
|
|
229
|
+
const tenorPull = Math.max(0, 1 - dist / TENOR_DISTANCE_DIVISOR);
|
|
230
|
+
velocity *= 1 + profile.tenor * TENOR_GAIN * tenorPull;
|
|
231
|
+
}
|
|
232
|
+
const divisioStrength = getDivisioStrength(nextDivisio);
|
|
233
|
+
velocity *=
|
|
234
|
+
1 - profile.cadence * divisioStrength * CADENCE_VELOCITY_FACTOR;
|
|
235
|
+
const finalVelocity = Math.max(VELOCITY_MIN, Math.min(VELOCITY_MAX, velocity * profile.baseVelocity));
|
|
236
|
+
const baseDur = note.performance.duration;
|
|
237
|
+
let durFactor = DURATION_BASE_FACTOR +
|
|
238
|
+
arsisRelative * DURATION_ARSIS_FACTOR +
|
|
239
|
+
arch * DURATION_ARCH_FACTOR;
|
|
240
|
+
durFactor += (contourRelative - VELOCITY_CENTER) * profile.contourDur;
|
|
241
|
+
if (note.context.ictus)
|
|
242
|
+
durFactor *= profile.ictusBoost;
|
|
243
|
+
durFactor += profile.cadence * divisioStrength * CADENCE_DURATION_FACTOR;
|
|
244
|
+
const shapedDuration = Math.max(DURATION_MIN, Math.min(DURATION_MAX, baseDur * durFactor));
|
|
245
|
+
shaped.push({
|
|
246
|
+
...note,
|
|
247
|
+
performance: {
|
|
248
|
+
...note.performance,
|
|
249
|
+
velocity: finalVelocity,
|
|
250
|
+
},
|
|
251
|
+
shapedDuration,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return shaped;
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=phrasing.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Phrase } from "./types.js";
|
|
2
|
+
export interface NoteRange {
|
|
3
|
+
min: number;
|
|
4
|
+
max: number;
|
|
5
|
+
span: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CadenceDistribution {
|
|
8
|
+
comma: number;
|
|
9
|
+
tick: number;
|
|
10
|
+
semicolon: number;
|
|
11
|
+
colon: number;
|
|
12
|
+
doubleBar: number;
|
|
13
|
+
}
|
|
14
|
+
/** Aggregate of compound-beat qualities and sizes across the score. */
|
|
15
|
+
export interface RhythmicProfile {
|
|
16
|
+
arsic: number;
|
|
17
|
+
thetic: number;
|
|
18
|
+
avgGroupSize: number;
|
|
19
|
+
maxGroupSize: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Prosody {
|
|
22
|
+
noteCount: number;
|
|
23
|
+
syllableCount: number;
|
|
24
|
+
phraseCount: number;
|
|
25
|
+
noteRange: NoteRange | null;
|
|
26
|
+
ambitus: number | null;
|
|
27
|
+
melismaRatio: number;
|
|
28
|
+
melismaByPhrase: number[];
|
|
29
|
+
ictusRate: number;
|
|
30
|
+
rhythmicProfile: RhythmicProfile;
|
|
31
|
+
cadenceWeight: number;
|
|
32
|
+
cadenceDistribution: CadenceDistribution;
|
|
33
|
+
}
|
|
34
|
+
export declare function computeProsody(phrases: Phrase[]): Prosody;
|
|
35
|
+
//# sourceMappingURL=prosody.d.ts.map
|