@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
@@ -0,0 +1,1119 @@
1
+ /**
2
+ * Pitch correction (0.7 `Track.autotune`): from gentle to hard-tuned.
3
+ *
4
+ * A tracked pitch curve (src/audio/dsp/pitch.ts) becomes a shift in cents
5
+ * per analysis frame, which a PSOLA pass (src/audio/dsp/psola.ts) applies
6
+ * with the formants kept. Two target sources:
7
+ *
8
+ * - a grid (`to: scale | chromatic | chord`): pitches in cents re A440,
9
+ * chosen per note with hysteresis, Auto-Tune style. `speed` is the retune
10
+ * time constant of a one-pole slew on the correction, so a slow speed
11
+ * corrects the note centre and lets vibrato and scoops through while
12
+ * speed 0 snaps (the stepped hard-tune sound). `relax` slows the retune
13
+ * on sustained notes (Auto-Tune Humanize), `flex` narrows the capture
14
+ * zone so only notes already near a target are pulled (Flex-Tune).
15
+ * - guide notes (`to: notes`): each frame's target is the note sounding
16
+ * then. Without `speed` it is Melodyne-style (`center` moves each note's
17
+ * mean to the written pitch, `drift` is the share of slow drift removed);
18
+ * with `speed` it is the grid retune locked to the melody (`locked`).
19
+ *
20
+ * Everything here is pure and deterministic. The render hooks build the
21
+ * targets (tuning table, key, chord timeline, guide notes through the tempo
22
+ * map) and call `autotuneBuffer`. Accuracy and cost are measured in
23
+ * .plans/dawg-07/pitch.md section 3.
24
+ */
25
+ import { parseKey, scaleSteps } from "./chords.ts";
26
+ import { FxValidationError } from "./params.ts";
27
+ import { ratioCents, type TuningTable } from "./tuning.ts";
28
+
29
+ /** Bumped when correction output changes for the same inputs. */
30
+ export const AUTOTUNE_VERSION = 1;
31
+
32
+ export const AUTOTUNE_PRESETS = [
33
+ "hard",
34
+ "robot",
35
+ "warble",
36
+ "trap",
37
+ "pop",
38
+ "natural",
39
+ "gentle",
40
+ "guided",
41
+ "locked",
42
+ ] as const;
43
+ export type AutotunePreset = (typeof AUTOTUNE_PRESETS)[number];
44
+
45
+ export const AUTOTUNE_TARGETS = [
46
+ "scale",
47
+ "chromatic",
48
+ "chord",
49
+ "notes",
50
+ ] as const;
51
+ export type AutotuneTarget = (typeof AUTOTUNE_TARGETS)[number];
52
+
53
+ export const AUTOTUNE_VOICES = [
54
+ "auto",
55
+ "bass",
56
+ "tenor",
57
+ "alto",
58
+ "soprano",
59
+ ] as const;
60
+ export type AutotuneVoice = (typeof AUTOTUNE_VOICES)[number];
61
+
62
+ /** Track.autotune: a preset and/or fields; explicit fields override it. */
63
+ export type TrackAutotune = Readonly<{
64
+ preset?: AutotunePreset;
65
+ /** Target source (default `scale`). */
66
+ to?: AutotuneTarget;
67
+ /** Guide track id for `to: notes`; omitted = the track's own notes. */
68
+ from?: string;
69
+ /** Scale override, anything `parseKey` reads (`D bayati`). */
70
+ key?: string;
71
+ /** Retune time constant in ms, 0..400; 0 is instant. */
72
+ speed?: number;
73
+ /** 0..1 slower retune on sustained notes. */
74
+ relax?: number;
75
+ /** ms 50..1000 before a note counts as sustained. */
76
+ hold?: number;
77
+ /** 0..100 Flex-Tune: capture zone 50·(1 − flex/100) cents. */
78
+ flex?: number;
79
+ /** Seconds 0..0.5 to move between targets. */
80
+ glide?: number;
81
+ /** 0..1 correction strength. */
82
+ amount?: number;
83
+ /** Added vibrato rate in Hz, 0..12 (0 off). */
84
+ vib?: number;
85
+ /** Added vibrato depth in semitones, 0..1 (0.2 when vib is on). */
86
+ vibmod?: number;
87
+ /** Note mode without speed: 0..1 how far each note's mean moves. */
88
+ center?: number;
89
+ /** Note mode without speed: 0..1 share of slow drift removed. */
90
+ drift?: number;
91
+ /** Tracker range. */
92
+ voice?: AutotuneVoice;
93
+ }>;
94
+
95
+ /** Every field filled: the preset applied, then the explicit fields. */
96
+ export type ResolvedAutotune = Readonly<{
97
+ to: AutotuneTarget;
98
+ from?: string;
99
+ key?: string;
100
+ /** Undefined in note mode means center/drift (Melodyne-style). */
101
+ speed?: number;
102
+ relax: number;
103
+ hold: number;
104
+ flex: number;
105
+ glide: number;
106
+ amount: number;
107
+ vib: number;
108
+ vibmod: number;
109
+ center: number;
110
+ drift: number;
111
+ voice: AutotuneVoice;
112
+ }>;
113
+
114
+ export type AutotuneNumberField =
115
+ | "speed"
116
+ | "relax"
117
+ | "hold"
118
+ | "flex"
119
+ | "glide"
120
+ | "amount"
121
+ | "vib"
122
+ | "vibmod"
123
+ | "center"
124
+ | "drift";
125
+
126
+ export type AutotuneParam = Readonly<{
127
+ name: AutotuneNumberField;
128
+ min: number;
129
+ max: number;
130
+ def: number;
131
+ /** Step for menu left/right. */
132
+ step: number;
133
+ unit: string;
134
+ label: string;
135
+ help: string;
136
+ }>;
137
+
138
+ /** Numeric fields with ranges, defaults (= pop) and units, in type order. */
139
+ export const AUTOTUNE_PARAMS: readonly AutotuneParam[] = Object.freeze([
140
+ {
141
+ name: "speed",
142
+ min: 0,
143
+ max: 400,
144
+ def: 25,
145
+ step: 5,
146
+ unit: "ms",
147
+ label: "Speed",
148
+ help: "retune time; 0 is instant and stepped",
149
+ },
150
+ {
151
+ name: "relax",
152
+ min: 0,
153
+ max: 1,
154
+ def: 0.3,
155
+ step: 0.05,
156
+ unit: "",
157
+ label: "Relax",
158
+ help: "slower retune on held notes",
159
+ },
160
+ {
161
+ name: "hold",
162
+ min: 50,
163
+ max: 1000,
164
+ def: 150,
165
+ step: 10,
166
+ unit: "ms",
167
+ label: "Hold",
168
+ help: "when a note counts as held, for relax",
169
+ },
170
+ {
171
+ name: "flex",
172
+ min: 0,
173
+ max: 100,
174
+ def: 0,
175
+ step: 5,
176
+ unit: "",
177
+ label: "Flex",
178
+ help: "higher only pulls notes already near a target",
179
+ },
180
+ {
181
+ name: "glide",
182
+ min: 0,
183
+ max: 0.5,
184
+ def: 0,
185
+ step: 0.01,
186
+ unit: "s",
187
+ label: "Glide",
188
+ help: "time to move between targets",
189
+ },
190
+ {
191
+ name: "amount",
192
+ min: 0,
193
+ max: 1,
194
+ def: 1,
195
+ step: 0.05,
196
+ unit: "",
197
+ label: "Amount",
198
+ help: "correction strength",
199
+ },
200
+ {
201
+ name: "vib",
202
+ min: 0,
203
+ max: 12,
204
+ def: 0,
205
+ step: 0.5,
206
+ unit: "Hz",
207
+ label: "Vib",
208
+ help: "added vibrato rate (0 off)",
209
+ },
210
+ {
211
+ name: "vibmod",
212
+ min: 0,
213
+ max: 1,
214
+ def: 0.2,
215
+ step: 0.05,
216
+ unit: "st",
217
+ label: "Vib depth",
218
+ help: "added vibrato depth",
219
+ },
220
+ {
221
+ name: "center",
222
+ min: 0,
223
+ max: 1,
224
+ def: 1,
225
+ step: 0.05,
226
+ unit: "",
227
+ label: "Center",
228
+ help: "notes: move each note's middle to the written pitch",
229
+ },
230
+ {
231
+ name: "drift",
232
+ min: 0,
233
+ max: 1,
234
+ def: 0.5,
235
+ step: 0.05,
236
+ unit: "",
237
+ label: "Drift",
238
+ help: "notes: share of slow drift removed",
239
+ },
240
+ ]);
241
+
242
+ export const AUTOTUNE_FIELDS = [
243
+ "preset",
244
+ "to",
245
+ "from",
246
+ "key",
247
+ "speed",
248
+ "relax",
249
+ "hold",
250
+ "flex",
251
+ "glide",
252
+ "amount",
253
+ "vib",
254
+ "vibmod",
255
+ "center",
256
+ "drift",
257
+ "voice",
258
+ ] as const satisfies readonly (keyof TrackAutotune)[];
259
+
260
+ /** Preset fields (pitch.md section 5). */
261
+ export const AUTOTUNE_PRESET_FIELDS: Readonly<
262
+ Record<AutotunePreset, TrackAutotune>
263
+ > = Object.freeze({
264
+ hard: { speed: 0, relax: 0, glide: 0 },
265
+ robot: { speed: 0, relax: 0, to: "chromatic" },
266
+ warble: { speed: 0, relax: 0, vib: 6.5, vibmod: 0.35 },
267
+ trap: { speed: 10, relax: 0.2 },
268
+ pop: { speed: 25, relax: 0.3 },
269
+ natural: { speed: 60, relax: 0.5, flex: 40 },
270
+ gentle: { speed: 120, relax: 0.6, flex: 20 },
271
+ guided: { to: "notes", center: 1, drift: 0.5, glide: 0.04 },
272
+ locked: { to: "notes", speed: 0, relax: 0 },
273
+ });
274
+
275
+ /** One line per preset for help and menus. */
276
+ export const AUTOTUNE_PRESET_HELP: Readonly<Record<AutotunePreset, string>> =
277
+ Object.freeze({
278
+ hard: "instant, stepped notes",
279
+ robot: "stepped on every step of the tuning",
280
+ warble: "hard with wide synthetic vibrato",
281
+ trap: "fast and glossy",
282
+ pop: "polished but sung",
283
+ natural: "keeps scoops and vibrato",
284
+ gentle: "barely there",
285
+ guided: "notes to a written melody, vibrato kept",
286
+ locked: "hard tune locked to a melody",
287
+ });
288
+
289
+ export function isAutotunePreset(value: unknown): value is AutotunePreset {
290
+ return (
291
+ typeof value === "string" &&
292
+ (AUTOTUNE_PRESETS as readonly string[]).includes(value)
293
+ );
294
+ }
295
+
296
+ function fail(message: string): never {
297
+ throw new FxValidationError(`autotune: ${message}`);
298
+ }
299
+
300
+ function word<T extends string>(
301
+ value: unknown,
302
+ words: readonly T[],
303
+ field: string,
304
+ ): T {
305
+ if (
306
+ typeof value !== "string" ||
307
+ !(words as readonly string[]).includes(value)
308
+ )
309
+ fail(`${field} must be one of ${words.join(", ")}`);
310
+ return value as T;
311
+ }
312
+
313
+ /**
314
+ * Validates Track.autotune. Without `from`, `to: notes` follows the
315
+ * track's own notes (any track). `null`/undefined → undefined.
316
+ * Ranges are checked, never clamped. `from` naming an existing track is
317
+ * checked at score level (core/score.ts).
318
+ */
319
+ export function normalizeAutotune(
320
+ input: unknown,
321
+ _instrument?: string,
322
+ ): TrackAutotune | undefined {
323
+ if (input === undefined || input === null) return undefined;
324
+ if (typeof input !== "object" || Array.isArray(input))
325
+ fail("must be an object");
326
+ const record = input as Readonly<Record<string, unknown>>;
327
+ for (const key of Object.keys(record))
328
+ if (!(AUTOTUNE_FIELDS as readonly string[]).includes(key))
329
+ fail(`unknown field ${key}`);
330
+ const out: Record<string, unknown> = {};
331
+ for (const field of AUTOTUNE_FIELDS) {
332
+ const value = record[field];
333
+ if (value === undefined) continue;
334
+ if (field === "preset") out.preset = word(value, AUTOTUNE_PRESETS, field);
335
+ else if (field === "to") out.to = word(value, AUTOTUNE_TARGETS, field);
336
+ else if (field === "voice") out.voice = word(value, AUTOTUNE_VOICES, field);
337
+ else if (field === "from") {
338
+ if (typeof value !== "string" || value.length === 0 || value.length > 64)
339
+ fail("from must be a track id");
340
+ out.from = value;
341
+ } else if (field === "key") {
342
+ if (typeof value !== "string" || parseKey(value) === undefined)
343
+ fail(`key ${JSON.stringify(value)} is not a key (try "A minor")`);
344
+ out.key = value;
345
+ } else {
346
+ const spec = AUTOTUNE_PARAMS.find((param) => param.name === field)!;
347
+ if (typeof value !== "number" || !Number.isFinite(value))
348
+ fail(`${field} must be a number`);
349
+ if (value < spec.min || value > spec.max)
350
+ fail(
351
+ `${field} must be ${spec.min}..${spec.max}${spec.unit ? ` ${spec.unit}` : ""}`,
352
+ );
353
+ out[field] = value;
354
+ }
355
+ }
356
+ if (Object.keys(out).length === 0) fail("give a preset or settings");
357
+ const to =
358
+ (out.to as AutotuneTarget | undefined) ??
359
+ (out.preset
360
+ ? AUTOTUNE_PRESET_FIELDS[out.preset as AutotunePreset].to
361
+ : undefined) ??
362
+ "scale";
363
+ if (out.from !== undefined && to !== "notes")
364
+ fail(`from needs to: notes (this one tunes to ${to})`);
365
+ const ordered: Record<string, unknown> = {};
366
+ for (const field of AUTOTUNE_FIELDS)
367
+ if (out[field] !== undefined) ordered[field] = out[field];
368
+ return Object.freeze(ordered) as TrackAutotune;
369
+ }
370
+
371
+ /** The preset (pop when none), then the explicit fields. */
372
+ export function resolveAutotune(input: TrackAutotune): ResolvedAutotune {
373
+ const base: TrackAutotune = AUTOTUNE_PRESET_FIELDS[input.preset ?? "pop"];
374
+ const pick = <K extends keyof TrackAutotune>(key: K) =>
375
+ input[key] !== undefined ? input[key] : base[key];
376
+ const to = pick("to") ?? "scale";
377
+ const def = (name: AutotuneNumberField) =>
378
+ (pick(name) as number | undefined) ??
379
+ AUTOTUNE_PARAMS.find((param) => param.name === name)!.def;
380
+ const vib = def("vib");
381
+ // Note mode keeps center/drift (Melodyne-style) unless speed is set by
382
+ // the user or the preset; the pop base speed does not count there.
383
+ const speed =
384
+ to === "notes"
385
+ ? (input.speed ?? (input.preset ? base.speed : undefined))
386
+ : def("speed");
387
+ const from = input.from;
388
+ const key = input.key;
389
+ return Object.freeze({
390
+ to,
391
+ ...(from !== undefined ? { from } : {}),
392
+ ...(key !== undefined ? { key } : {}),
393
+ ...(speed !== undefined ? { speed } : {}),
394
+ relax: def("relax"),
395
+ hold: def("hold"),
396
+ flex: def("flex"),
397
+ glide: def("glide"),
398
+ amount: def("amount"),
399
+ vib,
400
+ vibmod: vib > 0 ? def("vibmod") : 0,
401
+ center: def("center"),
402
+ drift: def("drift"),
403
+ voice: pick("voice") ?? "auto",
404
+ });
405
+ }
406
+
407
+ /** A stable digest of the resolved settings, for cache and stem keys. */
408
+ export function autotuneDigest(resolved: ResolvedAutotune): string {
409
+ return `at${AUTOTUNE_VERSION}:${JSON.stringify(resolved)}`;
410
+ }
411
+
412
+ /** The shortest description: `hard`, `pop · speed 35 ms`. */
413
+ export function describeAutotune(input: TrackAutotune): string {
414
+ const parts: string[] = [input.preset ?? "pop"];
415
+ for (const field of AUTOTUNE_FIELDS) {
416
+ if (field === "preset") continue;
417
+ const value = input[field];
418
+ if (value === undefined) continue;
419
+ const spec = AUTOTUNE_PARAMS.find((param) => param.name === field);
420
+ // glide is stored in seconds (like Track.glide) and shown in ms.
421
+ parts.push(
422
+ field === "glide"
423
+ ? `glide ${Math.round((value as number) * 1000)} ms`
424
+ : `${field} ${value}${spec?.unit ? ` ${spec.unit}` : ""}`.trimEnd(),
425
+ );
426
+ }
427
+ return parts.join(" · ");
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Targets
432
+
433
+ /**
434
+ * A pitch curve, structurally the pitch lane's `PitchCurve`: frame f is
435
+ * centred at t0 + f·hop seconds; `f0` Hz (0 unvoiced); `prob` and
436
+ * `aperiodic` in 1/255 steps.
437
+ */
438
+ export type AutotuneCurve = Readonly<{
439
+ t0: number;
440
+ hop: number;
441
+ f0: ArrayLike<number>;
442
+ prob: ArrayLike<number>;
443
+ aperiodic: ArrayLike<number>;
444
+ }>;
445
+
446
+ /** Target pitches in cents re A440, ascending (absolute, every octave). */
447
+ export type Grid = Readonly<{ points: Float64Array; minStep: number }>;
448
+
449
+ export const centsOfHz = (hz: number): number => 1200 * Math.log2(hz / 440);
450
+
451
+ function gridOf(points: number[]): Grid {
452
+ const sorted = [...new Set(points.map((p) => Math.round(p * 1e6) / 1e6))]
453
+ .filter(Number.isFinite)
454
+ .sort((a, b) => a - b);
455
+ let minStep = Infinity;
456
+ for (let i = 1; i < sorted.length; i += 1)
457
+ minStep = Math.min(minStep, sorted[i]! - sorted[i - 1]!);
458
+ return Object.freeze({
459
+ points: Float64Array.from(sorted),
460
+ minStep: Number.isFinite(minStep) ? minStep : 1200,
461
+ });
462
+ }
463
+
464
+ /** 12-TET A440 cents of a (possibly fractional) key through a table. */
465
+ function keyCents(table: TuningTable | undefined, key: number): number {
466
+ const at = (k: number) => {
467
+ const hz = table?.hz[k];
468
+ return hz && hz > 0 ? ratioCents(hz / 440) : (k - 69) * 100;
469
+ };
470
+ const base = Math.min(126, Math.max(0, Math.floor(key)));
471
+ const frac = key - base;
472
+ if (frac === 0) return at(base);
473
+ // A maqam or dastgah scale preset (core/tuning.ts scalePreset) already
474
+ // lowered the upper key to the quarter tone: read it there. Only an
475
+ // untuned pair of keys interpolates.
476
+ const gap = at(base + 1) - at(base);
477
+ if (table && Math.abs(gap - 100) > 25) return at(base + 1);
478
+ return at(base) + gap * frac;
479
+ }
480
+
481
+ /** Every mapped pitch of a tuning (12-TET A440 without one). */
482
+ export function chromaticGrid(table?: TuningTable): Grid {
483
+ const points: number[] = [];
484
+ for (let key = 0; key < 128; key += 1) {
485
+ if (table) {
486
+ const hz = table.hz[key]!;
487
+ if (hz > 0) points.push(ratioCents(hz / 440));
488
+ } else points.push((key - 69) * 100);
489
+ }
490
+ return gridOf(points);
491
+ }
492
+
493
+ /**
494
+ * The key's scale through a tuning: degree positions are table pitches of
495
+ * the scale steps (fractional steps interpolate, so quarter tones and a
496
+ * raga tuning's intonation apply). A table that is not 12 to the period
497
+ * (19-EDO, Bohlen-Pierce) has no 12-note scale, so every table pitch is a
498
+ * target. No key: chromatic.
499
+ */
500
+ export function scaleGrid(
501
+ keyText: string | null | undefined,
502
+ table?: TuningTable,
503
+ ): Grid {
504
+ const key = parseKey(keyText ?? undefined);
505
+ if (
506
+ !key ||
507
+ (table && (table.size !== 12 || Math.abs(table.period - 1200) > 1e-6))
508
+ )
509
+ return chromaticGrid(table);
510
+ const steps = scaleSteps(key);
511
+ const points: number[] = [];
512
+ for (let octave = -1; octave <= 10; octave += 1)
513
+ for (const step of steps) {
514
+ const k = octave * 12 + key.tonic + step;
515
+ if (k >= 0 && k <= 127) points.push(keyCents(table, k));
516
+ }
517
+ return gridOf(points);
518
+ }
519
+
520
+ /** A chord's pitch classes (0 = C) through a tuning, every octave. */
521
+ export function classGrid(
522
+ classes: readonly number[],
523
+ table?: TuningTable,
524
+ ): Grid {
525
+ const points: number[] = [];
526
+ for (let key = 0; key < 128; key += 1)
527
+ if (classes.includes(((key % 12) + 12) % 12))
528
+ points.push(keyCents(table, key));
529
+ return gridOf(points);
530
+ }
531
+
532
+ /** Nearest grid target to `c` cents (lower on ties). */
533
+ export function nearestTarget(grid: Grid, c: number): number {
534
+ const p = grid.points;
535
+ if (p.length === 0) return c;
536
+ let lo = 0;
537
+ let hi = p.length - 1;
538
+ while (lo < hi) {
539
+ const mid = (lo + hi) >> 1;
540
+ if (p[mid]! < c) lo = mid + 1;
541
+ else hi = mid;
542
+ }
543
+ const above = p[lo]!;
544
+ const below = lo > 0 ? p[lo - 1]! : above;
545
+ return c - below <= above - c ? below : above;
546
+ }
547
+
548
+ /** Distance from target `t` to its nearer neighbouring point. */
549
+ function gapAt(grid: Grid, t: number): number {
550
+ const p = grid.points;
551
+ let best = Infinity;
552
+ for (let i = 0; i < p.length; i += 1) {
553
+ if (Math.abs(p[i]! - t) > 1e-6) continue;
554
+ if (i > 0) best = Math.min(best, t - p[i - 1]!);
555
+ if (i + 1 < p.length) best = Math.min(best, p[i + 1]! - t);
556
+ break;
557
+ }
558
+ return Number.isFinite(best) ? best : 1200;
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Correction curves
563
+
564
+ const lp = (dtMs: number, tauMs: number) =>
565
+ tauMs <= 0 ? 1 : 1 - Math.exp(-dtMs / tauMs);
566
+ const smoothstep = (x: number, a: number, b: number) => {
567
+ const u = Math.min(1, Math.max(0, (x - a) / (b - a)));
568
+ return u * u * (3 - 2 * u);
569
+ };
570
+
571
+ function voicedAt(curve: AutotuneCurve, f: number): boolean {
572
+ return curve.f0[f]! > 0;
573
+ }
574
+
575
+ /**
576
+ * How much correction a frame may take: breath, consonant tails and creak
577
+ * track as low-confidence or aperiodic frames, and hard tune on them would
578
+ * impose a periodic pitch on noise.
579
+ */
580
+ export function voicingWeight(curve: AutotuneCurve, f: number): number {
581
+ return (
582
+ smoothstep((curve.prob[f] ?? 0) / 255, 0.3, 0.7) *
583
+ (1 - smoothstep((curve.aperiodic[f] ?? 255) / 255, 0.2, 0.4))
584
+ );
585
+ }
586
+
587
+ /**
588
+ * Hard tune's weight: full correction on any pitched frame, including the
589
+ * lower-confidence onsets and glides between notes, so transitions land on
590
+ * the grid; breath and noise (aperiodic frames) still get none.
591
+ */
592
+ export function hardVoicingWeight(curve: AutotuneCurve, f: number): number {
593
+ return (
594
+ smoothstep((curve.prob[f] ?? 0) / 255, 0.04, 0.18) *
595
+ (1 - smoothstep((curve.aperiodic[f] ?? 255) / 255, 0.2, 0.4))
596
+ );
597
+ }
598
+
599
+ /** Centred moving average of cents over ±r frames, inside voiced runs. */
600
+ function centredAverage(curve: AutotuneCurve, r: number): Float64Array {
601
+ const n = curve.f0.length;
602
+ const out = new Float64Array(n).fill(NaN);
603
+ let f = 0;
604
+ while (f < n) {
605
+ if (!voicedAt(curve, f)) {
606
+ f += 1;
607
+ continue;
608
+ }
609
+ let e = f;
610
+ while (e < n && voicedAt(curve, e)) e += 1;
611
+ // prefix sums keep it linear in the run length
612
+ const sums = new Float64Array(e - f + 1);
613
+ for (let i = f; i < e; i += 1)
614
+ sums[i - f + 1] = sums[i - f]! + centsOfHz(curve.f0[i]!);
615
+ for (let i = f; i < e; i += 1) {
616
+ const a = Math.max(f, i - r);
617
+ const b = Math.min(e - 1, i + r);
618
+ out[i] = (sums[b - f + 1]! - sums[a - f]!) / (b - a + 1);
619
+ }
620
+ f = e;
621
+ }
622
+ return out;
623
+ }
624
+
625
+ /** Median of voiced cents over ±r frames (NaN where unvoiced). */
626
+ export function runningMedian(curve: AutotuneCurve, r: number): Float64Array {
627
+ const n = curve.f0.length;
628
+ const out = new Float64Array(n).fill(NaN);
629
+ const cents = new Float64Array(n);
630
+ for (let f = 0; f < n; f += 1)
631
+ cents[f] = voicedAt(curve, f) ? centsOfHz(curve.f0[f]!) : NaN;
632
+ const window: number[] = [];
633
+ for (let f = 0; f < n; f += 1) {
634
+ if (!voicedAt(curve, f)) continue;
635
+ window.length = 0;
636
+ for (let g = Math.max(0, f - r); g <= Math.min(n - 1, f + r); g += 1)
637
+ if (!Number.isNaN(cents[g]!)) window.push(cents[g]!);
638
+ window.sort((a, b) => a - b);
639
+ out[f] = window[window.length >> 1]!;
640
+ }
641
+ return out;
642
+ }
643
+
644
+ /** Grid targets: one grid, or one per frame time (chord changes). */
645
+ export type GridSource = Grid | ((seconds: number) => Grid);
646
+
647
+ export type RetuneParams = Readonly<{
648
+ speed: number;
649
+ relax?: number;
650
+ hold?: number;
651
+ flex?: number;
652
+ glide?: number;
653
+ amount?: number;
654
+ /** Added vibrato rate Hz and peak depth in cents. */
655
+ vib?: number;
656
+ vibCents?: number;
657
+ /** Hysteresis floor in cents (default 12). */
658
+ hysteresis?: number;
659
+ }>;
660
+
661
+ /**
662
+ * Grid correction: shift in cents per frame (NaN where unvoiced, i.e. pass
663
+ * through). Port of proto/pitch/correct.ts retuneCurve with its review
664
+ * fixes: density-scaled hysteresis, 180 ms centred selection on dense
665
+ * grids, octave guard, voicing weight.
666
+ */
667
+ export function retuneCurve(
668
+ curve: AutotuneCurve,
669
+ source: GridSource,
670
+ p: RetuneParams,
671
+ ): Float64Array {
672
+ const n = curve.f0.length;
673
+ const out = new Float64Array(n).fill(NaN);
674
+ const dtMs = curve.hop * 1000;
675
+ const amount = p.amount ?? 1;
676
+ const hystFloor = p.hysteresis ?? 12;
677
+ const relax = p.relax ?? 0;
678
+ const holdMs = p.hold ?? 150;
679
+ const flex = Math.min(100, Math.max(0, p.flex ?? 0));
680
+ const zone = 50 * (1 - flex / 100);
681
+ const glideMs = (p.glide ?? 0) * 1000;
682
+ const gridAt =
683
+ typeof source === "function" ? source : (_seconds: number) => source;
684
+ const dynamic = typeof source === "function";
685
+ const selTau = p.speed <= 5 ? 0 : 60;
686
+ // Hard tune also snaps the pitched glides between notes, which track
687
+ // with lower confidence than the held middles.
688
+ const hardTune = p.speed === 0 && relax === 0;
689
+ const first = gridAt(curve.t0);
690
+ const anyDense = dynamic || first.minStep < 100;
691
+ const avg = anyDense
692
+ ? centredAverage(curve, Math.round(0.09 / curve.hop))
693
+ : null;
694
+ const med = runningMedian(curve, Math.round(0.15 / curve.hop));
695
+ let centre = NaN;
696
+ let target = NaN;
697
+ let shown = NaN;
698
+ let corr = 0;
699
+ let inNote = 0;
700
+ let lastGrid: Grid | undefined;
701
+ for (let f = 0; f < n; f += 1) {
702
+ const hz = curve.f0[f]!;
703
+ if (!(hz > 0)) {
704
+ centre = NaN;
705
+ target = NaN;
706
+ shown = NaN;
707
+ corr = 0;
708
+ inNote = 0;
709
+ continue;
710
+ }
711
+ const grid = gridAt(curve.t0 + f * curve.hop);
712
+ const dense = grid.minStep < 100;
713
+ const c = centsOfHz(hz);
714
+ centre =
715
+ dense && avg
716
+ ? avg[f]!
717
+ : Number.isNaN(centre)
718
+ ? c
719
+ : centre + (c - centre) * lp(dtMs, selTau);
720
+ const nearest = nearestTarget(grid, centre);
721
+ // the grid changed (a chord change) and the held target left it
722
+ if (lastGrid !== grid && !Number.isNaN(target)) {
723
+ if (Math.abs(nearestTarget(grid, target) - target) > 1e-6) {
724
+ target = nearest;
725
+ inNote = 0;
726
+ }
727
+ }
728
+ lastGrid = grid;
729
+ const hyst =
730
+ Number.isNaN(target) || !dense
731
+ ? hystFloor
732
+ : Math.max(hystFloor, 0.35 * gapAt(grid, target));
733
+ if (Number.isNaN(target)) target = nearest;
734
+ else if (
735
+ nearest !== target &&
736
+ Math.abs(centre - nearest) + hyst < Math.abs(centre - target)
737
+ ) {
738
+ target = nearest;
739
+ inNote = 0;
740
+ }
741
+ if (Number.isNaN(shown) || glideMs <= 0) shown = target;
742
+ else {
743
+ const maxStep = (dtMs / glideMs) * 100;
744
+ shown += Math.max(-maxStep, Math.min(maxStep, target - shown));
745
+ }
746
+ inNote += dtMs;
747
+ let w = amount;
748
+ if (flex > 0) {
749
+ const dist = Math.abs(centre - target);
750
+ w *= dist <= zone ? 1 : Math.max(0, 1 - (dist - zone) / 10);
751
+ }
752
+ const voicing =
753
+ Math.abs(c - med[f]!) > 900
754
+ ? 0
755
+ : hardTune
756
+ ? hardVoicingWeight(curve, f)
757
+ : voicingWeight(curve, f);
758
+ w *= voicing;
759
+ const sustained =
760
+ relax > 0 && inNote > holdMs
761
+ ? Math.min(1, (inNote - holdMs) / holdMs)
762
+ : 0;
763
+ const tau =
764
+ p.speed * (1 + 7 * relax * sustained) +
765
+ (sustained > 0 && p.speed === 0 ? 40 * relax * sustained : 0);
766
+ const err = (shown - c) * w;
767
+ corr += (err - corr) * lp(dtMs, tau);
768
+ // Added vibrato follows the voicing too: none on breath or octave slips.
769
+ out[f] =
770
+ corr + voicing * addedVibrato(p.vib ?? 0, p.vibCents ?? 0, inNote / 1000);
771
+ }
772
+ return out;
773
+ }
774
+
775
+ /**
776
+ * Added vibrato: starts `delay` seconds into a note (0.2 s for the track's
777
+ * vib, the note's own vibrato delay for a guide note) and fades in over
778
+ * 0.15 s like core/expression.ts.
779
+ */
780
+ function addedVibrato(
781
+ rate: number,
782
+ depth: number,
783
+ t: number,
784
+ delay = 0.2,
785
+ ): number {
786
+ if (rate <= 0 || depth <= 0) return 0;
787
+ const into = t - delay;
788
+ if (into <= 0) return 0;
789
+ return depth * Math.min(1, into / 0.15) * Math.sin(2 * Math.PI * rate * into);
790
+ }
791
+
792
+ /** A guide note in buffer seconds. */
793
+ export type GuideNote = Readonly<{
794
+ start: number;
795
+ end: number;
796
+ /** Target cents re A440 (noteHz of the note, cents included). */
797
+ cents: number;
798
+ /** Per-note drift override (NoteExpression.drift). */
799
+ drift?: number;
800
+ /** Ghost notes get no correction. */
801
+ ghost?: boolean;
802
+ /** Seconds of glide into this note (the note's glide). */
803
+ glide?: number;
804
+ /** The note's vibrato (rate Hz, depth semitones) replaces vib/vibmod. */
805
+ vib?: number;
806
+ vibmod?: number;
807
+ /** The note's vibrato delay in seconds (0 when it has none). */
808
+ vibDelay?: number;
809
+ }>;
810
+
811
+ export type GuideParams = Readonly<{
812
+ center?: number;
813
+ drift?: number;
814
+ /** Transition between notes in seconds. */
815
+ glide?: number;
816
+ /** When set, locked mode: the retune slew in ms. */
817
+ speed?: number;
818
+ amount?: number;
819
+ relax?: number;
820
+ hold?: number;
821
+ vib?: number;
822
+ vibCents?: number;
823
+ }>;
824
+
825
+ /**
826
+ * Note-guided correction (port of proto guideCurve with its review fixes):
827
+ * Melodyne-style center/drift, or locked to the notes with `speed`; legato
828
+ * boundaries follow the straight line between the corrected pitches half a
829
+ * transition either side.
830
+ */
831
+ export function guideCurve(
832
+ curve: AutotuneCurve,
833
+ notes: readonly GuideNote[],
834
+ p: GuideParams,
835
+ ): Float64Array {
836
+ const n = curve.f0.length;
837
+ const out = new Float64Array(n).fill(NaN);
838
+ const center = p.center ?? 1;
839
+ const driftDefault = p.drift ?? 0.5;
840
+ const trans = p.glide ?? 0.04;
841
+ const dtMs = curve.hop * 1000;
842
+ const sorted = [...notes].sort((a, b) => a.start - b.start);
843
+ const noteOf = new Int32Array(n).fill(-1);
844
+ const sums = sorted.map(() => ({ s: 0, n: 0 }));
845
+ let cursor = 0;
846
+ for (let f = 0; f < n; f += 1) {
847
+ const hz = curve.f0[f]!;
848
+ if (!(hz > 0)) continue;
849
+ const t = curve.t0 + f * curve.hop;
850
+ while (cursor < sorted.length && sorted[cursor]!.end <= t) cursor += 1;
851
+ for (let k = cursor; k < sorted.length && sorted[k]!.start <= t; k += 1) {
852
+ const note = sorted[k]!;
853
+ if (t >= note.start && t < note.end) {
854
+ noteOf[f] = k;
855
+ if (t > note.start + 0.06) {
856
+ sums[k]!.s += centsOfHz(hz);
857
+ sums[k]!.n += 1;
858
+ }
859
+ break;
860
+ }
861
+ }
862
+ }
863
+ const mean = sums.map((s) => (s.n ? s.s / s.n : NaN));
864
+ // The slow (drift) estimate: a 150 ms one-pole run forwards then
865
+ // backwards inside each note, starting from the note mean. Zero phase
866
+ // (offline), so a scoop or the next note's step never leaks into it
867
+ // (review open item: drift 1 leaked the step with a causal estimate).
868
+ const slowArr = new Float64Array(n).fill(NaN);
869
+ {
870
+ const a = lp(dtMs, 150);
871
+ let f = 0;
872
+ while (f < n) {
873
+ const k = noteOf[f]!;
874
+ if (k < 0) {
875
+ f += 1;
876
+ continue;
877
+ }
878
+ let e = f;
879
+ while (e < n && noteOf[e] === k) e += 1;
880
+ let y = Number.isNaN(mean[k]!) ? centsOfHz(curve.f0[f]!) : mean[k]!;
881
+ for (let g = f; g < e; g += 1) {
882
+ y += (centsOfHz(curve.f0[g]!) - y) * a;
883
+ slowArr[g] = y;
884
+ }
885
+ y = Number.isNaN(mean[k]!) ? slowArr[e - 1]! : mean[k]!;
886
+ for (let g = e - 1; g >= f; g -= 1) {
887
+ y += (slowArr[g]! - y) * a;
888
+ slowArr[g] = y;
889
+ }
890
+ f = e;
891
+ }
892
+ }
893
+ const hard = p.speed !== undefined;
894
+ const amount = p.amount ?? 1;
895
+ const relax = p.relax ?? 0;
896
+ const holdMs = p.hold ?? 150;
897
+ let slow = NaN;
898
+ let prevK = -1;
899
+ let corr = 0;
900
+ let inNote = 0;
901
+ for (let f = 0; f < n; f += 1) {
902
+ const k = noteOf[f]!;
903
+ if (k < 0) {
904
+ slow = NaN;
905
+ prevK = -1;
906
+ corr = 0;
907
+ inNote = 0;
908
+ continue;
909
+ }
910
+ const note = sorted[k]!;
911
+ if (k !== prevK) inNote = 0;
912
+ inNote += dtMs;
913
+ const c = centsOfHz(curve.f0[f]!);
914
+ let want: number;
915
+ if (note.ghost) want = 0;
916
+ else if (hard) {
917
+ const sustained =
918
+ relax > 0 && inNote > holdMs
919
+ ? Math.min(1, (inNote - holdMs) / holdMs)
920
+ : 0;
921
+ const glideMs = (note.glide ?? 0) * 1000;
922
+ const tau = Math.max(p.speed! * (1 + 7 * relax * sustained), glideMs / 3);
923
+ const err = note.cents - c;
924
+ corr = prevK < 0 ? err : corr + (err - corr) * lp(dtMs, tau);
925
+ want = corr * amount;
926
+ } else {
927
+ slow = slowArr[f]!;
928
+ const m = Number.isNaN(mean[k]!) ? c : mean[k]!;
929
+ const drift = note.drift ?? driftDefault;
930
+ want = amount * (center * (note.cents - m) - drift * (slow - m));
931
+ }
932
+ const rate = note.vib ?? p.vib ?? 0;
933
+ const depth =
934
+ note.vib !== undefined ? (note.vibmod ?? 0) * 100 : (p.vibCents ?? 0);
935
+ const delay = note.vib !== undefined ? (note.vibDelay ?? 0) : 0.2;
936
+ out[f] = note.ghost
937
+ ? 0
938
+ : want + addedVibrato(rate, depth, inNote / 1000, delay);
939
+ prevK = k;
940
+ }
941
+ const voiced = (f: number) => noteOf[f]! >= 0;
942
+ if (!hard) {
943
+ const src = Float64Array.from(out);
944
+ for (let f = 1; f < n; f += 1) {
945
+ if (!voiced(f) || !voiced(f - 1) || noteOf[f] === noteOf[f - 1]) continue;
946
+ const into = sorted[noteOf[f]!]!;
947
+ const width = into.glide ?? trans;
948
+ if (width <= 0 || into.ghost || sorted[noteOf[f - 1]!]!.ghost) continue;
949
+ const half = Math.max(1, Math.round(width / 2 / curve.hop));
950
+ const a = Math.max(0, f - half);
951
+ const b = Math.min(n - 1, f + half - 1);
952
+ let ok = true;
953
+ for (let g = a; g <= b; g += 1) if (!voiced(g)) ok = false;
954
+ if (!ok) continue;
955
+ const pa = centsOfHz(curve.f0[a]!) + src[a]!;
956
+ const pb = centsOfHz(curve.f0[b]!) + src[b]!;
957
+ for (let g = a; g <= b; g += 1) {
958
+ const u = (g - a) / Math.max(1, b - a);
959
+ out[g] = pa + (pb - pa) * u - centsOfHz(curve.f0[g]!);
960
+ }
961
+ }
962
+ }
963
+ for (let f = 0; f < n; f += 1)
964
+ if (voiced(f) && !sorted[noteOf[f]!]!.ghost)
965
+ out[f] = out[f]! * voicingWeight(curve, f);
966
+ return out;
967
+ }
968
+
969
+ // ---------------------------------------------------------------------------
970
+ // Buffer
971
+
972
+ /** Targets for one buffer, in buffer seconds. */
973
+ export type AutotuneTargets =
974
+ | Readonly<{ kind: "grid"; grid: GridSource }>
975
+ | Readonly<{ kind: "notes"; notes: readonly GuideNote[] }>;
976
+
977
+ /** The PSOLA pass: input f0 and shift in cents per sample. */
978
+ export type PsolaShift = (
979
+ x: Float64Array,
980
+ sampleRate: number,
981
+ curve: Readonly<{ f0: Float64Array; cents: Float64Array }>,
982
+ ) => Float64Array;
983
+
984
+ /** Shift in cents per frame for resolved settings and targets. */
985
+ export function correctionCurve(
986
+ curve: AutotuneCurve,
987
+ targets: AutotuneTargets,
988
+ r: ResolvedAutotune,
989
+ maxShift = 12,
990
+ ): Float64Array {
991
+ const vibCents = r.vibmod * 100;
992
+ const shift =
993
+ targets.kind === "grid"
994
+ ? retuneCurve(curve, targets.grid, {
995
+ speed: r.speed ?? 25,
996
+ relax: r.relax,
997
+ hold: r.hold,
998
+ flex: r.flex,
999
+ glide: r.glide,
1000
+ amount: r.amount,
1001
+ vib: r.vib,
1002
+ vibCents,
1003
+ })
1004
+ : guideCurve(curve, targets.notes, {
1005
+ center: r.center,
1006
+ drift: r.drift,
1007
+ glide: r.glide,
1008
+ ...(r.speed !== undefined ? { speed: r.speed } : {}),
1009
+ amount: r.amount,
1010
+ relax: r.relax,
1011
+ hold: r.hold,
1012
+ vib: r.vib,
1013
+ vibCents,
1014
+ });
1015
+ const cap = maxShift * 100;
1016
+ for (let f = 0; f < shift.length; f += 1)
1017
+ if (!Number.isNaN(shift[f]!))
1018
+ shift[f] = Math.max(-cap, Math.min(cap, shift[f]!));
1019
+ return shift;
1020
+ }
1021
+
1022
+ /** Per-sample input f0 and shift (linear between frames). */
1023
+ export function perSampleShift(
1024
+ curve: AutotuneCurve,
1025
+ shift: Float64Array,
1026
+ frames: number,
1027
+ sampleRate: number,
1028
+ ): { f0: Float64Array; cents: Float64Array } {
1029
+ const f0 = new Float64Array(frames);
1030
+ const cents = new Float64Array(frames);
1031
+ const n = curve.f0.length;
1032
+ for (let i = 0; i < frames; i += 1) {
1033
+ const pos = (i / sampleRate - curve.t0) / curve.hop;
1034
+ const j = Math.floor(pos);
1035
+ if (j < 0 || j >= n) continue;
1036
+ const u = pos - j;
1037
+ const fa = curve.f0[j]!;
1038
+ const fb = j + 1 < n ? curve.f0[j + 1]! : fa;
1039
+ // the nearer frame decides voicing; both voiced interpolate in Hz
1040
+ f0[i] = fa > 0 && fb > 0 ? fa + (fb - fa) * u : u < 0.5 ? fa : fb;
1041
+ const a = shift[j]!;
1042
+ const b = j + 1 < n ? shift[j + 1]! : a;
1043
+ if (Number.isNaN(a) && Number.isNaN(b)) cents[i] = 0;
1044
+ else if (Number.isNaN(a)) cents[i] = b;
1045
+ else if (Number.isNaN(b)) cents[i] = a;
1046
+ else cents[i] = a + (b - a) * u;
1047
+ }
1048
+ return { f0, cents };
1049
+ }
1050
+
1051
+ /**
1052
+ * A resumable PSOLA pass (the pitch lane's psola as a generator): yields
1053
+ * between blocks so the live scheduler can run it in slices.
1054
+ */
1055
+ export type PsolaJob = (
1056
+ x: Float64Array,
1057
+ sampleRate: number,
1058
+ curve: Readonly<{ f0: Float64Array; cents: Float64Array }>,
1059
+ ) => Generator<void, Float64Array>;
1060
+
1061
+ /** Samples between yields in the per-sample steps of `autotuneJob`. */
1062
+ const JOB_BLOCK = 16384;
1063
+
1064
+ /**
1065
+ * The tuned buffer as a resumable job: correction curve, per-sample shift
1066
+ * and PSOLA, yielding between blocks (and inside `psolaJob` when given).
1067
+ * Its result is identical to `autotuneBuffer`, which drains it.
1068
+ */
1069
+ export function* autotuneJob(
1070
+ x: Float32Array,
1071
+ sampleRate: number,
1072
+ curve: AutotuneCurve,
1073
+ targets: AutotuneTargets,
1074
+ r: ResolvedAutotune,
1075
+ psola: PsolaShift,
1076
+ maxShift = 12,
1077
+ psolaJob?: PsolaJob,
1078
+ ): Generator<void, Float32Array> {
1079
+ const shift = correctionCurve(curve, targets, r, maxShift);
1080
+ let moves = false;
1081
+ for (let f = 0; f < shift.length && !moves; f += 1)
1082
+ if (Math.abs(shift[f]!) > 1e-9) moves = true;
1083
+ if (!moves) return x;
1084
+ yield;
1085
+ const input = new Float64Array(x.length);
1086
+ for (let i = 0; i < x.length; i += JOB_BLOCK) {
1087
+ const end = Math.min(x.length, i + JOB_BLOCK);
1088
+ for (let j = i; j < end; j += 1) input[j] = x[j]!;
1089
+ yield;
1090
+ }
1091
+ const per = perSampleShift(curve, shift, x.length, sampleRate);
1092
+ yield;
1093
+ const y = psolaJob
1094
+ ? yield* psolaJob(input, sampleRate, per)
1095
+ : psola(input, sampleRate, per);
1096
+ yield;
1097
+ return Float32Array.from(y);
1098
+ }
1099
+
1100
+ /**
1101
+ * The tuned buffer: correction curve, per-sample shift, PSOLA. The one
1102
+ * entry point the sampler and clip hooks call. Returns the input unchanged
1103
+ * (same object) when nothing would move.
1104
+ */
1105
+ export function autotuneBuffer(
1106
+ x: Float32Array,
1107
+ sampleRate: number,
1108
+ curve: AutotuneCurve,
1109
+ targets: AutotuneTargets,
1110
+ r: ResolvedAutotune,
1111
+ psola: PsolaShift,
1112
+ maxShift = 12,
1113
+ ): Float32Array {
1114
+ const job = autotuneJob(x, sampleRate, curve, targets, r, psola, maxShift);
1115
+ for (;;) {
1116
+ const step = job.next();
1117
+ if (step.done) return step.value;
1118
+ }
1119
+ }