@hraness/dawg 0.6.1 → 0.7.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/CHANGELOG.md +92 -0
- package/DAWG.md +343 -116
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +832 -13
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +276 -3
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +1849 -42
- package/core/sections.ts +292 -26
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +126 -14
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +12 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +108 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +11 -7
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +37 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +160 -28
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +558 -0
- package/src/commands/help.ts +610 -380
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +8 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sing.ts +478 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1397 -253
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +759 -177
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +47 -7
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/core/sing.ts
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in singing voice (0.7 "Voice", lane f07-sing): an LF glottal
|
|
3
|
+
* source through a five-formant Klatt cascade with SATB singer tables,
|
|
4
|
+
* vowel morphs, a choir ensemble and Tuvan throat singing
|
|
5
|
+
* (`src/audio/sing/`). Pure and import-light, like `core/winds.ts`: the
|
|
6
|
+
* parameter table, the presets and the validators that the score, the
|
|
7
|
+
* prompt, the menu, the agent and the SDK printer share.
|
|
8
|
+
*
|
|
9
|
+
* A track plays this engine only when its `instrument` is `"sing"` AND it
|
|
10
|
+
* carries a `sing` object (`{ preset, ...overrides }`). No voice is cloned:
|
|
11
|
+
* the voice is a synthetic source-filter model.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
FxValidationError,
|
|
15
|
+
isRecord,
|
|
16
|
+
normalizeParams,
|
|
17
|
+
type EnumParam,
|
|
18
|
+
type NumberParam,
|
|
19
|
+
type ParamSpec,
|
|
20
|
+
} from "./params.ts";
|
|
21
|
+
|
|
22
|
+
/** Instrument value that selects the sing engine. */
|
|
23
|
+
export const SING_INSTRUMENT = "sing" as const;
|
|
24
|
+
|
|
25
|
+
/** Bumped when the LF or formant tables change (joins the stem digest). */
|
|
26
|
+
export const SING_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
export const SING_VOICES = Object.freeze([
|
|
29
|
+
"auto",
|
|
30
|
+
"soprano",
|
|
31
|
+
"alto",
|
|
32
|
+
"tenor",
|
|
33
|
+
"bass",
|
|
34
|
+
] as const);
|
|
35
|
+
export type SingVoice = (typeof SING_VOICES)[number];
|
|
36
|
+
|
|
37
|
+
export const SING_VOWELS = Object.freeze(["a", "e", "i", "o", "u"] as const);
|
|
38
|
+
/** `"a"` or a two-vowel morph `"a>o"` (validated by `parseVowel`). */
|
|
39
|
+
export type SingVowel = string;
|
|
40
|
+
|
|
41
|
+
export const SING_PRESET_NAMES = Object.freeze([
|
|
42
|
+
"aah",
|
|
43
|
+
"ooh",
|
|
44
|
+
"choir",
|
|
45
|
+
"oohchoir",
|
|
46
|
+
"chorale",
|
|
47
|
+
"airy",
|
|
48
|
+
"glass",
|
|
49
|
+
"lament",
|
|
50
|
+
"soprano",
|
|
51
|
+
"basso",
|
|
52
|
+
"drone",
|
|
53
|
+
"khoomei",
|
|
54
|
+
"sygyt",
|
|
55
|
+
"kargyraa",
|
|
56
|
+
] as const);
|
|
57
|
+
export type SingPreset = (typeof SING_PRESET_NAMES)[number];
|
|
58
|
+
|
|
59
|
+
/** Presets that are also instrument words (`/instrument choir`). */
|
|
60
|
+
export const SING_WORDS = Object.freeze([
|
|
61
|
+
"aah",
|
|
62
|
+
"ooh",
|
|
63
|
+
"choir",
|
|
64
|
+
"chorale",
|
|
65
|
+
"khoomei",
|
|
66
|
+
"sygyt",
|
|
67
|
+
"kargyraa",
|
|
68
|
+
] as const);
|
|
69
|
+
|
|
70
|
+
/** Browse groups for Sound > browse sounds > Voices. */
|
|
71
|
+
export const SING_GROUPS: readonly Readonly<{
|
|
72
|
+
label: string;
|
|
73
|
+
presets: readonly SingPreset[];
|
|
74
|
+
}>[] = Object.freeze([
|
|
75
|
+
{
|
|
76
|
+
label: "Choir",
|
|
77
|
+
presets: ["aah", "ooh", "choir", "oohchoir", "chorale", "airy", "glass"],
|
|
78
|
+
},
|
|
79
|
+
{ label: "Solo", presets: ["lament", "soprano", "basso"] },
|
|
80
|
+
{ label: "Throat", presets: ["drone", "khoomei", "sygyt", "kargyraa"] },
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const num = (
|
|
84
|
+
min: number,
|
|
85
|
+
max: number,
|
|
86
|
+
fallback: number,
|
|
87
|
+
step: number | "log",
|
|
88
|
+
doc: string,
|
|
89
|
+
extra: Partial<NumberParam> = {},
|
|
90
|
+
): NumberParam =>
|
|
91
|
+
Object.freeze({
|
|
92
|
+
kind: "number",
|
|
93
|
+
min,
|
|
94
|
+
max,
|
|
95
|
+
default: fallback,
|
|
96
|
+
step,
|
|
97
|
+
optional: true,
|
|
98
|
+
doc,
|
|
99
|
+
...extra,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** Lowest and highest drone pitch (C2..G4). */
|
|
103
|
+
export const SING_DRONE_RANGE = Object.freeze([36, 67] as const);
|
|
104
|
+
/** Harmonic numbers a throat melody may select. */
|
|
105
|
+
export const SING_HARMONIC_RANGE = Object.freeze([2, 24] as const);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every sing parameter (formant.md section 4.1). All are optional: an
|
|
109
|
+
* absent one takes the preset's value, then the default here. `automate`
|
|
110
|
+
* ones have a `sing-<param>` lane read every 32-sample control tick.
|
|
111
|
+
* `vowel` also takes a morph (`a>o`) and `harmonics` is a pair; both are
|
|
112
|
+
* validated by `normalizeSing`, not by these rows.
|
|
113
|
+
*/
|
|
114
|
+
export const SING_PARAMS: Readonly<Record<string, ParamSpec>> = Object.freeze({
|
|
115
|
+
voice: Object.freeze({
|
|
116
|
+
kind: "enum",
|
|
117
|
+
values: SING_VOICES,
|
|
118
|
+
default: "auto",
|
|
119
|
+
optional: true,
|
|
120
|
+
doc: "formant table: auto picks one per part from its median pitch (bass < F3, tenor < C#4, alto < G4, soprano)",
|
|
121
|
+
}) as EnumParam,
|
|
122
|
+
vowel: Object.freeze({
|
|
123
|
+
kind: "enum",
|
|
124
|
+
values: SING_VOWELS,
|
|
125
|
+
default: "a",
|
|
126
|
+
optional: true,
|
|
127
|
+
doc: "sung vowel; a>o morphs across each note; a note's vowel wins",
|
|
128
|
+
strudel: ["vowel"],
|
|
129
|
+
}) as EnumParam,
|
|
130
|
+
morph: num(0, 1, 1, 0.05, "how far an a>o vowel travels by the note's end", {
|
|
131
|
+
automate: true,
|
|
132
|
+
}),
|
|
133
|
+
formant: num(-12, 12, 0, 0.5, "throat size: formant shift, keeps pitch", {
|
|
134
|
+
unit: "st",
|
|
135
|
+
automate: true,
|
|
136
|
+
}),
|
|
137
|
+
bright: num(0, 1, 0.5, 0.05, "voice quality: breathy and dark .. pressed", {
|
|
138
|
+
automate: true,
|
|
139
|
+
}),
|
|
140
|
+
breath: num(0, 1, 0.12, 0.02, "aspiration noise", { automate: true }),
|
|
141
|
+
jitter: num(0, 3, 0.3, 0.1, "period jitter", { unit: "%" }),
|
|
142
|
+
shimmer: num(0, 1, 0.3, 0.05, "amplitude jitter"),
|
|
143
|
+
attack: num(0.005, 2, 0.08, "log", "onset", {
|
|
144
|
+
unit: "s",
|
|
145
|
+
strudel: ["att"],
|
|
146
|
+
}),
|
|
147
|
+
release: num(0.01, 4, 0.18, "log", "fade after the note", {
|
|
148
|
+
unit: "s",
|
|
149
|
+
strudel: ["rel"],
|
|
150
|
+
}),
|
|
151
|
+
vib: num(0, 9, 5.5, 0.25, "vibrato rate", {
|
|
152
|
+
unit: "Hz",
|
|
153
|
+
strudel: ["vibrato"],
|
|
154
|
+
}),
|
|
155
|
+
vibmod: num(0, 1, 0.3, 0.05, "vibrato depth", {
|
|
156
|
+
unit: "st",
|
|
157
|
+
automate: true,
|
|
158
|
+
strudel: ["vmod"],
|
|
159
|
+
}),
|
|
160
|
+
vibdelay: num(0, 2, 0.25, 0.05, "vibrato fades in after this", {
|
|
161
|
+
unit: "s",
|
|
162
|
+
}),
|
|
163
|
+
voices: num(1, 8, 1, 1, "singers per note (choir)", { integer: true }),
|
|
164
|
+
spread: num(0, 40, 10, 1, "ensemble detune scatter", { unit: "cents" }),
|
|
165
|
+
ring: num(0, 1, 0, 0.05, "singer's formant (bright 3 kHz ring)", {
|
|
166
|
+
automate: true,
|
|
167
|
+
}),
|
|
168
|
+
drone: num(
|
|
169
|
+
SING_DRONE_RANGE[0],
|
|
170
|
+
SING_DRONE_RANGE[1],
|
|
171
|
+
50,
|
|
172
|
+
1,
|
|
173
|
+
"throat singing: a held drone at this pitch; notes steer the overtone",
|
|
174
|
+
{ integer: true },
|
|
175
|
+
),
|
|
176
|
+
overtone: num(0, 1, 0, 0.05, "overtone filter sharpness and level", {
|
|
177
|
+
automate: true,
|
|
178
|
+
}),
|
|
179
|
+
sub: num(0, 1, 0, 0.05, "kargyraa subharmonic (an octave below)", {
|
|
180
|
+
automate: true,
|
|
181
|
+
}),
|
|
182
|
+
gain: num(0, 2, 0.8, 0.05, "engine output"),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** Stored key order after `preset` (`harmonics` sits before `sub`). */
|
|
186
|
+
export const SING_KEY_ORDER: readonly string[] = Object.freeze(
|
|
187
|
+
Object.keys(SING_PARAMS).flatMap((key) =>
|
|
188
|
+
key === "sub" ? ["harmonics", "sub"] : [key],
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
/** The rows shown under Sound > Parameters > Throat. */
|
|
193
|
+
export const SING_THROAT_PARAMS = Object.freeze([
|
|
194
|
+
"drone",
|
|
195
|
+
"overtone",
|
|
196
|
+
"harmonics",
|
|
197
|
+
"sub",
|
|
198
|
+
] as const);
|
|
199
|
+
|
|
200
|
+
/** A track's stored sing settings: a preset plus overrides, all optional. */
|
|
201
|
+
export type TrackSing = Readonly<{
|
|
202
|
+
preset?: SingPreset;
|
|
203
|
+
voice?: SingVoice;
|
|
204
|
+
vowel?: SingVowel;
|
|
205
|
+
morph?: number;
|
|
206
|
+
formant?: number;
|
|
207
|
+
bright?: number;
|
|
208
|
+
breath?: number;
|
|
209
|
+
jitter?: number;
|
|
210
|
+
shimmer?: number;
|
|
211
|
+
attack?: number;
|
|
212
|
+
release?: number;
|
|
213
|
+
vib?: number;
|
|
214
|
+
vibmod?: number;
|
|
215
|
+
vibdelay?: number;
|
|
216
|
+
voices?: number;
|
|
217
|
+
spread?: number;
|
|
218
|
+
ring?: number;
|
|
219
|
+
/** MIDI 36..67; present turns on throat mode. */
|
|
220
|
+
drone?: number;
|
|
221
|
+
overtone?: number;
|
|
222
|
+
harmonics?: readonly [number, number];
|
|
223
|
+
sub?: number;
|
|
224
|
+
gain?: number;
|
|
225
|
+
}>;
|
|
226
|
+
|
|
227
|
+
/** Every setting resolved: defaults, then the preset, then overrides. */
|
|
228
|
+
export type SingSettings = Readonly<{
|
|
229
|
+
voice: SingVoice;
|
|
230
|
+
vowel: SingVowel;
|
|
231
|
+
morph: number;
|
|
232
|
+
formant: number;
|
|
233
|
+
bright: number;
|
|
234
|
+
breath: number;
|
|
235
|
+
jitter: number;
|
|
236
|
+
shimmer: number;
|
|
237
|
+
attack: number;
|
|
238
|
+
release: number;
|
|
239
|
+
vib: number;
|
|
240
|
+
vibmod: number;
|
|
241
|
+
vibdelay: number;
|
|
242
|
+
voices: number;
|
|
243
|
+
spread: number;
|
|
244
|
+
ring: number;
|
|
245
|
+
drone?: number;
|
|
246
|
+
overtone: number;
|
|
247
|
+
harmonics: readonly [number, number];
|
|
248
|
+
sub: number;
|
|
249
|
+
gain: number;
|
|
250
|
+
}>;
|
|
251
|
+
|
|
252
|
+
export const SING_DEFAULTS: SingSettings = Object.freeze({
|
|
253
|
+
voice: "auto",
|
|
254
|
+
vowel: "a",
|
|
255
|
+
morph: 1,
|
|
256
|
+
formant: 0,
|
|
257
|
+
bright: 0.5,
|
|
258
|
+
breath: 0.12,
|
|
259
|
+
jitter: 0.3,
|
|
260
|
+
shimmer: 0.3,
|
|
261
|
+
attack: 0.08,
|
|
262
|
+
release: 0.18,
|
|
263
|
+
vib: 5.5,
|
|
264
|
+
vibmod: 0.3,
|
|
265
|
+
vibdelay: 0.25,
|
|
266
|
+
voices: 1,
|
|
267
|
+
spread: 10,
|
|
268
|
+
ring: 0,
|
|
269
|
+
overtone: 0,
|
|
270
|
+
harmonics: Object.freeze([6, 12]) as readonly [number, number],
|
|
271
|
+
sub: 0,
|
|
272
|
+
gain: 0.8,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
export type SingPresetEntry = Readonly<{
|
|
276
|
+
settings: SingSettings;
|
|
277
|
+
doc: string;
|
|
278
|
+
styles: string;
|
|
279
|
+
}>;
|
|
280
|
+
|
|
281
|
+
const preset = (
|
|
282
|
+
settings: Partial<SingSettings>,
|
|
283
|
+
doc: string,
|
|
284
|
+
styles: string,
|
|
285
|
+
): SingPresetEntry =>
|
|
286
|
+
Object.freeze({
|
|
287
|
+
settings: Object.freeze({ ...SING_DEFAULTS, ...settings }),
|
|
288
|
+
doc,
|
|
289
|
+
styles,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const pair = (lo: number, hi: number): readonly [number, number] =>
|
|
293
|
+
Object.freeze([lo, hi]) as readonly [number, number];
|
|
294
|
+
|
|
295
|
+
/** Presets (formant.md section 5). */
|
|
296
|
+
export const SING_PRESETS: Readonly<Record<SingPreset, SingPresetEntry>> =
|
|
297
|
+
Object.freeze({
|
|
298
|
+
aah: preset(
|
|
299
|
+
{ vowel: "a" },
|
|
300
|
+
'warm "aah", one voice, gentle vibrato',
|
|
301
|
+
"wordless leads, Sakamoto, ballads",
|
|
302
|
+
),
|
|
303
|
+
ooh: preset(
|
|
304
|
+
{ vowel: "u", bright: 0.3 },
|
|
305
|
+
'soft rounded "ooh", darker',
|
|
306
|
+
"dark ballad, Beach House, Mitski backing",
|
|
307
|
+
),
|
|
308
|
+
choir: preset(
|
|
309
|
+
{ voices: 6, ring: 0.2 },
|
|
310
|
+
'six-voice "aah" ensemble',
|
|
311
|
+
"Eno, Bach cantata color, pads",
|
|
312
|
+
),
|
|
313
|
+
oohchoir: preset(
|
|
314
|
+
{ voices: 6, vowel: "u", breath: 0.3, bright: 0.35 },
|
|
315
|
+
'six-voice breathy "ooh"',
|
|
316
|
+
"Beach House, shoegaze, ambient",
|
|
317
|
+
),
|
|
318
|
+
chorale: preset(
|
|
319
|
+
{ voices: 4, vibmod: 0.15, ring: 0.35, bright: 0.6 },
|
|
320
|
+
"four voices, little vibrato, clear ring",
|
|
321
|
+
"Bach and Brahms chorales",
|
|
322
|
+
),
|
|
323
|
+
airy: preset(
|
|
324
|
+
{
|
|
325
|
+
voices: 8,
|
|
326
|
+
breath: 0.35,
|
|
327
|
+
spread: 18,
|
|
328
|
+
attack: 0.6,
|
|
329
|
+
release: 1.2,
|
|
330
|
+
bright: 0.35,
|
|
331
|
+
},
|
|
332
|
+
"eight breathy voices, slow swell",
|
|
333
|
+
"Eno, OPN, Four Tet pads",
|
|
334
|
+
),
|
|
335
|
+
glass: preset(
|
|
336
|
+
{ voices: 4, vowel: "i", formant: 5, bright: 0.8, vibmod: 0 },
|
|
337
|
+
'four bright "ee" voices, no vibrato',
|
|
338
|
+
"OPN, A. G. Cook, hyperpop pads",
|
|
339
|
+
),
|
|
340
|
+
lament: preset(
|
|
341
|
+
{ vowel: "o", formant: -2, vib: 4.5, vibmod: 0.5, vibdelay: 0.4 },
|
|
342
|
+
'solo low "oh", slow wide vibrato',
|
|
343
|
+
"dark ballad, Gesaffelstein intros",
|
|
344
|
+
),
|
|
345
|
+
soprano: preset(
|
|
346
|
+
{ voice: "soprano", ring: 0.5, vib: 6, vibmod: 0.5, bright: 0.65 },
|
|
347
|
+
"solo soprano with an operatic ring",
|
|
348
|
+
"Messiaen, Debussy, wordless arias",
|
|
349
|
+
),
|
|
350
|
+
basso: preset(
|
|
351
|
+
{ voice: "bass", vowel: "o", ring: 0.4 },
|
|
352
|
+
'solo bass "oh"',
|
|
353
|
+
"cantata recitative color, drones",
|
|
354
|
+
),
|
|
355
|
+
drone: preset(
|
|
356
|
+
{
|
|
357
|
+
voice: "tenor",
|
|
358
|
+
drone: 48,
|
|
359
|
+
overtone: 0.3,
|
|
360
|
+
harmonics: pair(2, 16),
|
|
361
|
+
vibmod: 0,
|
|
362
|
+
release: 1.5,
|
|
363
|
+
},
|
|
364
|
+
"held drone, the melody colors its overtones",
|
|
365
|
+
"La Monte Young, Barbieri, Eno",
|
|
366
|
+
),
|
|
367
|
+
khoomei: preset(
|
|
368
|
+
{
|
|
369
|
+
voice: "tenor",
|
|
370
|
+
vowel: "o",
|
|
371
|
+
drone: 50,
|
|
372
|
+
overtone: 0.85,
|
|
373
|
+
harmonics: pair(6, 10),
|
|
374
|
+
vibmod: 0,
|
|
375
|
+
},
|
|
376
|
+
"Tuvan khoomei: a D3 drone with a whistled melody",
|
|
377
|
+
"Tuvan khoomei",
|
|
378
|
+
),
|
|
379
|
+
sygyt: preset(
|
|
380
|
+
{
|
|
381
|
+
voice: "tenor",
|
|
382
|
+
vowel: "u>i",
|
|
383
|
+
drone: 55,
|
|
384
|
+
bright: 0.85,
|
|
385
|
+
overtone: 1,
|
|
386
|
+
harmonics: pair(9, 12),
|
|
387
|
+
vibmod: 0,
|
|
388
|
+
},
|
|
389
|
+
"Tuvan sygyt: a G3 drone with a high flute-like whistle",
|
|
390
|
+
"Tuvan sygyt",
|
|
391
|
+
),
|
|
392
|
+
kargyraa: preset(
|
|
393
|
+
{
|
|
394
|
+
voice: "bass",
|
|
395
|
+
vowel: "a",
|
|
396
|
+
drone: 45,
|
|
397
|
+
sub: 0.8,
|
|
398
|
+
overtone: 0.25,
|
|
399
|
+
harmonics: pair(5, 9),
|
|
400
|
+
vibmod: 0,
|
|
401
|
+
bright: 0.6,
|
|
402
|
+
},
|
|
403
|
+
"Tuvan kargyraa: an A2 growl with a sub an octave below",
|
|
404
|
+
"Tuvan kargyraa",
|
|
405
|
+
),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
export const DEFAULT_SING_PRESET: SingPreset = "aah";
|
|
409
|
+
|
|
410
|
+
export function isSingPreset(name: string): name is SingPreset {
|
|
411
|
+
return (SING_PRESET_NAMES as readonly string[]).includes(name);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function isSingWord(word: string): boolean {
|
|
415
|
+
return (SING_WORDS as readonly string[]).includes(word);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function isSingParam(name: string): boolean {
|
|
419
|
+
return (
|
|
420
|
+
name === "harmonics" ||
|
|
421
|
+
Object.prototype.hasOwnProperty.call(SING_PARAMS, name)
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Strudel spelling (`att`, `vmod`) to a sing parameter name. */
|
|
426
|
+
export function singParamName(name: string): string | undefined {
|
|
427
|
+
if (isSingParam(name)) return name;
|
|
428
|
+
for (const [key, spec] of Object.entries(SING_PARAMS))
|
|
429
|
+
if (spec.strudel?.includes(name)) return key;
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const NOTE_LETTERS: Readonly<Record<string, number>> = Object.freeze({
|
|
434
|
+
c: 0,
|
|
435
|
+
d: 2,
|
|
436
|
+
e: 4,
|
|
437
|
+
f: 5,
|
|
438
|
+
g: 7,
|
|
439
|
+
a: 9,
|
|
440
|
+
b: 11,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
/** `D3`, `f#2`, `Bb3` to MIDI (C4 = 60); undefined if not a note name. */
|
|
444
|
+
export function singNoteNumber(text: string): number | undefined {
|
|
445
|
+
const match = /^([a-gA-G])(#|b|s)?(-?\d)$/.exec(text.trim());
|
|
446
|
+
if (!match) return undefined;
|
|
447
|
+
const base = NOTE_LETTERS[match[1]!.toLowerCase()]!;
|
|
448
|
+
const accidental =
|
|
449
|
+
match[2] === "#" || match[2] === "s" ? 1 : match[2] ? -1 : 0;
|
|
450
|
+
return (Number(match[3]) + 1) * 12 + base + accidental;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const SHARP_NAMES = [
|
|
454
|
+
"C",
|
|
455
|
+
"C#",
|
|
456
|
+
"D",
|
|
457
|
+
"D#",
|
|
458
|
+
"E",
|
|
459
|
+
"F",
|
|
460
|
+
"F#",
|
|
461
|
+
"G",
|
|
462
|
+
"G#",
|
|
463
|
+
"A",
|
|
464
|
+
"A#",
|
|
465
|
+
"B",
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
/** MIDI to a sharp note name (`50` -> `D3`). */
|
|
469
|
+
export function singNoteName(midi: number): string {
|
|
470
|
+
const m = Math.round(midi);
|
|
471
|
+
return `${SHARP_NAMES[((m % 12) + 12) % 12]}${Math.floor(m / 12) - 1}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A drone as a note name or MIDI number; throws naming both forms. */
|
|
475
|
+
export function parseDrone(value: unknown): number {
|
|
476
|
+
const n =
|
|
477
|
+
typeof value === "string"
|
|
478
|
+
? (singNoteNumber(value) ??
|
|
479
|
+
(/^\d+$/.test(value.trim()) ? Number(value) : undefined))
|
|
480
|
+
: typeof value === "number"
|
|
481
|
+
? value
|
|
482
|
+
: undefined;
|
|
483
|
+
const [lo, hi] = SING_DRONE_RANGE;
|
|
484
|
+
if (n === undefined || !Number.isInteger(n) || n < lo || n > hi)
|
|
485
|
+
throw new FxValidationError(
|
|
486
|
+
`sing drone must be a note name C2..G4 (e.g. "D3") or a MIDI number ${lo}..${hi}`,
|
|
487
|
+
);
|
|
488
|
+
return n;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Validates a vowel: `"a"` or a two-vowel morph `"a>o"`. Returns the
|
|
493
|
+
* parts; throws on anything else.
|
|
494
|
+
*/
|
|
495
|
+
/** Sung spellings of the five vowels (`sing vowels ah oo`). */
|
|
496
|
+
const VOWEL_SPELLINGS: Readonly<Record<string, string>> = Object.freeze({
|
|
497
|
+
ah: "a",
|
|
498
|
+
eh: "e",
|
|
499
|
+
ee: "i",
|
|
500
|
+
oh: "o",
|
|
501
|
+
oo: "u",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
export function parseVowel(text: string): readonly [string, string?] {
|
|
505
|
+
const parts =
|
|
506
|
+
typeof text === "string"
|
|
507
|
+
? text
|
|
508
|
+
.trim()
|
|
509
|
+
.toLowerCase()
|
|
510
|
+
.split(">")
|
|
511
|
+
.map((part) => VOWEL_SPELLINGS[part.trim()] ?? part.trim())
|
|
512
|
+
: [];
|
|
513
|
+
const ok = (v: string | undefined): v is string =>
|
|
514
|
+
v !== undefined && (SING_VOWELS as readonly string[]).includes(v);
|
|
515
|
+
if (parts.length === 1 && ok(parts[0])) return Object.freeze([parts[0]]);
|
|
516
|
+
if (parts.length === 2 && ok(parts[0]) && ok(parts[1]))
|
|
517
|
+
return Object.freeze([parts[0], parts[1]]) as readonly [string, string];
|
|
518
|
+
throw new FxValidationError(
|
|
519
|
+
`vowel must be one of ${SING_VOWELS.join(" ")} or a morph like a>o`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The canonical text of a vowel (`"A > O"` -> `"a>o"`). */
|
|
524
|
+
export function normalizeVowel(text: unknown): SingVowel {
|
|
525
|
+
if (typeof text !== "string")
|
|
526
|
+
throw new FxValidationError(
|
|
527
|
+
`vowel must be one of ${SING_VOWELS.join(" ")} or a morph like a>o`,
|
|
528
|
+
);
|
|
529
|
+
return parseVowel(text).join(">");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Spelling patterns to a sung vowel, longest first. English diphthongs
|
|
534
|
+
* become two-vowel morphs (the nucleus a singer holds, then the glide).
|
|
535
|
+
*/
|
|
536
|
+
const SPELLINGS: readonly (readonly [RegExp, SingVowel])[] = Object.freeze([
|
|
537
|
+
[/igh|ie$|i[^aeiou]e$/, "a>i"],
|
|
538
|
+
// final -y is a short i (hap-py, lone-ly, ev-ery); my, why, sky ... are
|
|
539
|
+
// in VOWEL_WORDS
|
|
540
|
+
[/[^aeiou]y$/, "i"],
|
|
541
|
+
[/ou|ow/, "a>u"],
|
|
542
|
+
[/oi|oy/, "o>i"],
|
|
543
|
+
[/ai|ay|ey|eigh|a[^aeiou]e$/, "e>i"],
|
|
544
|
+
[/oo|ew|ue|u[^aeiou]e$/, "u"],
|
|
545
|
+
[/ee|ea|ie|ei/, "i"],
|
|
546
|
+
[/oa|o[^aeiou]e$|o$/, "o"],
|
|
547
|
+
// an open syllable ending in u (hal-le-lu-jah, flu) is u
|
|
548
|
+
[/[^aeiou]u$/, "u"],
|
|
549
|
+
[/au|aw|ah|ar/, "a"],
|
|
550
|
+
[/a/, "a"],
|
|
551
|
+
[/e/, "e"],
|
|
552
|
+
[/i/, "i"],
|
|
553
|
+
[/o/, "o"],
|
|
554
|
+
[/u/, "a"],
|
|
555
|
+
]);
|
|
556
|
+
|
|
557
|
+
/** Common lyric words the spelling rules get wrong. */
|
|
558
|
+
const VOWEL_WORDS: Readonly<Record<string, SingVowel>> = Object.freeze({
|
|
559
|
+
you: "u",
|
|
560
|
+
to: "u",
|
|
561
|
+
do: "u",
|
|
562
|
+
who: "u",
|
|
563
|
+
heart: "a",
|
|
564
|
+
are: "a",
|
|
565
|
+
yeah: "e",
|
|
566
|
+
eye: "a>i",
|
|
567
|
+
eyes: "a>i",
|
|
568
|
+
i: "a>i",
|
|
569
|
+
...Object.fromEntries(
|
|
570
|
+
[
|
|
571
|
+
"my",
|
|
572
|
+
"by",
|
|
573
|
+
"why",
|
|
574
|
+
"fly",
|
|
575
|
+
"cry",
|
|
576
|
+
"sky",
|
|
577
|
+
"try",
|
|
578
|
+
"dry",
|
|
579
|
+
"shy",
|
|
580
|
+
"spy",
|
|
581
|
+
"fry",
|
|
582
|
+
].map((word) => [word, "a>i" as const]),
|
|
583
|
+
),
|
|
584
|
+
love: "a",
|
|
585
|
+
come: "a",
|
|
586
|
+
some: "a",
|
|
587
|
+
one: "a",
|
|
588
|
+
done: "a",
|
|
589
|
+
none: "a",
|
|
590
|
+
above: "a",
|
|
591
|
+
was: "a",
|
|
592
|
+
of: "a",
|
|
593
|
+
the: "a",
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The vowel a lyric syllable sings (its nucleus): `"night"` -> `"a>i"`,
|
|
598
|
+
* `"you"` -> `"u"`, `"la"` -> `"a"`. A bare vowel letter or `a>o` passes
|
|
599
|
+
* through. Undefined when the syllable has no vowel letter.
|
|
600
|
+
*/
|
|
601
|
+
export function vowelOf(syllable: string | undefined): SingVowel | undefined {
|
|
602
|
+
if (!syllable) return undefined;
|
|
603
|
+
const lower = syllable.toLowerCase();
|
|
604
|
+
try {
|
|
605
|
+
return normalizeVowel(lower);
|
|
606
|
+
} catch {
|
|
607
|
+
// a word, not a vowel
|
|
608
|
+
}
|
|
609
|
+
const word = lower.replace(/[^a-z]/g, "");
|
|
610
|
+
const exception = VOWEL_WORDS[word];
|
|
611
|
+
if (exception) return exception;
|
|
612
|
+
for (const [pattern, vowel] of SPELLINGS)
|
|
613
|
+
if (pattern.test(word)) return vowel;
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Validates a `sing` field: `{ preset?, ...overrides }`, null or absent
|
|
619
|
+
* meaning none. Keys come out in canonical order (preset, then
|
|
620
|
+
* `SING_PARAMS` order, harmonics before sub). `{}` is kept: it means the
|
|
621
|
+
* default preset.
|
|
622
|
+
*/
|
|
623
|
+
export function normalizeSing(input: unknown): TrackSing | undefined {
|
|
624
|
+
if (input === undefined || input === null) return undefined;
|
|
625
|
+
if (!isRecord(input))
|
|
626
|
+
throw new FxValidationError("track sing must be an object or null");
|
|
627
|
+
const params: Record<string, unknown> = {};
|
|
628
|
+
let presetName: SingPreset | undefined;
|
|
629
|
+
let vowel: SingVowel | undefined;
|
|
630
|
+
let drone: number | undefined;
|
|
631
|
+
let harmonics: readonly [number, number] | undefined;
|
|
632
|
+
for (const [key, value] of Object.entries(input)) {
|
|
633
|
+
if (value === undefined || value === null) continue;
|
|
634
|
+
if (key === "preset") {
|
|
635
|
+
const name = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
636
|
+
if (!isSingPreset(name))
|
|
637
|
+
throw new FxValidationError(
|
|
638
|
+
`sing preset must be one of ${SING_PRESET_NAMES.join(", ")}`,
|
|
639
|
+
);
|
|
640
|
+
presetName = name;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const name = singParamName(key);
|
|
644
|
+
if (!name)
|
|
645
|
+
throw new FxValidationError(
|
|
646
|
+
`sing has no parameter "${key.slice(0, 32)}" (try ${[...Object.keys(SING_PARAMS), "harmonics"].join(", ")})`,
|
|
647
|
+
);
|
|
648
|
+
if (name === "vowel") vowel = normalizeVowel(value);
|
|
649
|
+
else if (name === "drone") drone = parseDrone(value);
|
|
650
|
+
else if (name === "harmonics") harmonics = parseHarmonics(value);
|
|
651
|
+
else {
|
|
652
|
+
// A stored count is whole; `voices: 2.5` is a mistake, not a 3.
|
|
653
|
+
const spec = SING_PARAMS[name];
|
|
654
|
+
if (
|
|
655
|
+
spec?.kind === "number" &&
|
|
656
|
+
spec.integer === true &&
|
|
657
|
+
typeof value === "number" &&
|
|
658
|
+
!Number.isInteger(value)
|
|
659
|
+
)
|
|
660
|
+
throw new FxValidationError(`sing ${name} must be a whole number`);
|
|
661
|
+
params[name] = value;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const values = normalizeParams(SING_PARAMS, params, "sing", false);
|
|
665
|
+
const out: Record<string, unknown> = {};
|
|
666
|
+
if (presetName) out.preset = presetName;
|
|
667
|
+
const special: Record<string, unknown> = { vowel, drone, harmonics };
|
|
668
|
+
for (const key of SING_KEY_ORDER) {
|
|
669
|
+
const value = key in special ? special[key] : values[key];
|
|
670
|
+
if (value !== undefined) out[key] = value;
|
|
671
|
+
}
|
|
672
|
+
return Object.freeze(out) as TrackSing;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function parseHarmonics(value: unknown): readonly [number, number] {
|
|
676
|
+
const [lo, hi] = SING_HARMONIC_RANGE;
|
|
677
|
+
if (
|
|
678
|
+
!Array.isArray(value) ||
|
|
679
|
+
value.length !== 2 ||
|
|
680
|
+
!value.every((h) => Number.isInteger(h) && h >= lo && h <= hi) ||
|
|
681
|
+
value[0] >= value[1]
|
|
682
|
+
)
|
|
683
|
+
throw new FxValidationError(
|
|
684
|
+
`sing harmonics must be [lo, hi], whole numbers ${lo}..${hi} with lo < hi`,
|
|
685
|
+
);
|
|
686
|
+
return pair(value[0] as number, value[1] as number);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The preset a `sing` field plays. */
|
|
690
|
+
export function singPresetOf(sing: TrackSing | undefined): SingPreset {
|
|
691
|
+
return sing?.preset ?? DEFAULT_SING_PRESET;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The settings a `sing` field plays: defaults, then the preset, then
|
|
696
|
+
* explicit overrides. `keyRoot` (a pitch class) moves a throat preset's
|
|
697
|
+
* drone to the song's key root in the preset drone's octave when the
|
|
698
|
+
* user set no drone.
|
|
699
|
+
*/
|
|
700
|
+
export function resolveSing(
|
|
701
|
+
sing: TrackSing | undefined,
|
|
702
|
+
keyRoot?: number,
|
|
703
|
+
): SingSettings {
|
|
704
|
+
const base = SING_PRESETS[singPresetOf(sing)].settings;
|
|
705
|
+
const out: Record<string, unknown> = { ...base };
|
|
706
|
+
if (sing)
|
|
707
|
+
for (const [key, value] of Object.entries(sing))
|
|
708
|
+
if (key !== "preset" && value !== undefined) out[key] = value;
|
|
709
|
+
if (
|
|
710
|
+
keyRoot !== undefined &&
|
|
711
|
+
sing?.drone === undefined &&
|
|
712
|
+
base.drone !== undefined
|
|
713
|
+
) {
|
|
714
|
+
// The key root nearest the preset's drone (within a tritone), so each
|
|
715
|
+
// style keeps its register: sygyt's whistle stays near 2 kHz and
|
|
716
|
+
// kargyraa's growl near A2 in any key.
|
|
717
|
+
const offset = (((Math.round(keyRoot) - base.drone) % 12) + 18) % 12;
|
|
718
|
+
let drone = base.drone + offset - 6;
|
|
719
|
+
if (drone > SING_DRONE_RANGE[1]) drone -= 12;
|
|
720
|
+
if (drone < SING_DRONE_RANGE[0]) drone += 12;
|
|
721
|
+
out.drone = drone;
|
|
722
|
+
}
|
|
723
|
+
return Object.freeze(out) as SingSettings;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Parameters with a `sing-<param>` automation lane. */
|
|
727
|
+
export const SING_LANE_PARAMS: readonly Readonly<{
|
|
728
|
+
param: string;
|
|
729
|
+
spec: NumberParam;
|
|
730
|
+
}>[] = Object.freeze(
|
|
731
|
+
Object.entries(SING_PARAMS)
|
|
732
|
+
.filter(
|
|
733
|
+
(entry): entry is [string, NumberParam] =>
|
|
734
|
+
entry[1].kind === "number" && entry[1].automate === true,
|
|
735
|
+
)
|
|
736
|
+
.map(([param, spec]) => Object.freeze({ param, spec })),
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
/** The formant table `auto` picks for a MIDI pitch. */
|
|
740
|
+
export function autoVoice(pitch: number): Exclude<SingVoice, "auto"> {
|
|
741
|
+
if (pitch < 48) return "bass";
|
|
742
|
+
if (pitch < 55) return "tenor";
|
|
743
|
+
if (pitch < 72) return "alto";
|
|
744
|
+
return "soprano";
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* `voice: "auto"` for a whole part: the voice type of the part's median
|
|
749
|
+
* pitch, so an SATB part keeps one formant table across its range (a tenor
|
|
750
|
+
* line around B3-D4 sings the tenor table, not the alto one note by note).
|
|
751
|
+
* Undefined for no pitches.
|
|
752
|
+
*/
|
|
753
|
+
export function autoPartVoice(
|
|
754
|
+
pitches: readonly number[],
|
|
755
|
+
): Exclude<SingVoice, "auto"> | undefined {
|
|
756
|
+
if (pitches.length === 0) return undefined;
|
|
757
|
+
const sorted = [...pitches].sort((a, b) => a - b);
|
|
758
|
+
const median = sorted[Math.floor((sorted.length - 1) / 2)]!;
|
|
759
|
+
if (median < 53) return "bass";
|
|
760
|
+
if (median < 61) return "tenor";
|
|
761
|
+
if (median < 67) return "alto";
|
|
762
|
+
return "soprano";
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Ring after the last note ends, in seconds. */
|
|
766
|
+
export function singTailSeconds(sing: TrackSing | undefined): number {
|
|
767
|
+
return resolveSing(sing).release + 0.05;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Whether a track sings: instrument `sing` with a `sing` field. */
|
|
771
|
+
export function singTrack(
|
|
772
|
+
track: Readonly<{ instrument: string; sing?: unknown }> | undefined,
|
|
773
|
+
): boolean {
|
|
774
|
+
return (
|
|
775
|
+
track?.instrument === SING_INSTRUMENT &&
|
|
776
|
+
typeof track.sing === "object" &&
|
|
777
|
+
track.sing !== null
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Whether a track sings in throat mode (a drone): play mode is mono. */
|
|
782
|
+
export function singThroat(
|
|
783
|
+
track: Readonly<{ instrument: string; sing?: TrackSing }> | undefined,
|
|
784
|
+
): boolean {
|
|
785
|
+
return singTrack(track) && resolveSing(track!.sing).drone !== undefined;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Whether the drone that plays is the preset's, moved to the song key. */
|
|
789
|
+
export function singDroneFollows(
|
|
790
|
+
sing: TrackSing | undefined,
|
|
791
|
+
keyRoot?: number,
|
|
792
|
+
): boolean {
|
|
793
|
+
return (
|
|
794
|
+
keyRoot !== undefined &&
|
|
795
|
+
sing?.drone === undefined &&
|
|
796
|
+
SING_PRESETS[singPresetOf(sing)].settings.drone !== undefined
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The one-line summary the prompt and menu show. */
|
|
801
|
+
export function singSummary(
|
|
802
|
+
sing: TrackSing | undefined,
|
|
803
|
+
keyRoot?: number,
|
|
804
|
+
): string {
|
|
805
|
+
const s = resolveSing(sing, keyRoot);
|
|
806
|
+
const parts = [singPresetOf(sing), `vowel ${s.vowel}`];
|
|
807
|
+
if (s.voices > 1) parts.push(`${s.voices} voices`);
|
|
808
|
+
if (s.drone !== undefined)
|
|
809
|
+
parts.push(
|
|
810
|
+
`drone ${singNoteName(s.drone)}${singDroneFollows(sing, keyRoot) ? " (key)" : ""}`,
|
|
811
|
+
);
|
|
812
|
+
if (s.formant !== 0)
|
|
813
|
+
parts.push(`formant ${s.formant > 0 ? "+" : ""}${s.formant}`);
|
|
814
|
+
return parts.join(" · ");
|
|
815
|
+
}
|