@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/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/audio.d.ts +343 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +821 -0
- package/dist/audio.js.map +1 -0
- package/dist/fft.d.ts +51 -0
- package/dist/fft.d.ts.map +1 -0
- package/dist/fft.js +246 -0
- package/dist/fft.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/dist/midi.d.ts +333 -0
- package/dist/midi.d.ts.map +1 -0
- package/dist/midi.js +651 -0
- package/dist/midi.js.map +1 -0
- package/dist/movement.d.ts +431 -0
- package/dist/movement.d.ts.map +1 -0
- package/dist/movement.js +701 -0
- package/dist/movement.js.map +1 -0
- package/package.json +70 -0
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
|