@hraness/dawg 0.6.0 → 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.
Files changed (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  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/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  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 +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -10,7 +10,7 @@
10
10
  * right impulses share one transform: G = FFT(hL + i·hR), and because the
11
11
  * input is real, Re(IFFT(X·G)) is the left output and Im(…) the right.
12
12
  *
13
- * Built-in impulses (`builtin:room`, `builtin:hall`, `builtin:plate`) are
13
+ * Built-in impulses (`builtin:room`, `hall`, `plate`, `reverse`, `gate`, `spring`) are
14
14
  * generated from a seeded integer PRNG, so they need no files and are
15
15
  * identical on every render path. Everything here is plain float64
16
16
  * arithmetic over fixed loops: deterministic. Clean-room, from standard DSP
@@ -79,10 +79,13 @@ export function fftInPlace(
79
79
  for (let length = 2; length <= n; length <<= 1) {
80
80
  const half = length >> 1;
81
81
  const stride = n / length;
82
- for (let k = 0; k < half; k += 1) {
83
- const wr = cos[k * stride]!;
84
- const wi = sign * sin[k * stride]!;
85
- for (let a = k; a < n; a += length) {
82
+ // Butterfly groups outer, twiddles inner: memory is walked in order
83
+ // (the same operations as twiddle-outer, so the same bits).
84
+ for (let start = 0; start < n; start += length) {
85
+ for (let k = 0; k < half; k += 1) {
86
+ const wr = cos[k * stride]!;
87
+ const wi = sign * sin[k * stride]!;
88
+ const a = start + k;
86
89
  const b = a + half;
87
90
  const xr = re[b]! * wr - im[b]! * wi;
88
91
  const xi = re[b]! * wi + im[b]! * wr;
@@ -236,6 +239,31 @@ export const BUILTIN_IMPULSES = Object.freeze({
236
239
  attack: 0.06,
237
240
  },
238
241
  plate: { title: "plate", seconds: 1.8, damp: 9000, early: 0, attack: 0 },
242
+ // 0.6.1 shaped impulses (`shape`, see `shapedSide`).
243
+ reverse: {
244
+ title: "reverse",
245
+ seconds: 1.2,
246
+ damp: 7000,
247
+ early: 0,
248
+ attack: 0,
249
+ shape: "rise",
250
+ },
251
+ gate: {
252
+ title: "gated",
253
+ seconds: 0.45,
254
+ damp: 8000,
255
+ early: 6,
256
+ attack: 0.005,
257
+ shape: "gate",
258
+ },
259
+ spring: {
260
+ title: "spring tank",
261
+ seconds: 2.2,
262
+ damp: 4500,
263
+ early: 0,
264
+ attack: 0,
265
+ shape: "spring",
266
+ },
239
267
  } as const);
240
268
  export type BuiltinImpulse = keyof typeof BUILTIN_IMPULSES;
241
269
 
@@ -259,6 +287,15 @@ export function builtinImpulse(
259
287
  if (cached) return cached;
260
288
  const spec = BUILTIN_IMPULSES[name as BuiltinImpulse];
261
289
  const length = Math.max(1, Math.round(spec.seconds * sampleRate));
290
+ if ("shape" in spec) {
291
+ const impulse = normalizeImpulse(
292
+ `builtin:${name}`,
293
+ shapedSide(name, "L", spec, sampleRate),
294
+ shapedSide(name, "R", spec, sampleRate),
295
+ );
296
+ BUILTIN_CACHE.set(key, impulse);
297
+ return impulse;
298
+ }
262
299
  const sides = ["L", "R"].map((side) => {
263
300
  const random = seededRandom(`dawg-ir:${name}:${side}`);
264
301
  const out = new Float64Array(length);
@@ -289,6 +326,77 @@ export function builtinImpulse(
289
326
  return impulse;
290
327
  }
291
328
 
329
+ /**
330
+ * One side of a shaped built-in (0.6.1), all seeded noise through a
331
+ * one-pole low-pass at `damp`:
332
+ * - rise: the Yamaha SPX90 "reverse gate": energy rises exponentially to a
333
+ * hard cut at `seconds`, so the wash swells into the cut (a reverse
334
+ * reverb that stays causal and works in play mode).
335
+ * - gate: dense flat tail cut hard at `seconds` (the 1980s gated snare).
336
+ * - spring: a decaying noise tail plus a train of dispersive chirps every
337
+ * ~33 ms (high frequencies arrive first, the drip of a spring tank).
338
+ */
339
+ function shapedSide(
340
+ name: string,
341
+ side: string,
342
+ spec: Readonly<{
343
+ seconds: number;
344
+ damp: number;
345
+ early: number;
346
+ attack: number;
347
+ shape: string;
348
+ }>,
349
+ sampleRate: number,
350
+ ): Float64Array {
351
+ const random = seededRandom(`dawg-ir:${name}:${side}`);
352
+ const length = Math.max(16, Math.round(spec.seconds * sampleRate));
353
+ const out = new Float64Array(length);
354
+ const coefficient =
355
+ 1 -
356
+ Math.exp(
357
+ (-2 * Math.PI * Math.min(spec.damp, sampleRate * 0.45)) / sampleRate,
358
+ );
359
+ const fadeOut = Math.min(length >> 2, Math.round(0.003 * sampleRate));
360
+ let state = 0;
361
+ for (let index = 0; index < length; index += 1) {
362
+ const x = (index + 0.5) / length;
363
+ const t = index / sampleRate;
364
+ state += coefficient * (random() * 2 - 1 - state);
365
+ let env: number;
366
+ if (spec.shape === "rise") env = Math.exp(4 * (x - 1));
367
+ else if (spec.shape === "gate")
368
+ env = Math.min(1, t / Math.max(1e-4, spec.attack)) * (1 - 0.3 * x);
369
+ else env = 0.35 * Math.exp((-Math.log(1000) * t) / spec.seconds);
370
+ // A short fade into the cut, so it clicks no more than a real gate.
371
+ if (spec.shape !== "spring" && index >= length - fadeOut)
372
+ env *= (length - index) / fadeOut;
373
+ out[index] = state * env;
374
+ }
375
+ for (let tap = 0; tap < spec.early; tap += 1) {
376
+ const at = Math.floor((0.004 + random() * 0.036) * sampleRate);
377
+ if (at < length) out[at]! += (random() < 0.5 ? -1 : 1) * (0.5 - tap * 0.05);
378
+ }
379
+ if (spec.shape === "spring") {
380
+ // Dispersive chirps: 4 kHz falling to 300 Hz over 25 ms, each echo
381
+ // -1.6 dB down on the last, the period jittered per side.
382
+ const period = (0.031 + 0.004 * random()) * sampleRate;
383
+ const chirp = 0.025;
384
+ const decay = Math.log(1000) / spec.seconds;
385
+ for (let echo = 0; echo * period < length; echo += 1) {
386
+ const start = Math.round(echo * period);
387
+ const level = Math.exp((-decay * start) / sampleRate);
388
+ let phase = random() * 2 * Math.PI;
389
+ for (let k = 0; k < chirp * sampleRate && start + k < length; k += 1) {
390
+ const u = k / (chirp * sampleRate);
391
+ const hz = 4000 * (300 / 4000) ** u;
392
+ phase += (2 * Math.PI * hz) / sampleRate;
393
+ out[start + k]! += level * Math.sin(phase) * Math.sin(Math.PI * u);
394
+ }
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+
292
400
  /**
293
401
  * An impulse from a decoded sample (mono, any rate): resampled linearly to
294
402
  * the render rate, cut to `MAX_IMPULSE_SECONDS`, energy-normalized; both
@@ -5,10 +5,12 @@
5
5
  */
6
6
  import { SCORE_LIMITS, type Track } from "../../../core/score.ts";
7
7
  import type { FxValues } from "../../../core/fx.ts";
8
+ import { morphFormants, VOWEL_FORMANTS } from "../dsp/formant.ts";
8
9
  import {
9
10
  Biquad,
10
11
  CONTROL_SAMPLES,
11
12
  OnePole,
13
+ Param,
12
14
  Phasor,
13
15
  clamp,
14
16
  fxReader,
@@ -123,16 +125,26 @@ export function applyTrackFilter(
123
125
  }
124
126
  }
125
127
 
126
- /** Cutoff for a DJ-filter position: 0 → 20 Hz low-pass … 1 → 20 kHz high-pass. */
128
+ /**
129
+ * Where the DJ filter's high-pass stops rising, as on a DJ mixer: a 20 kHz
130
+ * high-pass at 44.1 kHz left nothing (q08), so the top of the knob is thin.
131
+ * The curve below about 0.95 is unchanged.
132
+ */
133
+ const DJ_HPF_MAX_HZ = 10_000;
134
+
135
+ /** Cutoff for a DJ-filter position: 0 → 20 Hz low-pass … 1 → 10 kHz high-pass. */
127
136
  function djCutoff(value: number): { type: FilterType; cutoff: number } {
128
137
  return value < 0.5
129
138
  ? { type: "lpf", cutoff: 20 * 1000 ** (value / 0.5) }
130
- : { type: "hpf", cutoff: 20 * 1000 ** ((value - 0.5) / 0.5) };
139
+ : {
140
+ type: "hpf",
141
+ cutoff: Math.min(DJ_HPF_MAX_HZ, 20 * 1000 ** ((value - 0.5) / 0.5)),
142
+ };
131
143
  }
132
144
 
133
145
  /**
134
146
  * One-knob DJ filter (Strudel `djf`): below 0.5 a low-pass closes toward
135
- * 20 Hz, above 0.5 a high-pass rises toward 20 kHz; 0.5 passes unchanged.
147
+ * 20 Hz, above 0.5 a high-pass rises toward 10 kHz; 0.5 passes unchanged.
136
148
  */
137
149
  export function applyDjFilter(
138
150
  buffer: Float64Array,
@@ -223,72 +235,6 @@ export function applyAutoFilter(
223
235
  }
224
236
  }
225
237
 
226
- type Formants = readonly (readonly [number, number, number])[];
227
-
228
- /**
229
- * Formant frequency (Hz), level (dB) and bandwidth (Hz). The five plain
230
- * vowels use the classic tenor formant table (published acoustic
231
- * phonetics data); the extended set approximates F1–F3 from standard
232
- * vowel charts with tenor-typical F4/F5.
233
- */
234
- const VOWEL_FORMANTS: Readonly<Record<string, Formants>> = Object.freeze({
235
- a: [
236
- [650, 0, 80],
237
- [1080, -6, 90],
238
- [2650, -7, 120],
239
- [2900, -8, 130],
240
- [3250, -22, 140],
241
- ],
242
- e: [
243
- [400, 0, 70],
244
- [1700, -14, 80],
245
- [2600, -12, 100],
246
- [3200, -14, 120],
247
- [3580, -20, 120],
248
- ],
249
- i: [
250
- [290, 0, 40],
251
- [1870, -15, 90],
252
- [2800, -18, 100],
253
- [3250, -20, 120],
254
- [3540, -30, 120],
255
- ],
256
- o: [
257
- [400, 0, 40],
258
- [800, -10, 80],
259
- [2600, -12, 100],
260
- [2800, -12, 120],
261
- [3000, -26, 120],
262
- ],
263
- u: [
264
- [350, 0, 40],
265
- [600, -20, 60],
266
- [2700, -17, 100],
267
- [2900, -14, 120],
268
- [3300, -26, 120],
269
- ],
270
- ae: extended(660, 1720, 2410),
271
- aa: extended(710, 1100, 2540),
272
- oe: extended(390, 1680, 2400),
273
- ue: extended(300, 1600, 2200),
274
- y: extended(250, 1750, 2160),
275
- uh: extended(600, 1170, 2390),
276
- un: extended(500, 1400, 2500),
277
- en: extended(550, 1650, 2500),
278
- an: extended(650, 1050, 2550),
279
- on: extended(450, 850, 2550),
280
- });
281
-
282
- function extended(f1: number, f2: number, f3: number): Formants {
283
- return [
284
- [f1, 0, 70],
285
- [f2, -8, 90],
286
- [f3, -14, 110],
287
- [3300, -16, 130],
288
- [3750, -24, 140],
289
- ];
290
- }
291
-
292
238
  /** Gain that brings the summed formant bank near unity for speech-band input. */
293
239
  const VOWEL_GAIN = 2.2;
294
240
 
@@ -307,11 +253,44 @@ export function applyVowel(
307
253
  filter.set("bpf", frequency, frequency / bandwidth, context.sampleRate);
308
254
  return { filter, gain: 10 ** (level / 20) };
309
255
  });
256
+ // 0.7 morph: present only with `to`; absent keeps the static bank exactly.
257
+ const toName = values.to as string | undefined;
258
+ const to = toName === undefined ? undefined : VOWEL_FORMANTS[toName];
259
+ // Absent morph means 0, so a vowel-morph lane still sweeps without it.
260
+ const morph = to
261
+ ? new Param(
262
+ (values.morph as number | undefined) ?? 0,
263
+ track.fxAutomation?.["vowel-morph"],
264
+ context.samplesPerTick,
265
+ context.warp,
266
+ )
267
+ : undefined;
268
+ const moving = morph !== undefined && (morph.automated || morph.fallback > 0);
269
+ let at = -1;
270
+ const retune = (position: number) => {
271
+ if (position === at) return;
272
+ at = position;
273
+ morphFormants(formants, to!, position).forEach(
274
+ ([frequency, level, bandwidth], index) => {
275
+ const band = bank[index]!;
276
+ band.filter.set(
277
+ "bpf",
278
+ frequency,
279
+ frequency / bandwidth,
280
+ context.sampleRate,
281
+ );
282
+ band.gain = 10 ** (level / 20);
283
+ },
284
+ );
285
+ };
286
+ if (moving) retune(morph.at(0));
310
287
  // Formant peaks are narrow; a gentle pre-emphasis keeps the vowel bright.
311
288
  const tilt = new OnePole(8000, context.sampleRate);
312
289
  let wet = mix.at(0);
313
290
  for (let index = 0; index < buffer.length; index += 1) {
314
291
  if (mix.automated && index % CONTROL_SAMPLES === 0) wet = mix.at(index);
292
+ if (moving && morph.automated && index % CONTROL_SAMPLES === 0)
293
+ retune(morph.at(index));
315
294
  const input = buffer[index]!;
316
295
  const emphasized = input + 0.5 * (input - tilt.process(input));
317
296
  let sum = 0;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Formant shift at constant pitch (0.7 `fx.formant`): the throat or gender
3
+ * knob. Per STFT frame the cepstral spectral envelope E is taken and each
4
+ * bin is scaled by E(k / a) / E(k), a = 2^(shift / 12), with its phase left
5
+ * untouched, then the frames overlap-add. The pitch never moves, so no
6
+ * phase vocoder is needed (Röbel and Rodet, "Efficient spectral envelope
7
+ * estimation and its application to pitch shifting and envelope
8
+ * preservation", DAFx 2005; Little AlterBoy and VocalSynth's formant knob).
9
+ *
10
+ * The lifter adapts to the voice: 0.75 periods of a 5-frame median
11
+ * autocorrelation f0, clamped to 1-2 ms, 1 ms when unvoiced. A fixed 2 ms
12
+ * lifter resolves single harmonics above about 500 Hz and makes high
13
+ * voices worse than no processing (design formant.md section 2).
14
+ *
15
+ * Frames sit at multiples of the hop in absolute track samples (an
16
+ * arranged window passes its origin as `seedSeconds`), and a window padded
17
+ * back by `FORMANT_PAD` samples and forward by one frame equals the same
18
+ * span of a full render bit for bit.
19
+ */
20
+ import type { FxValues } from "../../../core/fx.ts";
21
+ import type { Track } from "../../../core/score.ts";
22
+ import { cepstralEnvelope, envelopeScratch } from "../dsp/envelope.ts";
23
+ import { fftInPlace } from "../dsp/fft.ts";
24
+ import { hann, HANN_SQUARED_OLA_QUARTER } from "../dsp/stft.ts";
25
+ import { fxReader, type EffectContext } from "./common.ts";
26
+
27
+ /** Per-bin gain clamp (24 dB), as shift.ts holds formants. */
28
+ const MAX_GAIN = 10 ** (24 / 20);
29
+ /** Lifter periods and clamps (design formant.md section 2). */
30
+ const LIFTER_PERIODS = 0.75;
31
+ const LIFTER_MIN_SECONDS = 0.001;
32
+ const LIFTER_MAX_SECONDS = 0.002;
33
+ /** Frames in the f0 median (this one and the four before it). */
34
+ const F0_MEDIAN = 5;
35
+ const F0_MIN_HZ = 60;
36
+ const F0_MAX_HZ = 1500;
37
+ /** Below this normalized autocorrelation a frame counts as unvoiced. */
38
+ const VOICED = 0.45;
39
+ /** The first peak within this fraction of the best is the period. */
40
+ const FIRST_PEAK = 0.85;
41
+
42
+ /** Frame length: 1024 at or below 24 kHz, 2048 above. */
43
+ export function formantFrame(sampleRate: number): number {
44
+ return sampleRate > 24_000 ? 2048 : 1024;
45
+ }
46
+
47
+ /**
48
+ * Samples a window render must start before its first output sample (4
49
+ * hops for the f0 median plus one frame), and it renders one frame past
50
+ * its last.
51
+ */
52
+ export function formantPad(sampleRate: number): number {
53
+ const n = formantFrame(sampleRate);
54
+ return n + 4 * (n / 4);
55
+ }
56
+
57
+ export type FormantShiftOptions = Readonly<{
58
+ /** Wet amount 0..1 (1: fully shifted). */
59
+ mix?: number | ((index: number) => number);
60
+ /** Absolute track sample of `x[0]`; frames anchor at hop multiples of it. */
61
+ origin?: number;
62
+ }>;
63
+
64
+ type Analysis = {
65
+ n: number;
66
+ hop: number;
67
+ win: Float64Array;
68
+ wac: Float64Array;
69
+ minLag: number;
70
+ maxLag: number;
71
+ };
72
+
73
+ const analyses = new Map<number, Analysis>();
74
+
75
+ function analysisFor(sampleRate: number): Analysis {
76
+ let a = analyses.get(sampleRate);
77
+ if (a) return a;
78
+ const n = formantFrame(sampleRate);
79
+ const win = hann(n);
80
+ const minLag = Math.floor(sampleRate / F0_MAX_HZ);
81
+ const maxLag = Math.min(n / 2 - 1, Math.ceil(sampleRate / F0_MIN_HZ));
82
+ // The window's own autocorrelation, divided out so long lags are not
83
+ // biased down (Boersma 1993).
84
+ const wac = new Float64Array(maxLag + 1);
85
+ for (let t = 0; t <= maxLag; t += 1) {
86
+ let sum = 0;
87
+ for (let i = 0; i + t < n; i += 1) sum += win[i]! * win[i + t]!;
88
+ wac[t] = sum;
89
+ }
90
+ a = { n, hop: n / 4, win, wac, minLag, maxLag };
91
+ analyses.set(sampleRate, a);
92
+ return a;
93
+ }
94
+
95
+ /** One frame's f0 from its power spectrum (0: unvoiced). */
96
+ function frameF0(
97
+ mag: Float64Array,
98
+ a: Analysis,
99
+ sampleRate: number,
100
+ re: Float64Array,
101
+ im: Float64Array,
102
+ ): number {
103
+ const { n, wac, minLag, maxLag } = a;
104
+ const half = n / 2;
105
+ for (let k = 0; k <= half; k += 1) {
106
+ re[k] = mag[k]! * mag[k]!;
107
+ im[k] = 0;
108
+ if (k > 0 && k < half) {
109
+ re[n - k] = re[k]!;
110
+ im[n - k] = 0;
111
+ }
112
+ }
113
+ fftInPlace(re, im, true);
114
+ const r0 = re[0]!;
115
+ if (r0 <= 1e-12) return 0;
116
+ const w0 = wac[0]! / r0;
117
+ for (let t = minLag - 1; t <= maxLag; t += 1) re[t] = (re[t]! * w0) / wac[t]!;
118
+ let best = 0;
119
+ for (let t = minLag; t <= maxLag; t += 1) best = Math.max(best, re[t]!);
120
+ if (best < VOICED) return 0;
121
+ for (let t = minLag + 1; t < maxLag; t += 1) {
122
+ const v = re[t]!;
123
+ if (v >= FIRST_PEAK * best && v >= re[t - 1]! && v >= re[t + 1]!) {
124
+ const l = re[t - 1]!;
125
+ const r = re[t + 1]!;
126
+ const d = l - 2 * v + r;
127
+ const offset = d < 0 ? (0.5 * (l - r)) / d : 0;
128
+ return sampleRate / (t + offset);
129
+ }
130
+ }
131
+ return 0;
132
+ }
133
+
134
+ /**
135
+ * Shift the formants of `x` by `shift` semitones (a number, or a function
136
+ * of the buffer index read once per hop) at constant pitch. Returns a new
137
+ * buffer; `shift` 0 everywhere returns a copy of `x`.
138
+ */
139
+ export function formantShift(
140
+ x: Float64Array,
141
+ sampleRate: number,
142
+ shift: number | ((index: number) => number),
143
+ options: FormantShiftOptions = {},
144
+ ): Float64Array {
145
+ if (shift === 0) return Float64Array.from(x);
146
+ const a = analysisFor(sampleRate);
147
+ const { n, hop, win } = a;
148
+ const half = n / 2;
149
+ const origin = Math.round(options.origin ?? 0);
150
+ const out = new Float64Array(x.length);
151
+ const re = new Float64Array(n);
152
+ const im = new Float64Array(n);
153
+ const acRe = new Float64Array(n);
154
+ const acIm = new Float64Array(n);
155
+ const mag = new Float64Array(half + 1);
156
+ const env = new Float64Array(half + 1);
157
+ const scratch = envelopeScratch(n);
158
+ const history = new Float64Array(F0_MEDIAN);
159
+ const sorted = new Float64Array(F0_MEDIAN);
160
+ let frames = 0;
161
+ const ola = 1 / HANN_SQUARED_OLA_QUARTER;
162
+ const auto = typeof shift === "function";
163
+ // First frame: the latest hop multiple (absolute) whose frame still
164
+ // reaches x[0].
165
+ const firstAbs = Math.floor((origin - n + 1 + (hop - 1)) / hop) * hop;
166
+ for (let start = firstAbs - origin; start < x.length; start += hop) {
167
+ const st = auto ? shift(Math.max(0, start + half)) : shift;
168
+ for (let i = 0; i < n; i += 1) {
169
+ const j = start + i;
170
+ re[i] = j >= 0 && j < x.length ? x[j]! * win[i]! : 0;
171
+ im[i] = 0;
172
+ }
173
+ fftInPlace(re, im);
174
+ for (let k = 0; k <= half; k += 1) mag[k] = Math.hypot(re[k]!, im[k]!);
175
+ // Every frame joins the f0 history, so a window and the full render
176
+ // agree whatever the shift was.
177
+ history[frames % F0_MEDIAN] = frameF0(mag, a, sampleRate, acRe, acIm);
178
+ frames += 1;
179
+ if (st === 0) {
180
+ fftInPlace(re, im, true);
181
+ } else {
182
+ const m = Math.min(frames, F0_MEDIAN);
183
+ for (let i = 0; i < m; i += 1) sorted[i] = history[i]!;
184
+ for (let i = 1; i < m; i += 1)
185
+ for (let j = i; j > 0 && sorted[j - 1]! > sorted[j]!; j -= 1) {
186
+ const tmp = sorted[j]!;
187
+ sorted[j] = sorted[j - 1]!;
188
+ sorted[j - 1] = tmp;
189
+ }
190
+ const f0 = sorted[m >> 1]!;
191
+ const seconds =
192
+ f0 > 0
193
+ ? Math.min(
194
+ LIFTER_MAX_SECONDS,
195
+ Math.max(LIFTER_MIN_SECONDS, LIFTER_PERIODS / f0),
196
+ )
197
+ : LIFTER_MIN_SECONDS;
198
+ const lifter = Math.max(4, Math.round(seconds * sampleRate));
199
+ cepstralEnvelope(mag, n, lifter, env, scratch);
200
+ const ratio = 2 ** (-st / 12);
201
+ for (let k = 0; k <= half; k += 1) {
202
+ const source = k * ratio;
203
+ const i0 = Math.floor(source);
204
+ const f = source - i0;
205
+ const e =
206
+ i0 >= half
207
+ ? env[half]!
208
+ : env[i0]! + (env[Math.min(half, i0 + 1)]! - env[i0]!) * f;
209
+ const g = Math.min(
210
+ MAX_GAIN,
211
+ Math.max(1 / MAX_GAIN, e / Math.max(env[k]!, 1e-12)),
212
+ );
213
+ re[k] = re[k]! * g;
214
+ im[k] = im[k]! * g;
215
+ if (k > 0 && k < half) {
216
+ re[n - k] = re[k]!;
217
+ im[n - k] = -im[k]!;
218
+ }
219
+ }
220
+ fftInPlace(re, im, true);
221
+ }
222
+ for (let i = 0; i < n; i += 1) {
223
+ const j = start + i;
224
+ if (j >= 0 && j < x.length)
225
+ out[j] = out[j]! + (re[i]! / n) * win[i]! * ola;
226
+ }
227
+ }
228
+ const mix = options.mix ?? 1;
229
+ if (typeof mix === "function")
230
+ for (let i = 0; i < x.length; i += 1)
231
+ out[i] = x[i]! + (out[i]! - x[i]!) * mix(i);
232
+ else if (mix < 1)
233
+ for (let i = 0; i < x.length; i += 1)
234
+ out[i] = x[i]! + (out[i]! - x[i]!) * mix;
235
+ return out;
236
+ }
237
+
238
+ /** `fx.formant`: formant shift with mix, both automatable. */
239
+ export function applyFormant(
240
+ buffer: Float64Array,
241
+ track: Track,
242
+ values: FxValues,
243
+ context: EffectContext,
244
+ ): void {
245
+ const read = fxReader(track, "formant", values, context);
246
+ const shift = read.param("shift");
247
+ const mix = read.param("mix");
248
+ if (!shift.automated && shift.fallback === 0) return;
249
+ if (!mix.automated && mix.fallback <= 0) return;
250
+ const origin = context.seedSeconds
251
+ ? Math.round(context.seedSeconds * context.sampleRate)
252
+ : 0;
253
+ const out = formantShift(
254
+ buffer,
255
+ context.sampleRate,
256
+ shift.automated ? (index) => shift.at(index) : shift.fallback,
257
+ {
258
+ origin,
259
+ mix: mix.automated ? (index) => mix.at(index) : mix.fallback,
260
+ },
261
+ );
262
+ buffer.set(out);
263
+ }