@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
@@ -0,0 +1,995 @@
1
+ /**
2
+ * Monophonic f0 tracker for 0.7 Voice: pYIN-lite (Mauch and Dixon, ICASSP
3
+ * 2014) over YIN's cumulative mean normalised difference (de Cheveigne and
4
+ * Kawahara, JASA 2002).
5
+ *
6
+ * - The input is first resampled to exactly 16 kHz (zero-phase half-band
7
+ * stages with the Decimate2 taps, then a polyphase windowed-sinc final
8
+ * stage), so the result does not depend on the source rate.
9
+ * - The difference function comes from one FFT cross-correlation per frame.
10
+ * - Every local minimum of d' is a candidate; a Beta(2, 18) prior over YIN
11
+ * thresholds gives each its probability (pYIN), and an unvoiced state
12
+ * takes the rest.
13
+ * - Octave control: a longer-lag dip hands its probability to a dip at a half
14
+ * or a third of its lag when the shorter one is nearly as deep in d', or
15
+ * when its raw difference is close and a harmonic comb finds no energy at
16
+ * the longer period's odd harmonics. A register prior and an octave fold
17
+ * catch what is left.
18
+ * - A Viterbi pass over candidates plus unvoiced decodes the whole clip, with
19
+ * a transition cost that grows with the jump in cents.
20
+ *
21
+ * Frame f is centred exactly at t0 + f * hop whatever lag wins. The curve
22
+ * comes back canonical (f0 through Math.fround, prob and aperiodic in 1/255
23
+ * steps), which is exactly what the DWF0 disk cache stores, so a cache hit
24
+ * and a cache miss render the same bytes. Deterministic; no randomness.
25
+ */
26
+ import { fftInPlace } from "./fft.ts";
27
+ import { halfbandTaps } from "./oversample.ts";
28
+
29
+ /** Bumped whenever tracker output changes (it keys the disk cache). */
30
+ export const PITCH_TRACKER_VERSION = 2;
31
+
32
+ /** The rate every source is resampled to before tracking. */
33
+ export const TRACK_RATE = 16_000;
34
+
35
+ /** Default hop: 5 ms. */
36
+ export const PITCH_HOP_SECONDS = 0.005;
37
+
38
+ /**
39
+ * Tracker ranges. `auto` starts at 70 Hz because 50/60 Hz hum and its
40
+ * harmonics otherwise read as a voice in the gaps; only `bass` goes to 55.
41
+ */
42
+ export const PITCH_VOICES = {
43
+ auto: { fmin: 70, fmax: 1400 },
44
+ bass: { fmin: 55, fmax: 400 },
45
+ tenor: { fmin: 90, fmax: 600 },
46
+ alto: { fmin: 140, fmax: 900 },
47
+ soprano: { fmin: 200, fmax: 1400 },
48
+ } as const satisfies Record<string, { fmin: number; fmax: number }>;
49
+
50
+ export type PitchVoice = keyof typeof PITCH_VOICES;
51
+
52
+ export const PITCH_VOICE_NAMES = Object.keys(PITCH_VOICES) as PitchVoice[];
53
+
54
+ export function isPitchVoice(value: unknown): value is PitchVoice {
55
+ return typeof value === "string" && value in PITCH_VOICES;
56
+ }
57
+
58
+ /**
59
+ * The canonical pitch curve. Frame f is centred at `t0 + f * hop` seconds.
60
+ * `f0` is Hz (0 when unvoiced); `prob` is the voicing probability and
61
+ * `aperiodic` the YIN d' at the chosen lag, both in 1/255 steps (255 = 1).
62
+ */
63
+ export type PitchCurve = Readonly<{
64
+ t0: number;
65
+ hop: number;
66
+ f0: Float32Array;
67
+ prob: Uint8Array;
68
+ aperiodic: Uint8Array;
69
+ }>;
70
+
71
+ export type TrackOptions = Readonly<{
72
+ /** Range preset (default `auto`, 70-1400 Hz). */
73
+ voice?: PitchVoice;
74
+ /** Overrides of the preset range in Hz. */
75
+ fmin?: number;
76
+ fmax?: number;
77
+ /** Hop in seconds (default 5 ms); rounded to whole 16 kHz samples. */
78
+ hop?: number;
79
+ /** Frames this many dB under the loudest frame are unvoiced (default 45). */
80
+ gateDb?: number;
81
+ /** The harmonic-comb subharmonic check (default on; off for tests). */
82
+ comb?: boolean;
83
+ }>;
84
+
85
+ /** Time of frame `f`. */
86
+ export function frameTime(curve: PitchCurve, f: number): number {
87
+ return curve.t0 + f * curve.hop;
88
+ }
89
+
90
+ export function frameCount(curve: PitchCurve): number {
91
+ return curve.f0.length;
92
+ }
93
+
94
+ /** Voicing probability of frame `f`, 0..1. */
95
+ export function probAt(curve: PitchCurve, f: number): number {
96
+ return (curve.prob[f] ?? 0) / 255;
97
+ }
98
+
99
+ /** Aperiodicity of frame `f`, 0..1 (1 when unvoiced). */
100
+ export function aperiodicAt(curve: PitchCurve, f: number): number {
101
+ return (curve.aperiodic[f] ?? 255) / 255;
102
+ }
103
+
104
+ /** An empty curve (no frames). */
105
+ export function emptyCurve(hop = PITCH_HOP_SECONDS): PitchCurve {
106
+ return {
107
+ t0: 0,
108
+ hop,
109
+ f0: new Float32Array(0),
110
+ prob: new Uint8Array(0),
111
+ aperiodic: new Uint8Array(0),
112
+ };
113
+ }
114
+
115
+ // ------------------------------------------------------------ resampling
116
+
117
+ const HALFBAND = halfbandTaps(47, 8);
118
+
119
+ /** Zero-phase 2:1 decimation with the Decimate2 half-band taps. */
120
+ function halve(x: Float64Array): Float64Array {
121
+ const n = Math.floor(x.length / 2);
122
+ const y = new Float64Array(n);
123
+ const mid = (HALFBAND.length - 1) / 2;
124
+ const taps: number[] = [];
125
+ const offsets: number[] = [];
126
+ for (let k = 0; k < HALFBAND.length; k += 1) {
127
+ if (HALFBAND[k] === 0) continue;
128
+ taps.push(HALFBAND[k]!);
129
+ offsets.push(k - mid);
130
+ }
131
+ const len = x.length;
132
+ for (let j = 0; j < n; j += 1) {
133
+ const c = 2 * j;
134
+ let acc = 0;
135
+ for (let k = 0; k < taps.length; k += 1) {
136
+ const i = c + offsets[k]!;
137
+ if (i >= 0 && i < len) acc += taps[k]! * x[i]!;
138
+ }
139
+ y[j] = acc;
140
+ }
141
+ return y;
142
+ }
143
+
144
+ function gcd(a: number, b: number): number {
145
+ while (b) [a, b] = [b, a % b];
146
+ return a;
147
+ }
148
+
149
+ function besselI0(x: number): number {
150
+ let sum = 1;
151
+ let term = 1;
152
+ for (let k = 1; k < 50; k += 1) {
153
+ term *= (x / (2 * k)) ** 2;
154
+ sum += term;
155
+ if (term < 1e-12 * sum) break;
156
+ }
157
+ return sum;
158
+ }
159
+
160
+ type PolyTable = Readonly<{
161
+ up: number;
162
+ down: number;
163
+ half: number;
164
+ phases: Float64Array[];
165
+ }>;
166
+
167
+ const POLY_TABLES = new Map<string, PolyTable>();
168
+
169
+ /** Kaiser-windowed sinc phases for a rational `up/down` resampler. */
170
+ function polyTable(from: number, to: number): PolyTable {
171
+ const key = `${from}:${to}`;
172
+ const cached = POLY_TABLES.get(key);
173
+ if (cached) return cached;
174
+ const g = gcd(from, to);
175
+ const up = to / g;
176
+ const down = from / g;
177
+ // Cutoff at 0.45 of the lower rate, in cycles per input sample.
178
+ const cut = (0.45 * Math.min(from, to)) / from;
179
+ const half = Math.ceil(12 / (2 * cut));
180
+ const beta = 8;
181
+ const i0b = besselI0(beta);
182
+ const phases: Float64Array[] = [];
183
+ for (let p = 0; p < up; p += 1) {
184
+ const frac = p / up;
185
+ const taps = new Float64Array(2 * half);
186
+ let sum = 0;
187
+ for (let k = 0; k < 2 * half; k += 1) {
188
+ const t = k - half + 1 - frac;
189
+ const arg = 2 * cut * t;
190
+ const sinc = arg === 0 ? 1 : Math.sin(Math.PI * arg) / (Math.PI * arg);
191
+ const r = t / half;
192
+ const w =
193
+ Math.abs(r) >= 1 ? 0 : besselI0(beta * Math.sqrt(1 - r * r)) / i0b;
194
+ taps[k] = sinc * w;
195
+ sum += taps[k]!;
196
+ }
197
+ for (let k = 0; k < taps.length; k += 1) taps[k]! /= sum;
198
+ phases.push(taps);
199
+ }
200
+ const table = { up, down, half, phases };
201
+ POLY_TABLES.set(key, table);
202
+ return table;
203
+ }
204
+
205
+ /** Zero-phase polyphase resampling between integer rates. */
206
+ function polyphase(x: Float64Array, from: number, to: number): Float64Array {
207
+ if (from === to) return x;
208
+ const { up, down, half, phases } = polyTable(from, to);
209
+ const n = Math.floor((x.length * up) / down);
210
+ const y = new Float64Array(n);
211
+ const len = x.length;
212
+ for (let j = 0; j < n; j += 1) {
213
+ const num = j * down;
214
+ const i = Math.floor(num / up);
215
+ const taps = phases[num - i * up]!;
216
+ let acc = 0;
217
+ const base = i - half + 1;
218
+ for (let k = 0; k < taps.length; k += 1) {
219
+ const s = base + k;
220
+ if (s >= 0 && s < len) acc += taps[k]! * x[s]!;
221
+ }
222
+ y[j] = acc;
223
+ }
224
+ return y;
225
+ }
226
+
227
+ /**
228
+ * An exact 16 kHz copy of `x`: half-band halvings while the rate is at least
229
+ * 32 kHz, then one polyphase stage. Sample n sits at n / 16000 seconds.
230
+ */
231
+ export function resampleForTracking(
232
+ x: Float64Array,
233
+ sampleRate: number,
234
+ ): Float64Array {
235
+ let rate = Math.round(sampleRate);
236
+ let y = x;
237
+ while (rate >= 2 * TRACK_RATE && rate % 2 === 0) {
238
+ y = halve(y);
239
+ rate /= 2;
240
+ }
241
+ return polyphase(y, rate, TRACK_RATE);
242
+ }
243
+
244
+ // ------------------------------------------------------------ tracking
245
+
246
+ // Beta(2, 18) prior over thresholds 0.01..1 (pYIN's default, mean 0.1).
247
+ const THRESHOLDS = 100;
248
+ const BETA = (() => {
249
+ const w = new Float64Array(THRESHOLDS);
250
+ let s = 0;
251
+ for (let i = 0; i < THRESHOLDS; i += 1) {
252
+ const t = (i + 0.5) / THRESHOLDS;
253
+ w[i] = t * (1 - t) ** 17;
254
+ s += w[i]!;
255
+ }
256
+ for (let i = 0; i < THRESHOLDS; i += 1) w[i]! /= s;
257
+ return w;
258
+ })();
259
+ const MAX_CANDIDATES = 6;
260
+ /** A shorter-lag dip within this much d' of a 2x/3x-lag dip wins. */
261
+ const SUBHARMONIC_MARGIN = 0.12;
262
+ /** The comb check runs when the short dip's raw difference is under this multiple. */
263
+ const SUBHARMONIC_RAW = 6;
264
+ /** Non-multiple / multiple harmonic energy under this means "not a real low period". */
265
+ const SUBHARMONIC_COMB = 0.3;
266
+ /** Candidates further than this from the clip's confident median are down-weighted. */
267
+ const REGISTER_CENTS = 1700;
268
+
269
+ type Dip = { tau: number; v: number; raw: number };
270
+ type Frame = {
271
+ tau: number[];
272
+ p: number[];
273
+ ap: number[];
274
+ rms: number;
275
+ v: number;
276
+ };
277
+
278
+ const HANN = new Map<number, Float64Array>();
279
+
280
+ function hannOf(n: number): Float64Array {
281
+ let w = HANN.get(n);
282
+ if (!w) {
283
+ w = new Float64Array(n);
284
+ for (let i = 0; i < n; i += 1)
285
+ w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / n);
286
+ HANN.set(n, w);
287
+ }
288
+ return w;
289
+ }
290
+
291
+ /**
292
+ * Goertzel energies at the harmonics k = 1, 2, ... of `rate / lag` up to
293
+ * 4 kHz over a Hann window. `combRatio` reads them for each divisor.
294
+ */
295
+ function harmonicEnergies(
296
+ y: Float64Array,
297
+ start: number,
298
+ size: number,
299
+ rate: number,
300
+ lag: number,
301
+ ): Float64Array {
302
+ const w = hannOf(size);
303
+ const f = rate / lag;
304
+ const top = Math.min(4000, rate * 0.45);
305
+ const count = Math.max(0, Math.ceil(top / f) - 1);
306
+ const out = new Float64Array(count);
307
+ for (let k = 1; k * f < top; k += 1) {
308
+ const om = (2 * Math.PI * k * f) / rate;
309
+ const c = 2 * Math.cos(om);
310
+ let q1 = 0;
311
+ let q2 = 0;
312
+ for (let i = 0; i < size; i += 1) {
313
+ const q0 = (y[start + i] ?? 0) * w[i]! + c * q1 - q2;
314
+ q2 = q1;
315
+ q1 = q0;
316
+ }
317
+ out[k - 1] = q1 * q1 + q2 * q2 - c * q1 * q2;
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /**
323
+ * Spectral subharmonic check: for a candidate whose harmonic energies are
324
+ * `e`, and which is `div` times a shorter-lag candidate, the mean energy at
325
+ * harmonics that are not multiples of `div` over the mean at the multiples. A
326
+ * real low voice has energy at its odd harmonics; breath under a high voice
327
+ * does not form harmonic lines there.
328
+ */
329
+ function combRatio(e: Float64Array, div: number): number {
330
+ let eNon = 0;
331
+ let nNon = 0;
332
+ let eMul = 0;
333
+ let nMul = 0;
334
+ for (let k = 1; k <= e.length; k += 1) {
335
+ if (k % div === 0) {
336
+ eMul += e[k - 1]!;
337
+ nMul += 1;
338
+ } else {
339
+ eNon += e[k - 1]!;
340
+ nNon += 1;
341
+ }
342
+ }
343
+ if (!nMul || !nNon || eMul <= 0) return 1;
344
+ return eNon / nNon / (eMul / nMul);
345
+ }
346
+
347
+ /** Frames per resumable block (0.5 s at hop 5 ms). */
348
+ const STEP_FRAMES = 100;
349
+
350
+ /** Track the f0 of a mono signal. Stereo sources: pass the mono mixdown. */
351
+ export function trackPitch(
352
+ x: Float64Array,
353
+ sampleRate: number,
354
+ options: TrackOptions = {},
355
+ ): PitchCurve {
356
+ const steps = trackPitchSteps(x, sampleRate, options);
357
+ for (;;) {
358
+ const r = steps.next();
359
+ if (r.done) return r.value;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * `trackPitch` that yields to the event loop every 0.5 s of frames, so the
365
+ * TUI and the live engine stay responsive while a long take is analysed.
366
+ * `progress` gets the fraction of frames done. The curve is byte-identical.
367
+ */
368
+ export async function trackPitchAsync(
369
+ x: Float64Array,
370
+ sampleRate: number,
371
+ options: TrackOptions = {},
372
+ progress?: (done: number) => void,
373
+ ): Promise<PitchCurve> {
374
+ const steps = trackPitchSteps(x, sampleRate, options);
375
+ for (;;) {
376
+ const r = steps.next();
377
+ if (r.done) return r.value;
378
+ progress?.(r.value);
379
+ await new Promise<void>((resolve) => setImmediate(resolve));
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The tracker as a generator: yields the fraction of frames done after each
385
+ * block of STEP_FRAMES frames and returns the curve.
386
+ */
387
+ export function* trackPitchSteps(
388
+ x: Float64Array,
389
+ sampleRate: number,
390
+ options: TrackOptions = {},
391
+ ): Generator<number, PitchCurve> {
392
+ const range = PITCH_VOICES[options.voice ?? "auto"];
393
+ const fmin = options.fmin ?? range.fmin;
394
+ const fmax = options.fmax ?? range.fmax;
395
+ const gateDb = options.gateDb ?? 45;
396
+ const comb = options.comb !== false;
397
+ const rate = TRACK_RATE;
398
+ const hopS = Math.max(
399
+ 1,
400
+ Math.round((options.hop ?? PITCH_HOP_SECONDS) * rate),
401
+ );
402
+ const hop = hopS / rate;
403
+ const tauMax = Math.ceil(rate / fmin);
404
+ const tauMin = Math.max(2, Math.floor(rate / fmax));
405
+ const W = Math.max(256, 1 << Math.ceil(Math.log2(tauMax * 1.6)));
406
+ // The difference over lag tau spans [s, s + W + tau); label frames at the
407
+ // middle for a 220 Hz period (off by at most |tau - ref| / 2 samples).
408
+ const tauRef = Math.min(tauMax, rate / 220);
409
+ const centre = (W + tauRef) / 2;
410
+ const pad = Math.floor(centre / hopS) * hopS;
411
+ const t0 = (centre - pad) / rate;
412
+ const src = resampleForTracking(x, sampleRate);
413
+ if (src.length === 0) return emptyCurve(hop);
414
+ const frames = Math.max(
415
+ 0,
416
+ Math.floor((pad + src.length - centre) / hopS) + 1,
417
+ );
418
+ const y = new Float64Array(pad + src.length + W + tauMax + 2);
419
+ y.set(src, pad);
420
+ let N = 1;
421
+ while (N < W + tauMax + 1) N <<= 1;
422
+ const re = new Float64Array(N);
423
+ const im = new Float64Array(N);
424
+ const pr = new Float64Array(N);
425
+ const pi = new Float64Array(N);
426
+ const sq = new Float64Array(y.length + 1);
427
+ for (let i = 0; i < y.length; i += 1) sq[i + 1] = sq[i]! + y[i]! * y[i]!;
428
+ const d = new Float64Array(tauMax + 2);
429
+ const dn = new Float64Array(tauMax + 2);
430
+ const all: Frame[] = [];
431
+ let maxRms = 0;
432
+ for (let f = 0; f < frames; f += 1) {
433
+ const s = f * hopS;
434
+ re.fill(0);
435
+ im.fill(0);
436
+ // Pack a = window (W) as real and b = window plus lags as imaginary.
437
+ for (let i = 0; i < W; i += 1) re[i] = y[s + i]!;
438
+ for (let i = 0; i < W + tauMax; i += 1) im[i] = y[s + i]!;
439
+ fftInPlace(re, im);
440
+ // R = conj(A) * B, separating A and B from the packed spectrum.
441
+ for (let k = 0; k < N; k += 1) {
442
+ const k2 = (N - k) & (N - 1);
443
+ const ar = 0.5 * (re[k]! + re[k2]!);
444
+ const ai = 0.5 * (im[k]! - im[k2]!);
445
+ const br = 0.5 * (im[k]! + im[k2]!);
446
+ const bi = -0.5 * (re[k]! - re[k2]!);
447
+ pr[k] = ar * br + ai * bi;
448
+ pi[k] = ar * bi - ai * br;
449
+ }
450
+ fftInPlace(pr, pi, true);
451
+ const e0 = Math.max(0, sq[s + W]! - sq[s]!);
452
+ d[0] = 0;
453
+ dn[0] = 1;
454
+ let run = 0;
455
+ for (let tau = 1; tau <= tauMax; tau += 1) {
456
+ const et = sq[s + tau + W]! - sq[s + tau]!;
457
+ const r = pr[tau]! / N;
458
+ const v = Math.max(0, e0 + et - 2 * r);
459
+ d[tau] = v;
460
+ run += v;
461
+ dn[tau] = run > 0 ? (v * tau) / run : 1;
462
+ }
463
+ const rms = Math.sqrt(e0 / W);
464
+ if (rms > maxRms) maxRms = rms;
465
+ const mins: Dip[] = [];
466
+ for (let tau = tauMin; tau < tauMax; tau += 1) {
467
+ if (dn[tau]! < dn[tau - 1]! && dn[tau]! <= dn[tau + 1]!) {
468
+ const a = dn[tau - 1]!;
469
+ const b = dn[tau]!;
470
+ const c = dn[tau + 1]!;
471
+ const den = a - 2 * b + c;
472
+ const off = den > 0 ? (0.5 * (a - c)) / den : 0;
473
+ mins.push({
474
+ tau: tau + Math.max(-0.5, Math.min(0.5, off)),
475
+ v: b - 0.25 * (a - c) * off,
476
+ raw: d[tau]!,
477
+ });
478
+ }
479
+ }
480
+ const tauList: number[] = [];
481
+ const pList: number[] = [];
482
+ const apList: number[] = [];
483
+ if (mins.length) {
484
+ const probs = new Float64Array(mins.length);
485
+ let gi = 0;
486
+ for (let i = 1; i < mins.length; i += 1)
487
+ if (mins[i]!.v < mins[gi]!.v) gi = i;
488
+ for (let t = 0; t < THRESHOLDS; t += 1) {
489
+ const thr = (t + 1) / THRESHOLDS;
490
+ let chosen = -1;
491
+ for (let i = 0; i < mins.length; i += 1)
492
+ if (mins[i]!.v < thr) {
493
+ chosen = i;
494
+ break;
495
+ }
496
+ if (chosen >= 0) probs[chosen]! += BETA[t]!;
497
+ else probs[gi]! += BETA[t]! * 0.01;
498
+ }
499
+ // A dip at 2x or 3x the lag of a nearly-as-deep dip is a subharmonic.
500
+ // The comb only runs while the long dip still carries mass to move.
501
+ const energies = new Map<number, Float64Array>();
502
+ const combOf = (i: number, div: number): number => {
503
+ let e = energies.get(i);
504
+ if (!e) {
505
+ e = harmonicEnergies(y, s, W, rate, mins[i]!.tau);
506
+ energies.set(i, e);
507
+ }
508
+ return combRatio(e, div);
509
+ };
510
+ for (let i = 0; i < mins.length; i += 1) {
511
+ for (const div of [2, 3]) {
512
+ const want = mins[i]!.tau / div;
513
+ for (let m = 0; m < mins.length; m += 1) {
514
+ if (m === i || Math.abs(mins[m]!.tau - want) > want * 0.04)
515
+ continue;
516
+ const short = mins[m]!;
517
+ const long = mins[i]!;
518
+ const sub =
519
+ short.v < long.v + SUBHARMONIC_MARGIN ||
520
+ (comb &&
521
+ probs[i]! > 0 &&
522
+ short.raw < SUBHARMONIC_RAW * long.raw &&
523
+ combOf(i, div) < SUBHARMONIC_COMB);
524
+ if (sub) {
525
+ probs[m]! += probs[i]!;
526
+ probs[i] = 0;
527
+ short.v = Math.min(short.v, long.v);
528
+ }
529
+ }
530
+ }
531
+ }
532
+ const order = [...mins.keys()]
533
+ .sort((a, b) => probs[b]! - probs[a]! || a - b)
534
+ .slice(0, MAX_CANDIDATES);
535
+ for (const i of order) {
536
+ if (probs[i]! <= 0) continue;
537
+ tauList.push(mins[i]!.tau);
538
+ pList.push(probs[i]!);
539
+ apList.push(mins[i]!.v);
540
+ }
541
+ }
542
+ // Voicing: the Beta-prior mass, or for breathy and low voices whose best
543
+ // dip sits at 0.2-0.45, a ramp on that dip.
544
+ let pv = 0;
545
+ for (const q of pList) pv += q;
546
+ const apMin = apList.length ? Math.min(...apList) : 1;
547
+ const ramp = Math.max(0, Math.min(1, (0.5 - apMin) / 0.3));
548
+ all.push({
549
+ tau: tauList,
550
+ p: pList,
551
+ ap: apList,
552
+ rms,
553
+ v: Math.min(0.995, Math.max(pv, ramp)),
554
+ });
555
+ if ((f + 1) % STEP_FRAMES === 0 && f + 1 < frames) yield (f + 1) / frames;
556
+ }
557
+ // Energy gate.
558
+ const gate = maxRms * 10 ** (-gateDb / 20);
559
+ for (const fr of all)
560
+ if (fr.rms <= gate) {
561
+ fr.p = fr.p.map((p) => p * 0.02);
562
+ fr.v *= 0.02;
563
+ }
564
+ const centsOf = (tau: number): number => 1200 * Math.log2(rate / tau / 440);
565
+ // Register prior: candidates far from the clip's confident median pitch
566
+ // are down-weighted, so a breathy stretch cannot settle an octave below.
567
+ const confident: number[] = [];
568
+ for (const fr of all) {
569
+ if (fr.rms <= gate) continue;
570
+ for (let i = 0; i < fr.tau.length; i += 1)
571
+ if (fr.ap[i]! < 0.08) {
572
+ confident.push(centsOf(fr.tau[i]!));
573
+ break;
574
+ }
575
+ }
576
+ if (confident.length >= 20) {
577
+ confident.sort((a, b) => a - b);
578
+ const median = confident[confident.length >> 1]!;
579
+ for (const fr of all)
580
+ fr.p = fr.p.map((p, i) =>
581
+ Math.abs(centsOf(fr.tau[i]!) - median) > REGISTER_CENTS ? p * 0.15 : p,
582
+ );
583
+ }
584
+ const path = viterbi(all, centsOf);
585
+ const hz = new Float64Array(frames);
586
+ const prob = new Uint8Array(frames);
587
+ const aperiodic = new Uint8Array(frames).fill(255);
588
+ for (let f = 0; f < frames; f += 1) {
589
+ const fr = all[f]!;
590
+ const k = path[f]!;
591
+ let p = 0;
592
+ for (const v of fr.p) p += v;
593
+ prob[f] = toByte(p);
594
+ if (k >= 0) {
595
+ hz[f] = rate / fr.tau[k]!;
596
+ aperiodic[f] = toByte(fr.ap[k]!);
597
+ }
598
+ }
599
+ foldOctaves(hz);
600
+ return { t0, hop, f0: Float32Array.from(hz), prob, aperiodic };
601
+ }
602
+
603
+ function toByte(value: number): number {
604
+ return Math.round(Math.min(1, Math.max(0, value)) * 255);
605
+ }
606
+
607
+ /**
608
+ * Octave-glitch repair: inside voiced stretches, a frame more than 900 cents
609
+ * from the median of the voiced frames within 50 ms either side moves by
610
+ * whole octaves to the octave nearest that median. Real leaps survive.
611
+ */
612
+ function foldOctaves(hz: Float64Array): void {
613
+ const n = hz.length;
614
+ const src = Float64Array.from(hz);
615
+ const R = 10;
616
+ const win: number[] = [];
617
+ for (let f = 0; f < n; f += 1) {
618
+ if (!(src[f]! > 0)) continue;
619
+ win.length = 0;
620
+ for (let g = Math.max(0, f - R); g <= Math.min(n - 1, f + R); g += 1)
621
+ if (src[g]! > 0) win.push(Math.log2(src[g]!));
622
+ win.sort((a, b) => a - b);
623
+ const med = win[win.length >> 1]!;
624
+ const l = Math.log2(src[f]!);
625
+ if (Math.abs(l - med) * 1200 > 900) hz[f] = 2 ** (l - Math.round(l - med));
626
+ }
627
+ }
628
+
629
+ /** Viterbi over candidates (index >= 0) and unvoiced (-1). */
630
+ function viterbi(
631
+ frames: readonly Frame[],
632
+ centsOf: (tau: number) => number,
633
+ ): number[] {
634
+ const n = frames.length;
635
+ const SWITCH = Math.log(0.02);
636
+ const STAY_U = Math.log(0.98);
637
+ const JUMP = 250; // a 250-cent jump between frames costs half a nat
638
+ const back: Int8Array[] = [];
639
+ let prevScore: number[] = [];
640
+ let prevCents: number[] = [];
641
+ for (let f = 0; f < n; f += 1) {
642
+ const fr = frames[f]!;
643
+ const k = fr.tau.length;
644
+ let sum = 0;
645
+ for (const p of fr.p) sum += p;
646
+ const pv = fr.v;
647
+ const emitU = Math.log(Math.max(1e-6, 1 - pv));
648
+ const score = new Array<number>(k + 1).fill(-Infinity);
649
+ const bp = new Int8Array(k + 1).fill(-1);
650
+ const cents = fr.tau.map(centsOf);
651
+ for (let j = 0; j <= k; j += 1) {
652
+ const emit =
653
+ j < k
654
+ ? Math.log(Math.max(1e-9, (pv * fr.p[j]!) / Math.max(1e-12, sum)))
655
+ : emitU;
656
+ if (f === 0) {
657
+ score[j] = emit;
658
+ continue;
659
+ }
660
+ const pk = prevScore.length - 1;
661
+ for (let i = 0; i <= pk; i += 1) {
662
+ let t: number;
663
+ if (i === pk && j === k) t = STAY_U;
664
+ else if (i === pk || j === k) t = SWITCH;
665
+ else {
666
+ const dc = (cents[j]! - prevCents[i]!) / JUMP;
667
+ t = -0.5 * dc * dc;
668
+ }
669
+ const s = prevScore[i]! + t + emit;
670
+ if (s > score[j]!) {
671
+ score[j] = s;
672
+ bp[j] = i;
673
+ }
674
+ }
675
+ }
676
+ back.push(bp);
677
+ prevScore = score;
678
+ prevCents = cents;
679
+ }
680
+ const path = new Array<number>(n);
681
+ let best = 0;
682
+ for (let j = 1; j < prevScore.length; j += 1)
683
+ if (prevScore[j]! > prevScore[best]!) best = j;
684
+ for (let f = n - 1; f >= 0; f -= 1) {
685
+ const k = frames[f]!.tau.length;
686
+ path[f] = best === k ? -1 : best;
687
+ best = back[f]![best]!;
688
+ }
689
+ return path;
690
+ }
691
+
692
+ // ------------------------------------------------------------ reading
693
+
694
+ /**
695
+ * Hz at time `t` (seconds), interpolated in log frequency between frames; 0
696
+ * outside the curve. Next to an unvoiced frame it takes the nearer frame.
697
+ */
698
+ export function curveAt(curve: PitchCurve, t: number): number {
699
+ const n = curve.f0.length;
700
+ if (n === 0) return 0;
701
+ const pos = (t - curve.t0) / curve.hop;
702
+ if (pos < 0 || pos > n - 1) return 0;
703
+ const i = Math.min(n - 2, Math.floor(pos));
704
+ if (i < 0) return curve.f0[0]!;
705
+ const a = curve.f0[i]!;
706
+ const b = curve.f0[i + 1]!;
707
+ const u = pos - i;
708
+ if (!(a > 0) || !(b > 0)) return u < 0.5 ? a : b;
709
+ return a * (b / a) ** u;
710
+ }
711
+
712
+ /** Hz per sample of a buffer at `sampleRate` (0 where unvoiced). */
713
+ export function curveToSamples(
714
+ curve: PitchCurve,
715
+ length: number,
716
+ sampleRate: number,
717
+ offsetSeconds = 0,
718
+ ): Float64Array {
719
+ const out = new Float64Array(length);
720
+ for (let i = 0; i < length; i += 1)
721
+ out[i] = curveAt(curve, offsetSeconds + i / sampleRate);
722
+ return out;
723
+ }
724
+
725
+ /** Cents re A440 of `hz`. */
726
+ export function centsOfHz(hz: number): number {
727
+ return 1200 * Math.log2(hz / 440);
728
+ }
729
+
730
+ /**
731
+ * Where a confirmed pitch move began: walk back from `from` while each
732
+ * earlier frame is closer to the old `anchor` (a glide or scoop), until a
733
+ * frame is within 15 cents of it, at most 80 ms and never before `floor`.
734
+ */
735
+ function glideStart(
736
+ f0: Float64Array,
737
+ hop: number,
738
+ from: number,
739
+ floor: number,
740
+ anchor: number,
741
+ ): number {
742
+ const limit = Math.max(floor + 1, from - Math.round(0.08 / hop));
743
+ const sign = Math.sign(centsOfHz(f0[from]!) - anchor);
744
+ let f = from;
745
+ while (f - 1 >= limit && f0[f - 1]! > 0) {
746
+ const here = (centsOfHz(f0[f]!) - anchor) * sign;
747
+ const before = (centsOfHz(f0[f - 1]!) - anchor) * sign;
748
+ if (before >= here || here <= 15) break;
749
+ f -= 1;
750
+ }
751
+ return f;
752
+ }
753
+
754
+ /**
755
+ * A copy of `f0` with unvoiced gaps of at most `frames` frames between two
756
+ * voiced frames filled by log interpolation (a dropout inside a glide).
757
+ */
758
+ function bridgeGaps(f0: Float32Array, frames: number): Float64Array {
759
+ const out = Float64Array.from(f0);
760
+ const n = out.length;
761
+ let f = 1;
762
+ while (f < n) {
763
+ if (out[f]! > 0 || !(out[f - 1]! > 0)) {
764
+ f += 1;
765
+ continue;
766
+ }
767
+ let g = f;
768
+ while (g < n && !(out[g]! > 0)) g += 1;
769
+ if (g < n && g - f <= frames) {
770
+ const a = Math.log2(out[f - 1]!);
771
+ const b = Math.log2(out[g]!);
772
+ for (let k = f; k < g; k += 1)
773
+ out[k] = 2 ** (a + ((b - a) * (k - f + 1)) / (g - f + 1));
774
+ }
775
+ f = g;
776
+ }
777
+ return out;
778
+ }
779
+
780
+ /** Half-width (seconds) of the centred median that note splitting reads. */
781
+ const VIBRATO_WINDOW = 0.125;
782
+
783
+ /**
784
+ * Cents of each voiced frame's centred median over +-`half` frames of the
785
+ * same voiced run. A 250 ms window spans at least one cycle of 4-8 Hz
786
+ * vibrato, so wide vibrato stays one note while a held step still moves the
787
+ * median at the step itself.
788
+ */
789
+ function centredMedian(f0: Float64Array, half: number): Float64Array {
790
+ const n = f0.length;
791
+ const cents = new Float64Array(n);
792
+ for (let f = 0; f < n; f += 1) cents[f] = f0[f]! > 0 ? centsOfHz(f0[f]!) : 0;
793
+ const out = new Float64Array(n);
794
+ const win: number[] = [];
795
+ let f = 0;
796
+ while (f < n) {
797
+ if (!(f0[f]! > 0)) {
798
+ f += 1;
799
+ continue;
800
+ }
801
+ let end = f;
802
+ while (end < n && f0[end]! > 0) end += 1;
803
+ for (let g = f; g < end; g += 1) {
804
+ win.length = 0;
805
+ const a = Math.max(f, g - half);
806
+ const b = Math.min(end - 1, g + half);
807
+ for (let k = a; k <= b; k += 1) win.push(cents[k]!);
808
+ win.sort((x, y) => x - y);
809
+ out[g] = win[win.length >> 1]!;
810
+ }
811
+ f = end;
812
+ }
813
+ return out;
814
+ }
815
+
816
+ /** A note found in a pitch curve. */
817
+ export type PitchNote = Readonly<{
818
+ /** Seconds. */
819
+ start: number;
820
+ end: number;
821
+ /** Nearest MIDI note of the segment's median pitch. */
822
+ midi: number;
823
+ /** Median deviation from `midi` in cents (-50..50). */
824
+ cents: number;
825
+ /** Mean voicing probability, 0..1. */
826
+ confidence: number;
827
+ }>;
828
+
829
+ export type PitchNotesOptions = Readonly<{
830
+ /** A pitch move larger than this (cents) held for `holdSeconds` starts a note. */
831
+ splitCents?: number;
832
+ holdSeconds?: number;
833
+ /** Shorter segments are dropped. */
834
+ minSeconds?: number;
835
+ /**
836
+ * Per-frame level in dB (from `frameLevels`): a dip of `dipDb` or more
837
+ * between two louder stretches splits a held pitch, so repeated notes
838
+ * on one pitch (a re-articulation) come out as separate notes.
839
+ */
840
+ level?: Float32Array;
841
+ /** Depth of a re-articulation dip in dB (default 6). */
842
+ dipDb?: number;
843
+ }>;
844
+
845
+ /**
846
+ * Level in dB of `mono` around each frame of `curve` (20 ms RMS window),
847
+ * for `pitchNotes`' re-articulation split.
848
+ */
849
+ export function frameLevels(
850
+ mono: ArrayLike<number>,
851
+ sampleRate: number,
852
+ curve: PitchCurve,
853
+ ): Float32Array {
854
+ const n = curve.f0.length;
855
+ const out = new Float32Array(n);
856
+ const half = Math.max(1, Math.round(0.01 * sampleRate));
857
+ for (let f = 0; f < n; f += 1) {
858
+ const c = Math.round(frameTime(curve, f) * sampleRate);
859
+ const a = Math.max(0, c - half);
860
+ const b = Math.min(mono.length, c + half);
861
+ let e = 0;
862
+ for (let i = a; i < b; i += 1) e += mono[i]! * mono[i]!;
863
+ out[f] = 10 * Math.log10(b > a ? e / (b - a) + 1e-20 : 1e-20);
864
+ }
865
+ return out;
866
+ }
867
+
868
+ /** Splits `[a, b)` at level dips that sit between two louder stretches. */
869
+ function splitDips(
870
+ a: number,
871
+ b: number,
872
+ level: Float32Array,
873
+ hop: number,
874
+ dipDb: number,
875
+ minFrames: number,
876
+ ): [number, number][] {
877
+ const reach = Math.max(1, Math.round(0.15 / hop));
878
+ const local = Math.max(1, Math.round(0.03 / hop));
879
+ const out: [number, number][] = [];
880
+ let start = a;
881
+ for (let f = a + minFrames; f < b - minFrames; f += 1) {
882
+ const v = level[f]!;
883
+ let isMin = true;
884
+ for (
885
+ let k = Math.max(a, f - local);
886
+ k <= Math.min(b - 1, f + local);
887
+ k += 1
888
+ )
889
+ if (level[k]! < v || (level[k]! === v && k < f)) {
890
+ isMin = false;
891
+ break;
892
+ }
893
+ if (!isMin) continue;
894
+ let before = -Infinity;
895
+ let after = -Infinity;
896
+ for (let k = Math.max(start, f - reach); k < f; k += 1)
897
+ before = Math.max(before, level[k]!);
898
+ for (let k = f + 1; k <= Math.min(b - 1, f + reach); k += 1)
899
+ after = Math.max(after, level[k]!);
900
+ if (before - v < dipDb || after - v < dipDb) continue;
901
+ if (f - start < minFrames) continue;
902
+ out.push([start, f]);
903
+ start = f;
904
+ }
905
+ out.push([start, b]);
906
+ return out;
907
+ }
908
+
909
+ /**
910
+ * Curve to notes: a note starts at each voiced onset and wherever the
911
+ * pitch's 250 ms centred median leaves a slowly following anchor by more than
912
+ * 70 cents for 40 ms (the onset backdated to where that glide began), so
913
+ * vibrato up to +-150 cents stays one note. Each note's pitch is the median
914
+ * of its frames.
915
+ */
916
+ export function pitchNotes(
917
+ curve: PitchCurve,
918
+ options: PitchNotesOptions = {},
919
+ ): PitchNote[] {
920
+ const split = options.splitCents ?? 70;
921
+ const hold = options.holdSeconds ?? 0.04;
922
+ const minSeconds = options.minSeconds ?? 0.06;
923
+ const n = curve.f0.length;
924
+ const f0 = bridgeGaps(curve.f0, Math.round(0.025 / curve.hop));
925
+ const centre = centredMedian(f0, Math.round(VIBRATO_WINDOW / curve.hop));
926
+ const segments: [number, number][] = [];
927
+ let open = -1;
928
+ let anchor = NaN;
929
+ let pending = -1;
930
+ const close = (end: number): void => {
931
+ if (open >= 0 && end > open) segments.push([open, end]);
932
+ open = -1;
933
+ };
934
+ for (let f = 0; f < n; f += 1) {
935
+ const hz = f0[f]!;
936
+ if (!(hz > 0)) {
937
+ close(f);
938
+ anchor = NaN;
939
+ pending = -1;
940
+ continue;
941
+ }
942
+ const cc = centre[f]!;
943
+ if (open < 0) {
944
+ open = f;
945
+ anchor = cc;
946
+ } else if (Math.abs(cc - anchor) > split) {
947
+ if (pending < 0) pending = f;
948
+ else if ((f - pending) * curve.hop > hold) {
949
+ const start = glideStart(f0, curve.hop, pending, open, anchor);
950
+ close(start);
951
+ open = start;
952
+ anchor = cc;
953
+ pending = -1;
954
+ }
955
+ } else pending = -1;
956
+ if (pending < 0) anchor += (cc - anchor) * 0.05;
957
+ }
958
+ close(n);
959
+ const level = options.level;
960
+ const split2 =
961
+ level && level.length === n
962
+ ? segments.flatMap(([a, b]) =>
963
+ splitDips(
964
+ a,
965
+ b,
966
+ level,
967
+ curve.hop,
968
+ options.dipDb ?? 6,
969
+ Math.max(1, Math.ceil(minSeconds / curve.hop)),
970
+ ),
971
+ )
972
+ : segments;
973
+ const notes: PitchNote[] = [];
974
+ for (const [a, b] of split2) {
975
+ if ((b - a) * curve.hop < minSeconds) continue;
976
+ const cents: number[] = [];
977
+ let conf = 0;
978
+ for (let f = a; f < b; f += 1) {
979
+ if (curve.f0[f]! > 0) cents.push(centsOfHz(curve.f0[f]!));
980
+ conf += curve.prob[f]! / 255;
981
+ }
982
+ if (cents.length === 0) continue;
983
+ cents.sort((x, y) => x - y);
984
+ const med = cents[cents.length >> 1]!;
985
+ const midi = Math.round(69 + med / 100);
986
+ notes.push({
987
+ start: frameTime(curve, a),
988
+ end: frameTime(curve, b),
989
+ midi,
990
+ cents: Math.round(med - (midi - 69) * 100),
991
+ confidence: Math.round((conf / (b - a)) * 100) / 100,
992
+ });
993
+ }
994
+ return notes;
995
+ }