@hraness/dawg 0.6.1 → 0.8.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.
Files changed (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
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
+ }