@cleanfield/web 0.0.0-stage → 0.1.1
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/README.md +19 -2
- package/cf_wasm.wasm +0 -0
- package/dsp.js +816 -0
- package/index.js +27 -0
- package/live-dsp.js +528 -0
- package/package.json +9 -4
- package/worklet-processor.js +327 -0
package/dsp.js
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleanField Voice 1.0 — client-side DSP pipeline.
|
|
3
|
+
*
|
|
4
|
+
* This module is plain ES module JavaScript with no dependencies. It runs
|
|
5
|
+
* unchanged in a browser worker, on the browser main thread (the fallback
|
|
6
|
+
* path) and in Node, so the same STFT/OLA code can be exercised without a
|
|
7
|
+
* browser.
|
|
8
|
+
*
|
|
9
|
+
* Contract:
|
|
10
|
+
* 16 kHz mono, 1024-point FFT, 768-sample periodic Hann window,
|
|
11
|
+
* 256-sample hop, feature ln(|X[k]|^2 + 1e-20) for k = 0..512,
|
|
12
|
+
* mask[k] = clamp(sqrt(re[k]^2 + im[k]^2) * gain, floor, 1) from the
|
|
13
|
+
* engine's two 513-value output planes, inverse FFT, overlap-add. The
|
|
14
|
+
* floor is the "maximum attenuation" control: 0 dB is a passthrough and
|
|
15
|
+
* −80 dB is the full model mask.
|
|
16
|
+
*
|
|
17
|
+
* The fixed analysis latency (768 + 2 * 256 = 1280 samples) is removed from
|
|
18
|
+
* the result, so the returned array is time-aligned with the input.
|
|
19
|
+
*
|
|
20
|
+
* The spectrogram panels are fed by transforms of the real audio path. The
|
|
21
|
+
* input panel is the windowed analysis spectrum that enters the model; the
|
|
22
|
+
* output panel is a windowed transform of the 768 most recently emitted output
|
|
23
|
+
* samples, taken after the synthesis window and overlap-add. Capture only
|
|
24
|
+
* computes extra values when it is enabled and never changes an output sample.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { SPEC_BINS, fillDbColumn } from './spectrogram-core.js';
|
|
28
|
+
|
|
29
|
+
export const SAMPLE_RATE = 16000;
|
|
30
|
+
export const FFT_SIZE = 1024;
|
|
31
|
+
export const WINDOW_SIZE = 768;
|
|
32
|
+
export const HOP_SIZE = 256;
|
|
33
|
+
export const BIN_COUNT = 513;
|
|
34
|
+
export const LOOKAHEAD_HOPS = 2;
|
|
35
|
+
export const LATENCY_SAMPLES = WINDOW_SIZE + LOOKAHEAD_HOPS * HOP_SIZE;
|
|
36
|
+
/**
|
|
37
|
+
* How many emitted hops after an input frame its output window is complete.
|
|
38
|
+
* The input frame's 768-sample analysis window is the same span of audio as the
|
|
39
|
+
* 768 most recently emitted output samples at that point, so a column can show
|
|
40
|
+
* both panels for the same instant. The output hop trails its analysis frame
|
|
41
|
+
* by `LOOKAHEAD_HOPS`, and the rolling output window trails the emitted hop by
|
|
42
|
+
* `WINDOW_SIZE / HOP_SIZE - 1` hops.
|
|
43
|
+
*/
|
|
44
|
+
export const SPECTRUM_ALIGN_HOPS =
|
|
45
|
+
WINDOW_SIZE / HOP_SIZE - 1 + LOOKAHEAD_HOPS; // 4
|
|
46
|
+
/** Ring slots needed to hold the input spectrum until its output window is ready. */
|
|
47
|
+
export const SPECTRUM_HISTORY = SPECTRUM_ALIGN_HOPS + 1; // 5
|
|
48
|
+
export const MODEL_INPUT_LEN = 513;
|
|
49
|
+
export const MODEL_OUTPUT_LEN = 1026;
|
|
50
|
+
export const MAX_DURATION_SECONDS = 30 * 60;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The desktop engine was trained with power computed in int16 units and folds
|
|
54
|
+
* 32768^2 into this float32 multiplier. It is 0.99999726, i.e. 2.7e-6 below
|
|
55
|
+
* unity in log-power, but we keep it so the wasm call receives the same
|
|
56
|
+
* features as the reference pipeline.
|
|
57
|
+
*/
|
|
58
|
+
export const FEATURE_POWER_SCALE = Math.fround(
|
|
59
|
+
Math.fround(9.313200211025219e-10) * 1073741824
|
|
60
|
+
);
|
|
61
|
+
export const EPSILON = Math.fround(1e-20);
|
|
62
|
+
export const MASK_GAIN = 1;
|
|
63
|
+
/**
|
|
64
|
+
* Deepest mask floor the model path supports: −80 dB, matching the reference
|
|
65
|
+
* file pipeline. This is the fixed floor that earlier builds hard-coded; it is
|
|
66
|
+
* still exact at the default maximum attenuation of −80 dB.
|
|
67
|
+
*/
|
|
68
|
+
export const MASK_FLOOR = Math.fround(10 ** (-80 / 20));
|
|
69
|
+
/** Deepest maximum attenuation the demo exposes, in dB of noise reduction. */
|
|
70
|
+
export const MAX_ATTENUATION_DB = 80;
|
|
71
|
+
/** Default maximum attenuation: the full model mask (the historical output). */
|
|
72
|
+
export const DEFAULT_MAX_ATTENUATION_DB = -MAX_ATTENUATION_DB;
|
|
73
|
+
/** Sum of squared periodic Hann at 75% overlap: 768 * 3/8 / 256 = 9/8. */
|
|
74
|
+
export const SYNTHESIS_SCALE = Math.fround(8 / 9);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Clamp a maximum-attenuation setting to `[-80, 0]` dB, defaulting to −80 dB
|
|
78
|
+
* for non-numbers. The value is the mask floor expressed as a maximum
|
|
79
|
+
* reduction: 0 dB leaves the mask at unity (passthrough), −80 dB applies the
|
|
80
|
+
* full learned mask down to −80 dB.
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeMaxAttenuationDb(value) {
|
|
86
|
+
const db = Number(value);
|
|
87
|
+
if (!Number.isFinite(db)) return DEFAULT_MAX_ATTENUATION_DB;
|
|
88
|
+
return db < -MAX_ATTENUATION_DB ? -MAX_ATTENUATION_DB : db > 0 ? 0 : db;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert a maximum-attenuation setting in dB to a mask floor amplitude.
|
|
93
|
+
*
|
|
94
|
+
* `floorDb = -80 * s` in the old 0…1 strength notation, so a percentage-style
|
|
95
|
+
* slider can use `s = -db / 80`. Both endpoints are exact: 0 dB returns
|
|
96
|
+
* exactly 1 (passthrough) and −80 dB returns the historical `MASK_FLOOR`
|
|
97
|
+
* constant, which keeps the default output bit-identical to the previous build.
|
|
98
|
+
*
|
|
99
|
+
* @param {number} maxAttenuationDb in `[-80, 0]`
|
|
100
|
+
* @returns {number} mask floor in `[1e-4, 1]`
|
|
101
|
+
*/
|
|
102
|
+
export function maskFloorForDb(maxAttenuationDb) {
|
|
103
|
+
const db = normalizeMaxAttenuationDb(maxAttenuationDb);
|
|
104
|
+
if (db >= 0) return 1;
|
|
105
|
+
if (db <= -MAX_ATTENUATION_DB) return MASK_FLOOR;
|
|
106
|
+
return Math.fround(10 ** (db / 20));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply the mask floor to one learned mask magnitude.
|
|
111
|
+
*
|
|
112
|
+
* The applied mask is `min(max(m, floor), 1)`, evaluated with the same
|
|
113
|
+
* comparison sequence the fixed −80 dB floor used before this control existed.
|
|
114
|
+
* A floor of 1 is exactly unity (passthrough); a floor of `MASK_FLOOR` is the
|
|
115
|
+
* historical full-strength mask.
|
|
116
|
+
*
|
|
117
|
+
* @param {number} magnitude `|Y[k]| * MASK_GAIN`, not yet clamped
|
|
118
|
+
* @param {number} floor from `maskFloorForDb()`
|
|
119
|
+
* @returns {number}
|
|
120
|
+
*/
|
|
121
|
+
export function applyMaskFloor(magnitude, floor) {
|
|
122
|
+
return magnitude > floor ? (magnitude < 1 ? magnitude : 1) : floor;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const WINDOW = (() => {
|
|
126
|
+
const w = new Float32Array(WINDOW_SIZE);
|
|
127
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
128
|
+
w[i] = Math.fround(0.5 - 0.5 * Math.cos((2 * Math.PI * i) / WINDOW_SIZE));
|
|
129
|
+
}
|
|
130
|
+
return w;
|
|
131
|
+
})();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scale that turns a windowed-FFT magnitude into full-scale amplitude:
|
|
135
|
+
* a bin-centred full-scale sine has |X| = sum(w) / 2, so multiplying by
|
|
136
|
+
* 2 / sum(w) makes it 0 dBFS. Both spectrogram panels use this scale, so a
|
|
137
|
+
* full-scale sine reads 0 dBFS in either panel.
|
|
138
|
+
*/
|
|
139
|
+
export const SPECTRUM_NORM = (() => {
|
|
140
|
+
let sum = 0;
|
|
141
|
+
for (let i = 0; i < WINDOW_SIZE; i++) sum += WINDOW[i];
|
|
142
|
+
return 2 / sum;
|
|
143
|
+
})();
|
|
144
|
+
|
|
145
|
+
const clock =
|
|
146
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
147
|
+
? () => performance.now()
|
|
148
|
+
: () => Date.now();
|
|
149
|
+
|
|
150
|
+
class DemoError extends Error {
|
|
151
|
+
constructor(message) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.name = 'DemoError';
|
|
154
|
+
this.userFacing = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fail(message) {
|
|
159
|
+
return new DemoError(message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// FFT (radix-2, decimation in time, double precision)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export class FFT {
|
|
167
|
+
constructor(n) {
|
|
168
|
+
if ((n & (n - 1)) !== 0) throw fail('FFT length must be a power of two.');
|
|
169
|
+
this.n = n;
|
|
170
|
+
const bits = Math.log2(n);
|
|
171
|
+
this.rev = new Uint16Array(n);
|
|
172
|
+
for (let i = 0; i < n; i++) {
|
|
173
|
+
let x = i;
|
|
174
|
+
let r = 0;
|
|
175
|
+
for (let b = 0; b < bits; b++) {
|
|
176
|
+
r = (r << 1) | (x & 1);
|
|
177
|
+
x >>>= 1;
|
|
178
|
+
}
|
|
179
|
+
this.rev[i] = r;
|
|
180
|
+
}
|
|
181
|
+
this.cos = new Float64Array(n >> 1);
|
|
182
|
+
this.sin = new Float64Array(n >> 1);
|
|
183
|
+
for (let i = 0; i < n >> 1; i++) {
|
|
184
|
+
this.cos[i] = Math.cos((2 * Math.PI * i) / n);
|
|
185
|
+
this.sin[i] = Math.sin((2 * Math.PI * i) / n);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_run(re, im, inverse) {
|
|
190
|
+
const n = this.n;
|
|
191
|
+
const rev = this.rev;
|
|
192
|
+
for (let i = 0; i < n; i++) {
|
|
193
|
+
const j = rev[i];
|
|
194
|
+
if (j > i) {
|
|
195
|
+
const tr = re[i];
|
|
196
|
+
re[i] = re[j];
|
|
197
|
+
re[j] = tr;
|
|
198
|
+
const ti = im[i];
|
|
199
|
+
im[i] = im[j];
|
|
200
|
+
im[j] = ti;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const cos = this.cos;
|
|
205
|
+
const sin = this.sin;
|
|
206
|
+
for (let size = 2; size <= n; size <<= 1) {
|
|
207
|
+
const half = size >> 1;
|
|
208
|
+
const step = n / size;
|
|
209
|
+
for (let start = 0; start < n; start += size) {
|
|
210
|
+
for (let j = 0; j < half; j++) {
|
|
211
|
+
const k = j * step;
|
|
212
|
+
const wr = cos[k];
|
|
213
|
+
const wi = inverse ? sin[k] : -sin[k];
|
|
214
|
+
const a = start + j;
|
|
215
|
+
const b = a + half;
|
|
216
|
+
const tr = re[b] * wr - im[b] * wi;
|
|
217
|
+
const ti = re[b] * wi + im[b] * wr;
|
|
218
|
+
re[b] = re[a] - tr;
|
|
219
|
+
im[b] = im[a] - ti;
|
|
220
|
+
re[a] += tr;
|
|
221
|
+
im[a] += ti;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
forward(re, im) {
|
|
228
|
+
this._run(re, im, false);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
inverse(re, im) {
|
|
232
|
+
this._run(re, im, true);
|
|
233
|
+
const n = this.n;
|
|
234
|
+
for (let i = 0; i < n; i++) {
|
|
235
|
+
re[i] /= n;
|
|
236
|
+
im[i] /= n;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const FFT_1024 = new FFT(FFT_SIZE);
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// wasm engine
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function wasmFailure(exports, message) {
|
|
248
|
+
const code = typeof exports.cf_last_error === 'function' ? exports.cf_last_error() : 0;
|
|
249
|
+
return fail(code ? `${message} (error code ${code})` : message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Instantiate `cf_wasm.wasm` and create the voice model.
|
|
254
|
+
*
|
|
255
|
+
* @param {BufferSource|WebAssembly.Module} wasmSource
|
|
256
|
+
* @param {{modelBytes?: Uint8Array}} [options] use a host-supplied .cfwm
|
|
257
|
+
* container instead of the embedded one.
|
|
258
|
+
* @returns {Promise<VoiceEngine>}
|
|
259
|
+
*/
|
|
260
|
+
export async function loadVoiceEngine(wasmSource, options = {}) {
|
|
261
|
+
try {
|
|
262
|
+
let instance;
|
|
263
|
+
if (wasmSource instanceof WebAssembly.Module) {
|
|
264
|
+
instance = new WebAssembly.Instance(wasmSource, {});
|
|
265
|
+
} else {
|
|
266
|
+
const result = await WebAssembly.instantiate(wasmSource, {});
|
|
267
|
+
instance = result.instance ?? result;
|
|
268
|
+
}
|
|
269
|
+
return new VoiceEngine(instance, options);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// Keep our own validation messages; wrap runtime/compile errors.
|
|
272
|
+
if (error && error.userFacing) throw error;
|
|
273
|
+
throw fail(
|
|
274
|
+
`Could not initialise the denoising engine. Check that this browser supports ` +
|
|
275
|
+
`WebAssembly and SIMD (${error?.message || error}).`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export class VoiceEngine {
|
|
281
|
+
constructor(instance, { modelBytes = null } = {}) {
|
|
282
|
+
this.instance = instance;
|
|
283
|
+
this.exports = instance.exports;
|
|
284
|
+
const e = this.exports;
|
|
285
|
+
if (!e.memory || !(e.memory instanceof WebAssembly.Memory)) {
|
|
286
|
+
throw fail('The denoising engine did not export its memory.');
|
|
287
|
+
}
|
|
288
|
+
for (const name of [
|
|
289
|
+
'cf_voice_new',
|
|
290
|
+
'cf_voice_reset',
|
|
291
|
+
'cf_voice_process',
|
|
292
|
+
'cf_voice_free',
|
|
293
|
+
'cf_voice_input_len',
|
|
294
|
+
'cf_voice_output_len',
|
|
295
|
+
'cf_alloc',
|
|
296
|
+
'cf_free',
|
|
297
|
+
]) {
|
|
298
|
+
if (typeof e[name] !== 'function') throw fail(`The denoising engine did not export ${name}.`);
|
|
299
|
+
}
|
|
300
|
+
if (e.cf_voice_input_len() !== MODEL_INPUT_LEN || e.cf_voice_output_len() !== MODEL_OUTPUT_LEN) {
|
|
301
|
+
throw fail('The denoising engine has an unexpected feature size.');
|
|
302
|
+
}
|
|
303
|
+
if (typeof e.cf_voice_backend_is_scalar === 'function' && e.cf_voice_backend_is_scalar() !== 1) {
|
|
304
|
+
throw fail('The denoising engine is not using the scalar backend.');
|
|
305
|
+
}
|
|
306
|
+
if (typeof e.cf_fma_is_soft === 'function' && e.cf_fma_is_soft() !== 1) {
|
|
307
|
+
throw fail('The denoising engine failed its floating-point self-check.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.modelImageBytes = typeof e.cf_voice_model_image_len === 'function'
|
|
311
|
+
? e.cf_voice_model_image_len() >>> 0
|
|
312
|
+
: 0;
|
|
313
|
+
this.workspaceBytes = typeof e.cf_voice_workspace_bytes === 'function'
|
|
314
|
+
? e.cf_voice_workspace_bytes() >>> 0
|
|
315
|
+
: 0;
|
|
316
|
+
|
|
317
|
+
let handle;
|
|
318
|
+
if (modelBytes) {
|
|
319
|
+
const bytes = modelBytes instanceof Uint8Array ? modelBytes : new Uint8Array(modelBytes);
|
|
320
|
+
if (bytes.byteLength === 0 || bytes.byteLength % 4 !== 0) {
|
|
321
|
+
throw fail('The model file length is invalid.');
|
|
322
|
+
}
|
|
323
|
+
const count = bytes.byteLength >>> 2;
|
|
324
|
+
if (typeof e.cf_alloc_u32 !== 'function') throw fail('The denoising engine does not support an external model.');
|
|
325
|
+
const ptr = e.cf_alloc_u32(count);
|
|
326
|
+
if (!ptr) throw fail('Could not allocate memory for the model.');
|
|
327
|
+
new Uint8Array(e.memory.buffer, ptr, bytes.byteLength).set(bytes);
|
|
328
|
+
handle = e.cf_voice_new(ptr, bytes.byteLength);
|
|
329
|
+
if (!handle) {
|
|
330
|
+
// cf_voice_new takes ownership of the buffer on all paths.
|
|
331
|
+
throw wasmFailure(e, 'Could not load the model file');
|
|
332
|
+
}
|
|
333
|
+
this.modelImageBytes = bytes.byteLength;
|
|
334
|
+
} else {
|
|
335
|
+
handle = e.cf_voice_new(0, 0);
|
|
336
|
+
if (!handle) throw wasmFailure(e, 'Could not create the denoising model');
|
|
337
|
+
}
|
|
338
|
+
this.handle = handle;
|
|
339
|
+
|
|
340
|
+
const inBytes = MODEL_INPUT_LEN * 4;
|
|
341
|
+
const outBytes = MODEL_OUTPUT_LEN * 4;
|
|
342
|
+
this.inputPtr = e.cf_alloc(inBytes, 8);
|
|
343
|
+
this.outputPtr = e.cf_alloc(outBytes, 8);
|
|
344
|
+
if (!this.inputPtr || !this.outputPtr) {
|
|
345
|
+
this.close();
|
|
346
|
+
throw fail('Could not allocate memory for the audio frames.');
|
|
347
|
+
}
|
|
348
|
+
this._out = new Float32Array(MODEL_OUTPUT_LEN);
|
|
349
|
+
this._input = new Float32Array(MODEL_INPUT_LEN);
|
|
350
|
+
|
|
351
|
+
this.backendName = this._readBackendName();
|
|
352
|
+
this.modelBytes = typeof e.cf_voice_model_bytes === 'function'
|
|
353
|
+
? e.cf_voice_model_bytes(handle) >>> 0
|
|
354
|
+
: 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_readBackendName() {
|
|
358
|
+
const e = this.exports;
|
|
359
|
+
if (typeof e.cf_voice_backend_name_ptr !== 'function') return '';
|
|
360
|
+
const ptr = e.cf_voice_backend_name_ptr(this.handle);
|
|
361
|
+
const len = e.cf_voice_backend_name_len(this.handle);
|
|
362
|
+
if (!ptr || !len) return '';
|
|
363
|
+
try {
|
|
364
|
+
return new TextDecoder().decode(new Uint8Array(e.memory.buffer, ptr, len));
|
|
365
|
+
} catch {
|
|
366
|
+
return '';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Run one 513-value feature frame; returns a reused 1026-value buffer. */
|
|
371
|
+
process(features) {
|
|
372
|
+
if (!this.handle) throw fail('The denoising engine is closed.');
|
|
373
|
+
if (features.length !== MODEL_INPUT_LEN) throw fail('The feature frame has the wrong length.');
|
|
374
|
+
const e = this.exports;
|
|
375
|
+
new Float32Array(e.memory.buffer, this.inputPtr, MODEL_INPUT_LEN).set(features);
|
|
376
|
+
const rc = e.cf_voice_process(this.handle, this.inputPtr, this.outputPtr);
|
|
377
|
+
if (rc !== 0) throw wasmFailure(e, 'Denoising failed');
|
|
378
|
+
this._out.set(new Float32Array(e.memory.buffer, this.outputPtr, MODEL_OUTPUT_LEN));
|
|
379
|
+
return this._out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
reset() {
|
|
383
|
+
if (!this.handle) throw fail('The denoising engine is closed.');
|
|
384
|
+
if (this.exports.cf_voice_reset(this.handle) !== 0) {
|
|
385
|
+
throw wasmFailure(this.exports, 'Could not reset the denoising state');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
close() {
|
|
390
|
+
const e = this.exports;
|
|
391
|
+
if (this.handle) {
|
|
392
|
+
e.cf_voice_free(this.handle);
|
|
393
|
+
this.handle = 0;
|
|
394
|
+
}
|
|
395
|
+
if (this.inputPtr) {
|
|
396
|
+
e.cf_free(this.inputPtr, MODEL_INPUT_LEN * 4, 8);
|
|
397
|
+
this.inputPtr = 0;
|
|
398
|
+
}
|
|
399
|
+
if (this.outputPtr) {
|
|
400
|
+
e.cf_free(this.outputPtr, MODEL_OUTPUT_LEN * 4, 8);
|
|
401
|
+
this.outputPtr = 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// STFT / mask / overlap-add
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
function rms(samples) {
|
|
411
|
+
if (!samples.length) return 0;
|
|
412
|
+
let sum = 0;
|
|
413
|
+
for (let i = 0; i < samples.length; i++) {
|
|
414
|
+
const v = samples[i];
|
|
415
|
+
sum += v * v;
|
|
416
|
+
}
|
|
417
|
+
return Math.sqrt(sum / samples.length);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function peak(samples) {
|
|
421
|
+
let p = 0;
|
|
422
|
+
for (let i = 0; i < samples.length; i++) {
|
|
423
|
+
const v = Math.abs(samples[i]);
|
|
424
|
+
if (v > p) p = v;
|
|
425
|
+
}
|
|
426
|
+
return p;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function clippedCount(samples, limit = 1) {
|
|
430
|
+
let n = 0;
|
|
431
|
+
for (let i = 0; i < samples.length; i++) {
|
|
432
|
+
if (Math.abs(samples[i]) > limit) n++;
|
|
433
|
+
}
|
|
434
|
+
return n;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Denoise a mono float32 array at 16 kHz.
|
|
439
|
+
*
|
|
440
|
+
* @param {Float32Array|number[]} input
|
|
441
|
+
* @param {VoiceEngine} engine
|
|
442
|
+
* @param {{onProgress?: (done: number, total: number) => void,
|
|
443
|
+
* onSpectrum?: (index: number, inputDb: Float32Array, outputDb: Float32Array) => void,
|
|
444
|
+
* maxSpectrumColumns?: number, maxAttenuationDb?: number}} [options]
|
|
445
|
+
* `maxAttenuationDb` is the mask floor in dB in `[-80, 0]` (default
|
|
446
|
+
* −80 = the full learned mask, 0 = exact passthrough). The applied
|
|
447
|
+
* mask is `min(max(|Y[k]| * MASK_GAIN, 10^(dB/20)), 1)`.
|
|
448
|
+
* @returns {{
|
|
449
|
+
* samples: Float32Array, frames: number, durationSeconds: number,
|
|
450
|
+
* processingMs: number, rtf: number, rmsBefore: number, rmsAfter: number,
|
|
451
|
+
* peakBefore: number, peakAfter: number, clippedSamples: number,
|
|
452
|
+
* latencySamples: number
|
|
453
|
+
* }}
|
|
454
|
+
*/
|
|
455
|
+
export function processPcm(input, engine, options = {}) {
|
|
456
|
+
if (!(engine instanceof VoiceEngine)) throw fail('The denoising engine is not valid.');
|
|
457
|
+
const source = input instanceof Float32Array ? input : Float32Array.from(input);
|
|
458
|
+
const maxAttenuationDb = normalizeMaxAttenuationDb(options.maxAttenuationDb);
|
|
459
|
+
const maskFloor = maskFloorForDb(maxAttenuationDb);
|
|
460
|
+
const length = source.length;
|
|
461
|
+
if (length === 0) {
|
|
462
|
+
return {
|
|
463
|
+
samples: new Float32Array(0),
|
|
464
|
+
frames: 0,
|
|
465
|
+
durationSeconds: 0,
|
|
466
|
+
processingMs: 0,
|
|
467
|
+
rtf: 0,
|
|
468
|
+
rmsBefore: 0,
|
|
469
|
+
rmsAfter: 0,
|
|
470
|
+
peakBefore: 0,
|
|
471
|
+
peakAfter: 0,
|
|
472
|
+
clippedSamples: 0,
|
|
473
|
+
latencySamples: LATENCY_SAMPLES,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const durationSeconds = length / SAMPLE_RATE;
|
|
477
|
+
if (durationSeconds > MAX_DURATION_SECONDS) {
|
|
478
|
+
throw fail(`Audio is too long (over ${MAX_DURATION_SECONDS / 60} minutes); trim it first.`);
|
|
479
|
+
}
|
|
480
|
+
for (let i = 0; i < length; i++) {
|
|
481
|
+
if (!Number.isFinite(source[i])) throw fail('The audio contains an invalid sample value.');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const totalLength = length + LATENCY_SAMPLES;
|
|
485
|
+
const frames = Math.floor(totalLength / HOP_SIZE);
|
|
486
|
+
const output = new Float32Array(totalLength);
|
|
487
|
+
|
|
488
|
+
const analysis = new Float32Array(WINDOW_SIZE);
|
|
489
|
+
const overlap = new Float32Array(WINDOW_SIZE);
|
|
490
|
+
const fftRe = new Float64Array(FFT_SIZE);
|
|
491
|
+
const fftIm = new Float64Array(FFT_SIZE);
|
|
492
|
+
// The ring holds the two previous spectra; the current one waits in cur*.
|
|
493
|
+
const specRe = [new Float32Array(BIN_COUNT), new Float32Array(BIN_COUNT)];
|
|
494
|
+
const specIm = [new Float32Array(BIN_COUNT), new Float32Array(BIN_COUNT)];
|
|
495
|
+
const curRe = new Float32Array(BIN_COUNT);
|
|
496
|
+
const curIm = new Float32Array(BIN_COUNT);
|
|
497
|
+
const features = new Float32Array(BIN_COUNT);
|
|
498
|
+
const mask = new Float32Array(BIN_COUNT);
|
|
499
|
+
|
|
500
|
+
const progressEvery = Math.max(1, Math.floor(frames / 50));
|
|
501
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null;
|
|
502
|
+
|
|
503
|
+
// Optional spectrogram capture. It reuses values the audio path already
|
|
504
|
+
// computes and adds exactly one transform (the output panel FFT), which runs
|
|
505
|
+
// only while capture is enabled. All buffers are allocated here and reused
|
|
506
|
+
// for every frame, so enabling capture allocates nothing per frame.
|
|
507
|
+
const onSpectrum = typeof options.onSpectrum === 'function' ? options.onSpectrum : null;
|
|
508
|
+
const requestedColumns = Number(options.maxSpectrumColumns);
|
|
509
|
+
const maxColumns =
|
|
510
|
+
Number.isFinite(requestedColumns) && requestedColumns >= 1 ? Math.floor(requestedColumns) : 0;
|
|
511
|
+
const spectrumStride = maxColumns > 0 ? Math.max(1, Math.ceil(frames / maxColumns)) : 1;
|
|
512
|
+
const spectrumInput = onSpectrum ? new Float32Array(SPEC_BINS) : null;
|
|
513
|
+
const spectrumOutput = onSpectrum ? new Float32Array(SPEC_BINS) : null;
|
|
514
|
+
// Columns are averaged over `spectrumStride` frames instead of dropping frames,
|
|
515
|
+
// otherwise the painted time axis leaves empty columns (visible as dark bands).
|
|
516
|
+
const spectrumAccIn = onSpectrum ? new Float64Array(SPEC_BINS) : null;
|
|
517
|
+
const spectrumAccOut = onSpectrum ? new Float64Array(SPEC_BINS) : null;
|
|
518
|
+
let spectrumPending = 0;
|
|
519
|
+
// Input spectra wait here until the output window covering the same audio
|
|
520
|
+
// span has been emitted. One slot per hop of look-back, plus the slot being
|
|
521
|
+
// written this frame.
|
|
522
|
+
const inputHistory = onSpectrum
|
|
523
|
+
? Array.from({ length: SPECTRUM_HISTORY }, () => new Float32Array(SPEC_BINS))
|
|
524
|
+
: null;
|
|
525
|
+
// The 768 most recently emitted output samples, oldest at outputRingWrite.
|
|
526
|
+
const outputRing = onSpectrum ? new Float32Array(WINDOW_SIZE) : null;
|
|
527
|
+
let outputRingWrite = 0;
|
|
528
|
+
|
|
529
|
+
engine.reset();
|
|
530
|
+
const started = clock();
|
|
531
|
+
|
|
532
|
+
for (let frame = 0; frame < frames; frame++) {
|
|
533
|
+
const inputBase = frame * HOP_SIZE;
|
|
534
|
+
|
|
535
|
+
// Shift the 768-sample analysis window and append one hop.
|
|
536
|
+
analysis.copyWithin(0, HOP_SIZE);
|
|
537
|
+
for (let j = 0; j < HOP_SIZE; j++) {
|
|
538
|
+
const index = inputBase + j;
|
|
539
|
+
analysis[WINDOW_SIZE - HOP_SIZE + j] = index < length ? source[index] : 0;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Forward transform of the windowed 768 samples, zero-padded to 1024.
|
|
543
|
+
fftRe.fill(0);
|
|
544
|
+
fftIm.fill(0);
|
|
545
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
546
|
+
fftRe[i] = Math.fround(analysis[i] * WINDOW[i]);
|
|
547
|
+
}
|
|
548
|
+
FFT_1024.forward(fftRe, fftIm);
|
|
549
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
550
|
+
curRe[k] = fftRe[k];
|
|
551
|
+
curIm[k] = fftIm[k];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Input panel: the windowed analysis spectrum that enters the model. The
|
|
555
|
+
// column is emitted later, when the matching output window is available.
|
|
556
|
+
if (onSpectrum) {
|
|
557
|
+
fillDbColumn(
|
|
558
|
+
curRe,
|
|
559
|
+
curIm,
|
|
560
|
+
SPEC_BINS,
|
|
561
|
+
SPECTRUM_NORM,
|
|
562
|
+
inputHistory[frame % SPECTRUM_HISTORY]
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ln(power + epsilon) features, float32 at every rounding boundary.
|
|
567
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
568
|
+
const re = curRe[k];
|
|
569
|
+
const im = curIm[k];
|
|
570
|
+
const power = Math.fround(Math.fround(re * re) + Math.fround(im * im));
|
|
571
|
+
const scaled = Math.fround(power * FEATURE_POWER_SCALE);
|
|
572
|
+
features[k] = Math.fround(Math.log(Math.fround(scaled + EPSILON)));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const prediction = engine.process(features);
|
|
576
|
+
|
|
577
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
578
|
+
const re = prediction[k];
|
|
579
|
+
const im = prediction[k + BIN_COUNT];
|
|
580
|
+
const power = Math.fround(Math.fround(Math.fround(re * re) + Math.fround(im * im)) + EPSILON);
|
|
581
|
+
const magnitude = Math.fround(Math.sqrt(power));
|
|
582
|
+
const m = Math.fround(magnitude * MASK_GAIN);
|
|
583
|
+
// Apply the maximum-attenuation floor. A −80 dB floor is exactly the
|
|
584
|
+
// historical fixed floor; a 0 dB floor is exactly unity.
|
|
585
|
+
mask[k] = applyMaskFloor(m, maskFloor);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The mask from this frame is applied to the spectrum from two hops ago.
|
|
589
|
+
const slot = frame & 1;
|
|
590
|
+
const targetRe = specRe[slot];
|
|
591
|
+
const targetIm = specIm[slot];
|
|
592
|
+
|
|
593
|
+
fftRe.fill(0);
|
|
594
|
+
fftIm.fill(0);
|
|
595
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
596
|
+
const m = mask[k];
|
|
597
|
+
const re = Math.fround(targetRe[k] * m);
|
|
598
|
+
const im = Math.fround(targetIm[k] * m);
|
|
599
|
+
fftRe[k] = re;
|
|
600
|
+
fftIm[k] = im;
|
|
601
|
+
if (k > 0 && k < BIN_COUNT - 1) {
|
|
602
|
+
fftRe[FFT_SIZE - k] = re;
|
|
603
|
+
fftIm[FFT_SIZE - k] = -im;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
FFT_1024.inverse(fftRe, fftIm);
|
|
607
|
+
|
|
608
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
609
|
+
const reconstructed = Math.fround(fftRe[i]);
|
|
610
|
+
const windowed = Math.fround(Math.fround(reconstructed * WINDOW[i]) * SYNTHESIS_SCALE);
|
|
611
|
+
overlap[i] = Math.fround(overlap[i] + windowed);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// The current spectra become the look-ahead history for later frames.
|
|
615
|
+
targetRe.set(curRe);
|
|
616
|
+
targetIm.set(curIm);
|
|
617
|
+
|
|
618
|
+
const outputBase = HOP_SIZE + frame * HOP_SIZE;
|
|
619
|
+
const available = Math.min(HOP_SIZE, totalLength - outputBase);
|
|
620
|
+
for (let i = 0; i < available; i++) output[outputBase + i] = overlap[i];
|
|
621
|
+
|
|
622
|
+
if (onSpectrum) {
|
|
623
|
+
// Remember the hop that is emitted this frame. The ring always holds
|
|
624
|
+
// the last 768 output samples, oldest first.
|
|
625
|
+
const ring = outputRing;
|
|
626
|
+
let write = outputRingWrite;
|
|
627
|
+
for (let i = 0; i < HOP_SIZE; i++) {
|
|
628
|
+
ring[write] = overlap[i];
|
|
629
|
+
write += 1;
|
|
630
|
+
if (write === WINDOW_SIZE) write = 0;
|
|
631
|
+
}
|
|
632
|
+
outputRingWrite = write;
|
|
633
|
+
|
|
634
|
+
if (frame >= SPECTRUM_ALIGN_HOPS) {
|
|
635
|
+
const column = frame - SPECTRUM_ALIGN_HOPS;
|
|
636
|
+
|
|
637
|
+
// Output panel: window and transform the real emitted waveform. This
|
|
638
|
+
// is the overlap-add output after the synthesis window, not the masked
|
|
639
|
+
// analysis spectrum.
|
|
640
|
+
fftRe.fill(0);
|
|
641
|
+
fftIm.fill(0);
|
|
642
|
+
let read = outputRingWrite;
|
|
643
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
644
|
+
fftRe[i] = Math.fround(ring[read] * WINDOW[i]);
|
|
645
|
+
read += 1;
|
|
646
|
+
if (read === WINDOW_SIZE) read = 0;
|
|
647
|
+
}
|
|
648
|
+
FFT_1024.forward(fftRe, fftIm);
|
|
649
|
+
fillDbColumn(fftRe, fftIm, SPEC_BINS, SPECTRUM_NORM, spectrumOutput);
|
|
650
|
+
|
|
651
|
+
// Input panel: the frame whose analysis window covers the same audio
|
|
652
|
+
// span as those 768 output samples.
|
|
653
|
+
spectrumInput.set(inputHistory[(frame - SPECTRUM_ALIGN_HOPS) % SPECTRUM_HISTORY]);
|
|
654
|
+
|
|
655
|
+
for (let k = 0; k < SPEC_BINS; k++) {
|
|
656
|
+
spectrumAccIn[k] += spectrumInput[k];
|
|
657
|
+
spectrumAccOut[k] += spectrumOutput[k];
|
|
658
|
+
}
|
|
659
|
+
spectrumPending += 1;
|
|
660
|
+
if (spectrumPending >= spectrumStride) {
|
|
661
|
+
const inv = 1 / spectrumPending;
|
|
662
|
+
for (let k = 0; k < SPEC_BINS; k++) {
|
|
663
|
+
spectrumInput[k] = Math.fround(spectrumAccIn[k] * inv);
|
|
664
|
+
spectrumOutput[k] = Math.fround(spectrumAccOut[k] * inv);
|
|
665
|
+
spectrumAccIn[k] = 0;
|
|
666
|
+
spectrumAccOut[k] = 0;
|
|
667
|
+
}
|
|
668
|
+
onSpectrum(column, spectrumInput, spectrumOutput);
|
|
669
|
+
spectrumPending = 0;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
overlap.copyWithin(0, HOP_SIZE);
|
|
675
|
+
overlap.fill(0, WINDOW_SIZE - HOP_SIZE);
|
|
676
|
+
|
|
677
|
+
if (onProgress && ((frame + 1) % progressEvery === 0 || frame + 1 === frames)) {
|
|
678
|
+
onProgress(frame + 1, frames);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const processingMs = clock() - started;
|
|
683
|
+
const cleaned = output.slice(LATENCY_SAMPLES, LATENCY_SAMPLES + length);
|
|
684
|
+
return {
|
|
685
|
+
samples: cleaned,
|
|
686
|
+
frames,
|
|
687
|
+
durationSeconds,
|
|
688
|
+
processingMs,
|
|
689
|
+
rtf: durationSeconds > 0 ? processingMs / 1000 / durationSeconds : 0,
|
|
690
|
+
rmsBefore: rms(source),
|
|
691
|
+
rmsAfter: rms(cleaned),
|
|
692
|
+
peakBefore: peak(source),
|
|
693
|
+
peakAfter: peak(cleaned),
|
|
694
|
+
clippedSamples: clippedCount(cleaned),
|
|
695
|
+
latencySamples: LATENCY_SAMPLES,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
// WAV encoding and browser decoding
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
function writeAscii(view, offset, text) {
|
|
704
|
+
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Encode mono float samples as 16-bit PCM WAV.
|
|
709
|
+
* @param {Float32Array} samples
|
|
710
|
+
* @param {number} [sampleRate]
|
|
711
|
+
* @returns {ArrayBuffer}
|
|
712
|
+
*/
|
|
713
|
+
export function encodeWav(samples, sampleRate = SAMPLE_RATE) {
|
|
714
|
+
const dataBytes = samples.length * 2;
|
|
715
|
+
const buffer = new ArrayBuffer(44 + dataBytes);
|
|
716
|
+
const view = new DataView(buffer);
|
|
717
|
+
writeAscii(view, 0, 'RIFF');
|
|
718
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
719
|
+
writeAscii(view, 8, 'WAVE');
|
|
720
|
+
writeAscii(view, 12, 'fmt ');
|
|
721
|
+
view.setUint32(16, 16, true);
|
|
722
|
+
view.setUint16(20, 1, true);
|
|
723
|
+
view.setUint16(22, 1, true);
|
|
724
|
+
view.setUint32(24, sampleRate, true);
|
|
725
|
+
view.setUint32(28, sampleRate * 2, true);
|
|
726
|
+
view.setUint16(32, 2, true);
|
|
727
|
+
view.setUint16(34, 16, true);
|
|
728
|
+
writeAscii(view, 36, 'data');
|
|
729
|
+
view.setUint32(40, dataBytes, true);
|
|
730
|
+
for (let i = 0; i < samples.length; i++) {
|
|
731
|
+
const value = samples[i];
|
|
732
|
+
const finite = Number.isFinite(value) ? value : 0;
|
|
733
|
+
const clamped = finite < -1 ? -1 : finite > 1 ? 1 : finite;
|
|
734
|
+
let quantized = Math.round(clamped * 32768);
|
|
735
|
+
if (quantized > 32767) quantized = 32767;
|
|
736
|
+
if (quantized < -32768) quantized = -32768;
|
|
737
|
+
view.setInt16(44 + i * 2, quantized, true);
|
|
738
|
+
}
|
|
739
|
+
return buffer;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function getOfflineAudioContext() {
|
|
743
|
+
if (typeof window === 'undefined') return null;
|
|
744
|
+
return window.OfflineAudioContext || window.webkitOfflineAudioContext || null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Decode a File/Blob with the browser's Web Audio decoder and return mono
|
|
749
|
+
* float32 samples at 16 kHz. The decode context itself runs at 16 kHz, so the
|
|
750
|
+
* browser performs the sample-rate conversion; an OfflineAudioContext render
|
|
751
|
+
* is the explicit fallback if a browser returns a different rate.
|
|
752
|
+
*
|
|
753
|
+
* Browser-only: `OfflineAudioContext` is not available in workers.
|
|
754
|
+
*
|
|
755
|
+
* @param {Blob} file
|
|
756
|
+
* @returns {Promise<Float32Array>}
|
|
757
|
+
*/
|
|
758
|
+
export async function decodeAudioFile(file) {
|
|
759
|
+
const Ctx = getOfflineAudioContext();
|
|
760
|
+
if (!Ctx || typeof file?.arrayBuffer !== 'function') {
|
|
761
|
+
throw fail('This browser cannot decode audio. Please use a current browser.');
|
|
762
|
+
}
|
|
763
|
+
if (typeof location !== 'undefined' && location.protocol === 'file:') {
|
|
764
|
+
throw fail('Open this page through a static web server; browsers do not allow loading WebAssembly from a local file.');
|
|
765
|
+
}
|
|
766
|
+
const bytes = await file.arrayBuffer();
|
|
767
|
+
let decoded;
|
|
768
|
+
try {
|
|
769
|
+
// Decoding in a 16 kHz offline context resamples during decode.
|
|
770
|
+
const decodeContext = new Ctx(1, 1, SAMPLE_RATE);
|
|
771
|
+
decoded = await decodeContext.decodeAudioData(bytes);
|
|
772
|
+
} catch {
|
|
773
|
+
throw fail('Could not decode this audio file. Try another file, or convert it to WAV.');
|
|
774
|
+
}
|
|
775
|
+
if (!decoded || decoded.length === 0) throw fail('The audio file is empty.');
|
|
776
|
+
|
|
777
|
+
if (Math.abs(decoded.sampleRate - SAMPLE_RATE) < 1e-6) {
|
|
778
|
+
return mixToMono(decoded);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Fallback: render the decoded buffer through a 16 kHz mono context.
|
|
782
|
+
const targetLength = Math.max(1, Math.ceil(decoded.duration * SAMPLE_RATE));
|
|
783
|
+
const resampleContext = new Ctx(1, targetLength, SAMPLE_RATE);
|
|
784
|
+
const source = resampleContext.createBufferSource();
|
|
785
|
+
source.buffer = decoded;
|
|
786
|
+
source.connect(resampleContext.destination);
|
|
787
|
+
source.start(0);
|
|
788
|
+
const rendered = await resampleContext.startRendering();
|
|
789
|
+
return mixToMono(rendered);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function mixToMono(audioBuffer) {
|
|
793
|
+
const channels = audioBuffer.numberOfChannels;
|
|
794
|
+
const length = audioBuffer.length;
|
|
795
|
+
const mono = new Float32Array(length);
|
|
796
|
+
if (channels === 1) {
|
|
797
|
+
mono.set(audioBuffer.getChannelData(0));
|
|
798
|
+
return mono;
|
|
799
|
+
}
|
|
800
|
+
for (let c = 0; c < channels; c++) {
|
|
801
|
+
const data = audioBuffer.getChannelData(c);
|
|
802
|
+
for (let i = 0; i < length; i++) mono[i] += data[i];
|
|
803
|
+
}
|
|
804
|
+
const scale = 1 / channels;
|
|
805
|
+
for (let i = 0; i < length; i++) mono[i] *= scale;
|
|
806
|
+
return mono;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Format seconds as m:ss.s for the UI. */
|
|
810
|
+
export function formatDuration(seconds) {
|
|
811
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '—';
|
|
812
|
+
const total = Math.round(seconds * 10) / 10;
|
|
813
|
+
const minutes = Math.floor(total / 60);
|
|
814
|
+
const rest = total - minutes * 60;
|
|
815
|
+
return `${minutes}:${rest.toFixed(1).padStart(4, '0')}`;
|
|
816
|
+
}
|