@miadi/ava8-measure 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/audio.js ADDED
@@ -0,0 +1,821 @@
1
+ /**
2
+ * Spectral share and pitch, read back off a rendered artefact.
3
+ *
4
+ * A port of the atelier's `atelier_audio.py` (numpy + the standard library
5
+ * `wave` module) onto typed arrays, so the same measurement can run where
6
+ * numpy cannot. The numbers are the point: this file agrees with that program
7
+ * on synthetic signals to better than 1e-6 relative, and four details are the
8
+ * reason it does. Each one silently changes every number if it is written the
9
+ * way a DSP tutorial writes it.
10
+ *
11
+ * 1. `hann(n)` is `np.hanning(n)` — the SYMMETRIC window, denominator `n - 1`,
12
+ * not the periodic `n`. The difference is a fraction of a percent of a band
13
+ * share, measured against a reject line that sits at 13.12 %.
14
+ * 2. `bandShare` sums MAGNITUDE by default, not power. Summing |X|² instead
15
+ * puts every real piece under 1 % and makes the reject line unreachable by
16
+ * any sound, which cannot be what the threshold was calibrated against.
17
+ * 3. A median over an even-length window is the mean of the two middle values,
18
+ * and `medianSmooth` drops NaNs BEFORE taking the median.
19
+ * 4. The band mask is half-open — `freqs >= lo && freqs < hi`. The `<` decides
20
+ * a whole bin at a band edge. (`octaveErrorCheck` uses a CLOSED interval
21
+ * around each partial; that difference is in the original and is kept.)
22
+ *
23
+ * ## NaN is load-bearing
24
+ *
25
+ * `f0Track` returns a `Float64Array` carrying `NaN` for every unvoiced frame,
26
+ * and `isNaN` guards nearly every loop downstream. Silence with a pitch
27
+ * attached is an invention, and an unvoiced frame that reads as a number is
28
+ * how one gets invented. Do not reach for `number | null` here.
29
+ *
30
+ * ## Bytes in, never a path
31
+ *
32
+ * `decodeWav` takes a `Uint8Array`. This module opens no file, imports no
33
+ * `node:fs`, and touches no `Buffer` — a measurement library that reads the
34
+ * disk decides where a recording lives, and that decision belongs to whoever
35
+ * consented to the recording.
36
+ */
37
+ import { STRIDENCE, stridenceVerdict } from "@miadi/ava8-atelier";
38
+ import { irfft, rfft, rfftfreq } from "./fft.js";
39
+ export { STRIDENCE, stridenceVerdict };
40
+ /** Thrown for a WAV this module will not guess at. */
41
+ export class UnsupportedWav extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "UnsupportedWav";
45
+ }
46
+ }
47
+ const WAVE_FORMAT_PCM = 0x0001;
48
+ const WAVE_FORMAT_IEEE_FLOAT = 0x0003;
49
+ const WAVE_FORMAT_EXTENSIBLE = 0xfffe;
50
+ function fourcc(view, at) {
51
+ return String.fromCharCode(view.getUint8(at), view.getUint8(at + 1), view.getUint8(at + 2), view.getUint8(at + 3));
52
+ }
53
+ /**
54
+ * Decode a PCM WAV from bytes and mix it down to mono.
55
+ *
56
+ * Mono on purpose: every measure here is about spectral content and pitch, and
57
+ * a stereo pair measured separately would answer twice without deciding
58
+ * anything. Refuses a compressed or float WAV rather than guessing — a silent
59
+ * wrong answer is worse than a stop, and it is the same refusal the numpy
60
+ * original inherits from Python's `wave` module.
61
+ *
62
+ * 8-bit is unsigned, 16/24/32-bit are signed little-endian, matching the
63
+ * original byte for byte — including its `float32` intermediate, which is why
64
+ * a 32-bit source rounds identically here and there.
65
+ */
66
+ export function decodeWav(bytes) {
67
+ if (bytes.byteLength < 12) {
68
+ throw new UnsupportedWav(`not a WAV: ${bytes.byteLength} bytes is too short for a RIFF header`);
69
+ }
70
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
71
+ if (fourcc(view, 0) !== "RIFF" || fourcc(view, 8) !== "WAVE") {
72
+ throw new UnsupportedWav("not a WAV: missing the RIFF/WAVE signature");
73
+ }
74
+ let format = -1;
75
+ let effective = -1;
76
+ let channels = 0;
77
+ let rate = 0;
78
+ let bits = 0;
79
+ let dataAt = -1;
80
+ let dataLen = 0;
81
+ let pos = 12;
82
+ while (pos + 8 <= bytes.byteLength) {
83
+ const id = fourcc(view, pos);
84
+ const size = view.getUint32(pos + 4, true);
85
+ const body = pos + 8;
86
+ if (id === "fmt " && size >= 16) {
87
+ format = view.getUint16(body, true);
88
+ channels = view.getUint16(body + 2, true);
89
+ rate = view.getUint32(body + 4, true);
90
+ bits = view.getUint16(body + 14, true);
91
+ effective = format;
92
+ // WAVE_FORMAT_EXTENSIBLE hides the real code in the first two bytes of
93
+ // its SubFormat GUID.
94
+ if (format === WAVE_FORMAT_EXTENSIBLE && size >= 40) {
95
+ effective = view.getUint16(body + 24, true);
96
+ }
97
+ }
98
+ else if (id === "data") {
99
+ dataAt = body;
100
+ dataLen = Math.min(size, bytes.byteLength - body);
101
+ }
102
+ pos = body + size + (size & 1);
103
+ }
104
+ if (format < 0)
105
+ throw new UnsupportedWav("WAV has no fmt chunk");
106
+ if (dataAt < 0)
107
+ throw new UnsupportedWav("WAV has no data chunk");
108
+ if (effective !== WAVE_FORMAT_PCM) {
109
+ const named = effective === WAVE_FORMAT_IEEE_FLOAT
110
+ ? "IEEE float"
111
+ : `format 0x${effective.toString(16)}`;
112
+ throw new UnsupportedWav(`WAV is ${named}, not linear PCM. This decoder does not decompress or ` +
113
+ "convert; re-render the file as 8, 16, 24 or 32-bit PCM rather than " +
114
+ "measuring a guess.");
115
+ }
116
+ if (channels < 1)
117
+ throw new UnsupportedWav(`WAV declares ${channels} channels`);
118
+ if (rate < 1)
119
+ throw new UnsupportedWav(`WAV declares a sample rate of ${rate}`);
120
+ const width = Math.ceil(bits / 8);
121
+ if (width < 1 || width > 4) {
122
+ throw new UnsupportedWav(`unsupported sample width ${width} bytes (${bits} bits)`);
123
+ }
124
+ const block = width * channels;
125
+ const frames = Math.floor(dataLen / block);
126
+ const total = frames * channels;
127
+ // float32 first, exactly as the numpy original does, so a 32-bit source
128
+ // rounds the same way in both programs.
129
+ const flat = new Float32Array(total);
130
+ for (let i = 0; i < total; i += 1) {
131
+ const at = dataAt + i * width;
132
+ if (width === 1) {
133
+ flat[i] = (view.getUint8(at) - 128.0) / 128.0;
134
+ }
135
+ else if (width === 2) {
136
+ flat[i] = view.getInt16(at, true) / 32768.0;
137
+ }
138
+ else if (width === 3) {
139
+ const raw = view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getUint8(at + 2) << 16);
140
+ const signed = raw & 0x800000 ? raw - 0x1000000 : raw;
141
+ flat[i] = signed / 8388608.0;
142
+ }
143
+ else {
144
+ flat[i] = view.getInt32(at, true) / 2147483648.0;
145
+ }
146
+ }
147
+ const samples = new Float64Array(frames);
148
+ if (channels > 1) {
149
+ for (let i = 0; i < frames; i += 1) {
150
+ let acc = 0;
151
+ for (let c = 0; c < channels; c += 1)
152
+ acc = Math.fround(acc + flat[i * channels + c]);
153
+ samples[i] = Math.fround(acc / channels);
154
+ }
155
+ }
156
+ else {
157
+ samples.set(flat);
158
+ }
159
+ return { samples, rate, channels };
160
+ }
161
+ // --------------------------------------------------------------------------
162
+ // spectrum
163
+ // --------------------------------------------------------------------------
164
+ /** Default analysis frame, so the same file measured on two hosts agrees. */
165
+ export const N_FFT = 2048;
166
+ /** Default hop: 50 % overlap. */
167
+ export const HOP = 1024;
168
+ /**
169
+ * `np.hanning(n)` — the SYMMETRIC Hann window, denominator `n - 1`.
170
+ *
171
+ * Not the periodic window (`denominator n`) that most DSP writing means by
172
+ * "Hann". The two differ by about 1e-1 at the shoulders of a short window and
173
+ * shift a band share by a fraction of a percent, which is the whole margin
174
+ * between one verdict and another.
175
+ *
176
+ * Written as numpy writes it — `0.5 + 0.5·cos(π·n/(M-1))` over
177
+ * `n = 1-M, 3-M, … M-1` — rather than the algebraically equal
178
+ * `0.5 - 0.5·cos(2πi/(M-1))`, because the two forms differ in the last bit and
179
+ * only this one reproduces `np.hanning` exactly.
180
+ */
181
+ export function hann(n) {
182
+ if (n < 1)
183
+ return new Float64Array(0);
184
+ const out = new Float64Array(n);
185
+ if (n === 1) {
186
+ out[0] = 1;
187
+ return out;
188
+ }
189
+ for (let i = 0; i < n; i += 1) {
190
+ out[i] = 0.5 + 0.5 * Math.cos((Math.PI * (2 * i - n + 1)) / (n - 1));
191
+ }
192
+ return out;
193
+ }
194
+ /**
195
+ * Mean power spectrum over the whole file, Hann-windowed.
196
+ *
197
+ * Fixed 2048-point frames with 50 % overlap, so the same file measured twice
198
+ * returns the same number. A band edge is given in Hz, never in bins.
199
+ */
200
+ export function spectrum(audio, opts = {}) {
201
+ const nFft = opts.nFft ?? N_FFT;
202
+ const hop = opts.hop ?? HOP;
203
+ const x = audio.samples;
204
+ const win = hann(nFft);
205
+ const bins = (nFft >> 1) + 1;
206
+ const acc = new Float64Array(bins);
207
+ const frame = new Float64Array(nFft);
208
+ const spec = { re: new Float64Array(bins), im: new Float64Array(bins) };
209
+ // the original zero-pads a file shorter than one frame, then frames it
210
+ const len = Math.max(x.length, nFft);
211
+ let frames = 0;
212
+ for (let s = 0; s + nFft <= len; s += hop) {
213
+ for (let i = 0; i < nFft; i += 1) {
214
+ const j = s + i;
215
+ frame[i] = (j < x.length ? x[j] : 0) * win[i];
216
+ }
217
+ rfft(frame, nFft, spec);
218
+ for (let k = 0; k < bins; k += 1) {
219
+ // np.abs(X)**2 — hypot then square, as the original writes it
220
+ const m = Math.hypot(spec.re[k], spec.im[k]);
221
+ acc[k] += m * m;
222
+ }
223
+ frames += 1;
224
+ }
225
+ if (frames) {
226
+ for (let k = 0; k < bins; k += 1)
227
+ acc[k] /= frames;
228
+ }
229
+ return { freqs: rfftfreq(nFft, 1.0 / audio.rate), power: acc };
230
+ }
231
+ /**
232
+ * Share of total spectral weight inside an arbitrary Hz band, whole file.
233
+ *
234
+ * Decides which instrument a piece gets, and whether an arrangement will mask
235
+ * a singer. Two bands are read for every candidate timbre: 2-5 kHz (does it
236
+ * scratch) and the singer's own band in Hz (does it sit on top of them). The
237
+ * winner is the best of the two numbers, not the prettiest sound.
238
+ *
239
+ * `weight: "magnitude"` sums |X| — the default, and the only scale on which
240
+ * the thresholds in `@miadi/ava8-atelier` mean anything. Summing |X|² instead
241
+ * puts every real piece under 1 % and makes a 13.12 % reject line unreachable
242
+ * by any sound, which cannot be what those thresholds were calibrated against.
243
+ *
244
+ * The mask is half-open: `freqs >= lo && freqs < hi`.
245
+ */
246
+ export function bandShare(audio, loHz, hiHz, opts = {}) {
247
+ const { freqs, power } = spectrum(audio, opts);
248
+ const usePower = opts.weight === "power";
249
+ let total = 0;
250
+ let inside = 0;
251
+ for (let k = 0; k < power.length; k += 1) {
252
+ const v = usePower ? power[k] : Math.sqrt(power[k]);
253
+ total += v;
254
+ if (freqs[k] >= loHz && freqs[k] < hiHz)
255
+ inside += v;
256
+ }
257
+ if (total <= 0)
258
+ return 0.0;
259
+ return inside / total;
260
+ }
261
+ /**
262
+ * Share of spectral magnitude in 2000-5000 Hz over the whole file.
263
+ *
264
+ * THE timbre decision. See `STRIDENCE` and `stridenceVerdict` in
265
+ * `@miadi/ava8-atelier` for the thresholds; they belong to a musician and are
266
+ * not redefined here. Whole file on purpose: a bright section measured alone
267
+ * answers a different question than "does the piece scratch".
268
+ *
269
+ * UNVERIFIED against the session's own figures. The atelier's stridence code
270
+ * was never written down in a generator, and the candidate renders it read
271
+ * are gone, so this implementation could not be checked number for number.
272
+ * Measured here on the surviving renders of 2026-08-16: op019 2.09 %,
273
+ * op018 4.42 %, ava2v2 6.50 %, op023 8.44 % — the same ordering and the same
274
+ * 4-8 % range the session reported for that day, running 15-25 % low against
275
+ * the two figures it named (ava2v2 7.60 %, op023 11.30 %). Treat a number
276
+ * from this function as comparable to another number from this function, and
277
+ * re-measure every candidate rather than quoting a figure from the day.
278
+ */
279
+ export function stridence(audio, opts = {}) {
280
+ return bandShare(audio, STRIDENCE.bandHz[0], STRIDENCE.bandHz[1], opts);
281
+ }
282
+ function nextPow2(v) {
283
+ let p = 1;
284
+ while (p < v)
285
+ p *= 2;
286
+ return p;
287
+ }
288
+ /**
289
+ * Autocorrelation f0 per 40 ms window, 5-frame median smoothing.
290
+ *
291
+ * Decides what is actually sung, as opposed to what a listener reports. Two
292
+ * guards, both earned:
293
+ * · frames below `confFloor` or `rmsFloor` are UNVOICED (NaN), not forced to
294
+ * a pitch — silence with a pitch attached is an invention;
295
+ * · the median smoother removes single-frame octave flips, which are the
296
+ * cheap half of the octave problem. The expensive half needs
297
+ * `octaveErrorCheck`, and skipping it once invented a note nobody sang.
298
+ */
299
+ export function f0Track(audio, opts = {}) {
300
+ const windowS = opts.windowS ?? 0.04;
301
+ const hopS = opts.hopS ?? 0.02;
302
+ const fmin = opts.fmin ?? 60.0;
303
+ const fmax = opts.fmax ?? 1000.0;
304
+ const confFloor = opts.confFloor ?? 0.3;
305
+ const rmsFloor = opts.rmsFloor ?? 1e-4;
306
+ const smooth = opts.smooth ?? 5;
307
+ const x = audio.samples;
308
+ const rate = audio.rate;
309
+ const n = Math.max(64, Math.trunc(windowS * rate));
310
+ const hop = Math.max(1, Math.trunc(hopS * rate));
311
+ const lagLo = Math.max(2, Math.trunc(rate / fmax));
312
+ const lagHi = Math.min(n - 1, Math.trunc(rate / fmin));
313
+ const nfft = nextPow2(2 * n);
314
+ const bins = (nfft >> 1) + 1;
315
+ const times = [];
316
+ const hz = [];
317
+ const rmsList = [];
318
+ const confList = [];
319
+ const seg = new Float64Array(n);
320
+ const spec = { re: new Float64Array(bins), im: new Float64Array(bins) };
321
+ const powerSpec = { re: new Float64Array(bins), im: new Float64Array(bins) };
322
+ const ac = new Float64Array(nfft);
323
+ const stop = Math.max(1, x.length - n + 1);
324
+ for (let s = 0; s < stop; s += hop) {
325
+ if (s + n > x.length)
326
+ break;
327
+ let mean = 0;
328
+ for (let i = 0; i < n; i += 1)
329
+ mean += x[s + i];
330
+ mean /= n;
331
+ let r0 = 0;
332
+ for (let i = 0; i < n; i += 1) {
333
+ const v = x[s + i] - mean;
334
+ seg[i] = v;
335
+ r0 += v * v;
336
+ }
337
+ const rms = Math.sqrt(r0 / n);
338
+ times.push((s + n / 2.0) / rate);
339
+ rmsList.push(rms);
340
+ if (r0 <= 0 || rms < rmsFloor || lagHi <= lagLo) {
341
+ hz.push(Number.NaN);
342
+ confList.push(0.0);
343
+ continue;
344
+ }
345
+ rfft(seg, nfft, spec);
346
+ for (let k = 0; k < bins; k += 1) {
347
+ const m = Math.hypot(spec.re[k], spec.im[k]);
348
+ powerSpec.re[k] = m * m;
349
+ powerSpec.im[k] = 0;
350
+ }
351
+ irfft(powerSpec, nfft, ac);
352
+ const zero = ac[0];
353
+ let best = lagLo;
354
+ let bestVal = ac[lagLo] / zero;
355
+ for (let k = lagLo + 1; k <= lagHi; k += 1) {
356
+ const v = ac[k] / zero;
357
+ if (v > bestVal) {
358
+ bestVal = v;
359
+ best = k;
360
+ }
361
+ }
362
+ const c = bestVal;
363
+ if (c < confFloor) {
364
+ hz.push(Number.NaN);
365
+ confList.push(c);
366
+ continue;
367
+ }
368
+ let kRef = best;
369
+ if (best > 0 && best < n - 1) {
370
+ const y0 = ac[best - 1] / zero;
371
+ const y1 = ac[best] / zero;
372
+ const y2 = ac[best + 1] / zero;
373
+ const denom = y0 - 2 * y1 + y2;
374
+ kRef = denom ? best + 0.5 * (y0 - y2) / denom : best;
375
+ }
376
+ hz.push(kRef > 0 ? rate / kRef : Number.NaN);
377
+ confList.push(c);
378
+ }
379
+ let midi = new Float64Array(hz.length);
380
+ for (let i = 0; i < hz.length; i += 1) {
381
+ midi[i] = Number.isNaN(hz[i])
382
+ ? Number.NaN
383
+ : 69.0 + 12.0 * Math.log2(Math.max(hz[i], 1e-9) / 440.0);
384
+ }
385
+ const hzArr = Float64Array.from(hz);
386
+ if (smooth && smooth > 1) {
387
+ midi = medianSmooth(midi, smooth);
388
+ for (let i = 0; i < midi.length; i += 1) {
389
+ hzArr[i] = Number.isNaN(midi[i]) ? Number.NaN : 440.0 * 2.0 ** ((midi[i] - 69.0) / 12.0);
390
+ }
391
+ }
392
+ return {
393
+ times: Float64Array.from(times),
394
+ midi,
395
+ hz: hzArr,
396
+ rms: Float64Array.from(rmsList),
397
+ confidence: Float64Array.from(confList),
398
+ rate,
399
+ windowS,
400
+ hopS,
401
+ };
402
+ }
403
+ /** `np.median` — an even-length window is the MEAN of the two middle values. */
404
+ function medianOf(values) {
405
+ const s = values.slice().sort((a, b) => a - b);
406
+ const len = s.length;
407
+ if (len === 0)
408
+ return Number.NaN;
409
+ const mid = len >> 1;
410
+ return len % 2 === 1 ? s[mid] : 0.5 * (s[mid - 1] + s[mid]);
411
+ }
412
+ /**
413
+ * Median over `k` frames, NaN-aware. A single wild frame must not become a note.
414
+ *
415
+ * NaNs are dropped BEFORE the median, not treated as values and not
416
+ * propagated: a window that is half unvoiced still has a real median, and a
417
+ * window that is entirely unvoiced stays NaN.
418
+ */
419
+ export function medianSmooth(series, k) {
420
+ const len = series.length;
421
+ const out = new Float64Array(len);
422
+ const half = Math.floor(k / 2);
423
+ for (let i = 0; i < len; i += 1) {
424
+ const lo = Math.max(0, i - half);
425
+ const hi = Math.min(len, i + half + 1);
426
+ const w = [];
427
+ for (let j = lo; j < hi; j += 1) {
428
+ if (!Number.isNaN(series[j]))
429
+ w.push(series[j]);
430
+ }
431
+ out[i] = w.length ? medianOf(w) : Number.NaN;
432
+ }
433
+ return out;
434
+ }
435
+ /**
436
+ * Fold every MIDI value down by octaves until it sits at or under `ceiling`.
437
+ *
438
+ * Decides where a voice really is. A tracker that reports B4 for a man droning
439
+ * B2 is reporting its own error, and folding is the first half of the fix —
440
+ * the second half is `octaveErrorCheck`, which says whether the fold was
441
+ * warranted or whether the note up there was genuinely sung.
442
+ */
443
+ export function foldOctaves(midiSeries, ceiling, floor) {
444
+ const m = Float64Array.from(midiSeries);
445
+ for (let i = 0; i < m.length; i += 1) {
446
+ while (m[i] > ceiling)
447
+ m[i] -= 12.0;
448
+ }
449
+ if (floor !== undefined) {
450
+ for (let i = 0; i < m.length; i += 1) {
451
+ while (m[i] < floor)
452
+ m[i] += 12.0;
453
+ }
454
+ }
455
+ return m;
456
+ }
457
+ /**
458
+ * Energy at f/4 against energy at f, frame by frame. This caught an invented note.
459
+ *
460
+ * Decides whether a reported high pitch is real. When `shareBelow` is high and
461
+ * `medianRatio` > 1, the tracker is octave-doubling and the series must be
462
+ * folded before a note is claimed.
463
+ *
464
+ * The analysis window here is longer than the tracker's (4× by default): f/4
465
+ * of 494 Hz is 123 Hz, which a 40 ms window cannot resolve. Measuring the
466
+ * correction at the resolution that produced the error would reproduce it.
467
+ *
468
+ * The ratio is a POWER ratio, so a component at four times the amplitude reads
469
+ * 16 and not 4. Verified against a synthesised case: f/4 built at 5× amplitude
470
+ * reads 24.82. Say which ratio you mean when you quote it.
471
+ *
472
+ * Note the band here is CLOSED — `>= lo && <= hi` — unlike the half-open mask
473
+ * in `bandShare`. That asymmetry is in the original and is preserved.
474
+ */
475
+ export function octaveErrorCheck(audio, f0, opts = {}) {
476
+ const ratioHz = opts.ratioHz ?? 4.0;
477
+ const bandFrac = opts.bandFrac ?? 0.06;
478
+ const minHz = opts.minHz ?? 40.0;
479
+ const longWindowFactor = opts.longWindowFactor ?? 4;
480
+ const rate = audio.rate;
481
+ const x = audio.samples;
482
+ const n = Math.max(256, Math.trunc(f0.windowS * rate) * longWindowFactor);
483
+ const nfft = nextPow2(n);
484
+ const bins = (nfft >> 1) + 1;
485
+ const win = hann(n);
486
+ const freqs = rfftfreq(nfft, 1.0 / rate);
487
+ const frame = new Float64Array(n);
488
+ const spec = { re: new Float64Array(bins), im: new Float64Array(bins) };
489
+ const powerBins = new Float64Array(bins);
490
+ const ratios = [];
491
+ const count = Math.min(f0.times.length, f0.hz.length);
492
+ for (let idx = 0; idx < count; idx += 1) {
493
+ const t = f0.times[idx];
494
+ const f = f0.hz[idx];
495
+ if (Number.isNaN(f) || f / ratioHz < minHz)
496
+ continue;
497
+ const c = Math.trunc(t * rate);
498
+ const s = Math.max(0, c - Math.floor(n / 2));
499
+ if (s + n > x.length)
500
+ continue;
501
+ let mean = 0;
502
+ for (let i = 0; i < n; i += 1)
503
+ mean += x[s + i];
504
+ mean /= n;
505
+ for (let i = 0; i < n; i += 1)
506
+ frame[i] = (x[s + i] - mean) * win[i];
507
+ rfft(frame, nfft, spec);
508
+ for (let k = 0; k < bins; k += 1) {
509
+ const m = Math.hypot(spec.re[k], spec.im[k]);
510
+ powerBins[k] = m * m;
511
+ }
512
+ const lo = f * (1 - bandFrac);
513
+ const hi = f * (1 + bandFrac);
514
+ const fl = f / ratioHz;
515
+ const subLo = fl * (1 - bandFrac);
516
+ const subHi = fl * (1 + bandFrac);
517
+ let ef = 0;
518
+ let eSub = 0;
519
+ for (let k = 0; k < bins; k += 1) {
520
+ const fr = freqs[k];
521
+ if (fr >= lo && fr <= hi)
522
+ ef += powerBins[k];
523
+ if (fr >= subLo && fr <= subHi)
524
+ eSub += powerBins[k];
525
+ }
526
+ if (ef > 0)
527
+ ratios.push(eSub / ef);
528
+ }
529
+ if (ratios.length === 0) {
530
+ return {
531
+ frames: 0,
532
+ medianRatio: null,
533
+ meanRatio: null,
534
+ shareBelow: null,
535
+ verdict: "no voiced frame could be checked",
536
+ };
537
+ }
538
+ let above = 0;
539
+ let sum = 0;
540
+ for (const r of ratios) {
541
+ if (r > 1.0)
542
+ above += 1;
543
+ sum += r;
544
+ }
545
+ const share = above / ratios.length;
546
+ const med = medianOf(ratios);
547
+ const verdict = med > 1.0 && share > 0.5
548
+ ? `octave error: energy at f/${fmtG(ratioHz)} is ${fmtFixed(med, 2)}× stronger in ` +
549
+ `${fmtFixed(100 * share, 0)} % of frames — fold before claiming a note`
550
+ : `no octave error detected (median ratio ${fmtFixed(med, 2)}, ` +
551
+ `${fmtFixed(100 * share, 0)} % of frames)`;
552
+ return {
553
+ frames: ratios.length,
554
+ medianRatio: med,
555
+ meanRatio: sum / ratios.length,
556
+ shareBelow: share,
557
+ verdict,
558
+ };
559
+ }
560
+ /**
561
+ * Frames that stay within ±1 semitone for at least 200 ms become one note.
562
+ *
563
+ * Decides what is singing and what is speech. Speech never holds a pitch for
564
+ * 200 ms; a drone holds it for seconds. Everything downstream — the
565
+ * pitch-class profile, the motif database, the interval cells — is built on
566
+ * these notes and not on frames, because a frame count is a property of the
567
+ * tracker's hop and a held note is a property of the singer.
568
+ */
569
+ export function heldNotes(f0, opts = {}) {
570
+ const minMs = opts.minMs ?? 200.0;
571
+ const tol = opts.tolSemitones ?? 1.0;
572
+ const minS = minMs / 1000.0;
573
+ const out = [];
574
+ let run = [];
575
+ const close = () => {
576
+ if (run.length === 0)
577
+ return;
578
+ const first = f0.times[run[0]];
579
+ const last = f0.times[run[run.length - 1]];
580
+ const dur = last - first + f0.hopS;
581
+ if (dur >= minS) {
582
+ const ms = [];
583
+ let rmsSum = 0;
584
+ for (const i of run) {
585
+ ms.push(f0.midi[i]);
586
+ rmsSum += f0.rms[i];
587
+ }
588
+ const start = pyRound(first, 4);
589
+ const end = pyRound(first + dur, 4);
590
+ out.push({
591
+ start,
592
+ end,
593
+ midi: medianOf(ms),
594
+ rms: rmsSum / run.length,
595
+ duration: end - start,
596
+ });
597
+ }
598
+ };
599
+ for (let i = 0; i < f0.midi.length; i += 1) {
600
+ const m = f0.midi[i];
601
+ if (Number.isNaN(m)) {
602
+ close();
603
+ run = [];
604
+ continue;
605
+ }
606
+ if (run.length === 0) {
607
+ run = [i];
608
+ continue;
609
+ }
610
+ const ref = medianOf(run.map((j) => f0.midi[j]));
611
+ if (Math.abs(m - ref) <= tol && f0.times[i] - f0.times[run[run.length - 1]] <= 2.5 * f0.hopS) {
612
+ run.push(i);
613
+ }
614
+ else {
615
+ close();
616
+ run = [i];
617
+ }
618
+ }
619
+ close();
620
+ return out;
621
+ }
622
+ export const MOTIF_MIN_NOTES = 3;
623
+ export const MOTIF_MIN_DISTINCT = 3;
624
+ export const MOTIF_MIN_SPAN = 3.0;
625
+ /**
626
+ * Split held notes into gestures at every silence, then classify each one.
627
+ *
628
+ * The rule, and it is a rule and not an impression: a gesture is a MOTIF when
629
+ * it has ≥3 notes, ≥3 distinct pitches and a span of ≥3 semitones. Anything
630
+ * else is a DRONE. Without it, someone who drones reads as a melodist — which
631
+ * is exactly the mistake that would have had them handed a tune to sing
632
+ * instead of a world to stand inside.
633
+ */
634
+ export function motifs(notes, opts = {}) {
635
+ const gapS = opts.gapS ?? 0.5;
636
+ const gestures = [];
637
+ let cur = [];
638
+ for (const n of notes) {
639
+ if (cur.length > 0 && n.start - cur[cur.length - 1].end > gapS) {
640
+ gestures.push(cur);
641
+ cur = [];
642
+ }
643
+ cur.push(n);
644
+ }
645
+ if (cur.length > 0)
646
+ gestures.push(cur);
647
+ return gestures.map((g) => {
648
+ const pitches = g.map((n) => pyRound(n.midi, 0));
649
+ const distinct = new Set(pitches).size;
650
+ const span = pitches.length ? Math.max(...pitches) - Math.min(...pitches) : 0;
651
+ const isMotif = g.length >= MOTIF_MIN_NOTES && distinct >= MOTIF_MIN_DISTINCT && span >= MOTIF_MIN_SPAN;
652
+ const intervals = [];
653
+ for (let i = 0; i + 1 < pitches.length; i += 1)
654
+ intervals.push(pitches[i + 1] - pitches[i]);
655
+ return {
656
+ kind: isMotif ? "motif" : "drone",
657
+ start: g[0].start,
658
+ end: g[g.length - 1].end,
659
+ duration: pyRound(g[g.length - 1].end - g[0].start, 3),
660
+ nNotes: g.length,
661
+ distinct,
662
+ span,
663
+ pitches,
664
+ intervals,
665
+ notes: g,
666
+ };
667
+ });
668
+ }
669
+ /**
670
+ * Recurring interval sequences across gestures, with the time of every hit.
671
+ *
672
+ * Intervals and not pitches on purpose: a cell sung twice at different heights
673
+ * is the same cell, and counting pitches would miss it.
674
+ *
675
+ * Returns cells sorted by count then length, each with its timestamps. A cell
676
+ * that appears once is still returned; the timestamps are what let a human
677
+ * listen and disagree.
678
+ */
679
+ export function intervalCells(gestures, opts = {}) {
680
+ const lengths = opts.lengths ?? [2, 3, 4];
681
+ const motifsOnly = opts.motifsOnly ?? true;
682
+ const hits = new Map();
683
+ for (let gi = 0; gi < gestures.length; gi += 1) {
684
+ const g = gestures[gi];
685
+ if (motifsOnly && g.kind !== "motif")
686
+ continue;
687
+ const iv = g.intervals;
688
+ for (const L of lengths) {
689
+ for (let i = 0; i + L <= iv.length; i += 1) {
690
+ const cell = iv.slice(i, i + L);
691
+ const key = `${L}|${cell.join(",")}`;
692
+ let rec = hits.get(key);
693
+ if (!rec) {
694
+ rec = { cell, length: L, count: 0, times: [], gestures: [] };
695
+ hits.set(key, rec);
696
+ }
697
+ rec.count += 1;
698
+ rec.times.push(pyRound(g.notes[i].start, 3));
699
+ rec.gestures.push(gi);
700
+ }
701
+ }
702
+ }
703
+ return [...hits.values()].sort((a, b) => b.count - a.count || b.length - a.length || a.times[0] - b.times[0]);
704
+ }
705
+ /**
706
+ * RMS before, during and after every crossfade point.
707
+ *
708
+ * Decides whether an assembled piece is listenable. A seam that dips to
709
+ * silence is a hole; a seam that jumps is a click. Both are audible and both
710
+ * are invisible in the source score, because the score does not know the
711
+ * pieces were glued. Reported as levels and as a difference: dB differences
712
+ * are what an ear notices, and `jumpDb` is the number to argue about.
713
+ */
714
+ export function seams(audio, times, opts = {}) {
715
+ const windowS = opts.windowS ?? 0.25;
716
+ const x = audio.samples;
717
+ const rms = (t0, t1) => {
718
+ const i0 = Math.max(0, Math.trunc(t0 * audio.rate));
719
+ const i1 = Math.min(x.length, Math.trunc(t1 * audio.rate));
720
+ if (i1 <= i0)
721
+ return 0.0;
722
+ let acc = 0;
723
+ for (let i = i0; i < i1; i += 1)
724
+ acc += x[i] * x[i];
725
+ return Math.sqrt(acc / (i1 - i0));
726
+ };
727
+ const db = (v) => (v > 0 ? 20.0 * Math.log10(v) : Number.NEGATIVE_INFINITY);
728
+ return times.map((t) => {
729
+ const before = rms(t - windowS, t);
730
+ const during = rms(t - windowS / 2.0, t + windowS / 2.0);
731
+ const after = rms(t, t + windowS);
732
+ const lo = Math.min(before, during, after);
733
+ const hi = Math.max(before, during, after);
734
+ // a seam that runs into silence is a hole; a seam that halves is a dip.
735
+ // Both are audible, and the silence case must not be excused by a missing
736
+ // ratio — that is exactly the seam a listener notices first.
737
+ const silent = lo <= 0.0 && 0.0 < hi;
738
+ return {
739
+ at: t,
740
+ windowS,
741
+ rmsBefore: before,
742
+ rmsDuring: during,
743
+ rmsAfter: after,
744
+ dbBefore: db(before),
745
+ dbDuring: db(during),
746
+ dbAfter: db(after),
747
+ jumpDb: before > 0 && after > 0 ? db(after) - db(before) : null,
748
+ silent,
749
+ dip: (before > 0 && after > 0 && during < 0.5 * Math.min(before, after)) || silent,
750
+ };
751
+ });
752
+ }
753
+ // --------------------------------------------------------------------------
754
+ // small numeric helpers, matching Python where it differs from JavaScript
755
+ // --------------------------------------------------------------------------
756
+ /**
757
+ * Python's `round(x, d)` — half to EVEN, where JavaScript rounds half away
758
+ * from zero. `round(2.5)` is 2 in Python and 3 in JavaScript, and the pitches
759
+ * a motif is classified on are rounded MIDI floats.
760
+ */
761
+ export function pyRound(x, digits) {
762
+ if (!Number.isFinite(x))
763
+ return x;
764
+ const halfEven = (scaled) => {
765
+ const floor = Math.floor(scaled);
766
+ const frac = scaled - floor;
767
+ if (frac > 0.5)
768
+ return floor + 1;
769
+ if (frac < 0.5)
770
+ return floor;
771
+ return floor % 2 === 0 ? floor : floor + 1;
772
+ };
773
+ if (digits === 0)
774
+ return halfEven(x);
775
+ // `x * 10**digits` is NOT safe to round from: 2.675 is really
776
+ // 2.67499999999999982, so Python answers 2.67 while the scaled product
777
+ // answers 2.68. `toFixed` rounds from the exact value of the double, which
778
+ // is what Python does — it differs only on an exact tie, which it breaks
779
+ // away from zero where Python breaks it to even.
780
+ //
781
+ // A tie is exact only when the double is a dyadic rational with denominator
782
+ // 2^(digits+1) — 0.03125 at 4 digits, never 2.675 at 2 — and in that case
783
+ // the scaled product IS exact, so scaling is safe there and only there.
784
+ const p = 10 ** digits;
785
+ const scaled = x * p;
786
+ if (Number.isInteger(x * 2 ** (digits + 1)) &&
787
+ Math.abs(scaled) < Number.MAX_SAFE_INTEGER &&
788
+ Math.abs(scaled - Math.floor(scaled)) === 0.5) {
789
+ return halfEven(scaled) / p;
790
+ }
791
+ return Number(x.toFixed(digits));
792
+ }
793
+ /** Python's `f"{v:.Nf}"` — correctly rounded, ties to even. */
794
+ function fmtFixed(v, digits) {
795
+ if (!Number.isFinite(v))
796
+ return String(v);
797
+ const probe = v.toFixed(digits + 1);
798
+ if (probe.endsWith("5") && Number(probe) === v) {
799
+ return pyRound(v, digits).toFixed(digits);
800
+ }
801
+ return v.toFixed(digits);
802
+ }
803
+ /** Python's `f"{v:g}"` — six significant digits, trailing zeros stripped. */
804
+ function fmtG(v) {
805
+ if (!Number.isFinite(v))
806
+ return String(v);
807
+ if (v === 0)
808
+ return "0";
809
+ const exp = Math.floor(Math.log10(Math.abs(v)));
810
+ if (exp < -4 || exp >= 6) {
811
+ return v
812
+ .toExponential(5)
813
+ .replace(/\.?0+e/, "e")
814
+ .replace(/e([+-])(\d)$/, "e$10$2");
815
+ }
816
+ let s = v.toPrecision(6);
817
+ if (s.includes("."))
818
+ s = s.replace(/\.?0+$/, "");
819
+ return s;
820
+ }
821
+ //# sourceMappingURL=audio.js.map