@cleanfield/web 0.0.0-stage → 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/cf_wasm.wasm +0 -0
- package/dsp.js +816 -0
- package/index.js +27 -0
- package/live-dsp.js +528 -0
- package/package.json +8 -4
- package/worklet-processor.js +327 -0
- package/README.md +0 -3
package/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// CleanField Voice 1.0 - browser glue.
|
|
2
|
+
// Usage:
|
|
3
|
+
// import { attach } from "@cleanfield/web";
|
|
4
|
+
// const { node, detach } = await attach(audioContext, { gain: 1.0 });
|
|
5
|
+
// source.connect(node).connect(audioContext.destination);
|
|
6
|
+
const WASM_URL = new URL("./cf_wasm.wasm", import.meta.url).href;
|
|
7
|
+
const WORKLET_URL = new URL("./worklet-processor.js", import.meta.url).href;
|
|
8
|
+
|
|
9
|
+
export async function attach(context, options = {}) {
|
|
10
|
+
if (!context?.audioWorklet) throw new Error("AudioWorklet is required");
|
|
11
|
+
await context.audioWorklet.addModule(WORKLET_URL);
|
|
12
|
+
const response = await fetch(WASM_URL, { credentials: "same-origin" });
|
|
13
|
+
if (!response.ok) throw new Error(`wasm fetch failed: ${response.status}`);
|
|
14
|
+
const bytes = await response.arrayBuffer();
|
|
15
|
+
const node = new AudioWorkletNode(context, "cleanfield-voice", {
|
|
16
|
+
numberOfInputs: 1,
|
|
17
|
+
numberOfOutputs: 1,
|
|
18
|
+
outputChannelCount: [1],
|
|
19
|
+
channelCount: 1,
|
|
20
|
+
channelCountMode: "explicit",
|
|
21
|
+
channelInterpretation: "speakers",
|
|
22
|
+
});
|
|
23
|
+
node.port.postMessage({ type: "init", wasm: bytes, gain: options.gain ?? 1.0 });
|
|
24
|
+
return { node, detach: () => { try { node.disconnect(); } catch {} node.port.postMessage({ type: "close" }); } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const version = "1.0.0";
|
package/live-dsp.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleanField Voice 1.0 — shared live (microphone) DSP.
|
|
3
|
+
*
|
|
4
|
+
* This module is plain ES module JavaScript with no dependencies beyond the
|
|
5
|
+
* numerical primitives in `dsp.js`. It is imported unchanged by the
|
|
6
|
+
* AudioWorklet processor and by the Node streaming test, so the exact code
|
|
7
|
+
* that runs on the audio render thread is covered without a browser.
|
|
8
|
+
*
|
|
9
|
+
* Contract: 16 kHz mono, 1024-point FFT, 768-sample periodic Hann window,
|
|
10
|
+
* 256-sample hop, feature ln(|X[k]|^2 + 1e-20), mask from the engine's 1026
|
|
11
|
+
* outputs, inverse FFT, overlap-add with the 8/9 synthesis normalisation.
|
|
12
|
+
* One `StreamingDenoiser` keeps the engine's cross-frame state (5-frame history
|
|
13
|
+
* and LSTM state); it is reset once in the constructor and never mid-stream.
|
|
14
|
+
*
|
|
15
|
+
* The live spectrogram is fed by the real audio path: the input panel is the
|
|
16
|
+
* windowed analysis spectrum that enters the model, and the output panel is a
|
|
17
|
+
* windowed transform of the 768 most recently emitted output samples, after
|
|
18
|
+
* the synthesis window and overlap-add. Capture never changes the audio path.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
BIN_COUNT,
|
|
23
|
+
EPSILON,
|
|
24
|
+
FEATURE_POWER_SCALE,
|
|
25
|
+
FFT_1024,
|
|
26
|
+
FFT_SIZE,
|
|
27
|
+
HOP_SIZE,
|
|
28
|
+
LATENCY_SAMPLES,
|
|
29
|
+
MASK_GAIN,
|
|
30
|
+
MAX_ATTENUATION_DB,
|
|
31
|
+
SAMPLE_RATE,
|
|
32
|
+
SPECTRUM_ALIGN_HOPS,
|
|
33
|
+
SPECTRUM_HISTORY,
|
|
34
|
+
SPECTRUM_NORM,
|
|
35
|
+
SYNTHESIS_SCALE,
|
|
36
|
+
VoiceEngine,
|
|
37
|
+
WINDOW,
|
|
38
|
+
WINDOW_SIZE,
|
|
39
|
+
applyMaskFloor,
|
|
40
|
+
maskFloorForDb,
|
|
41
|
+
normalizeMaxAttenuationDb,
|
|
42
|
+
} from './dsp.js';
|
|
43
|
+
import { SPEC_BINS, fillDbColumn } from './spectrogram-core.js';
|
|
44
|
+
|
|
45
|
+
const RING_SIZE = 1024;
|
|
46
|
+
const RING_MASK = RING_SIZE - 1;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A live maximum-attenuation change is ramped linearly in dB over this many
|
|
50
|
+
* samples (100 ms at 16 kHz, i.e. 6.25 hops). Each hop moves the applied floor
|
|
51
|
+
* by at most `MAX_ATTENUATION_DB * HOP_SIZE / ATTENUATION_RAMP_SAMPLES`; the
|
|
52
|
+
* overlap-add windows then smooth the per-frame mask change further, so a
|
|
53
|
+
* slider move cannot click.
|
|
54
|
+
*/
|
|
55
|
+
export const ATTENUATION_RAMP_SAMPLES = 1600;
|
|
56
|
+
const ATTENUATION_STEP = (MAX_ATTENUATION_DB * HOP_SIZE) / ATTENUATION_RAMP_SAMPLES;
|
|
57
|
+
|
|
58
|
+
const clock =
|
|
59
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
60
|
+
? () => performance.now()
|
|
61
|
+
: () => Date.now();
|
|
62
|
+
|
|
63
|
+
function fail(message) {
|
|
64
|
+
const error = new Error(message);
|
|
65
|
+
error.userFacing = true;
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Frame-by-frame 16 kHz denoiser for live input.
|
|
71
|
+
*
|
|
72
|
+
* The arithmetic mirrors `processPcm()` frame for frame: the same analysis
|
|
73
|
+
* window, feature scaling, mask, synthesis window, overlap-add and 8/9
|
|
74
|
+
* normalisation. Output is delayed by `LATENCY_SAMPLES` (1280) relative to
|
|
75
|
+
* the input. `processHop()` returns a reused 256-sample buffer.
|
|
76
|
+
*/
|
|
77
|
+
export class StreamingDenoiser {
|
|
78
|
+
constructor(engine, options = {}) {
|
|
79
|
+
if (!(engine instanceof VoiceEngine)) throw fail('The denoising engine is not valid.');
|
|
80
|
+
this.engine = engine;
|
|
81
|
+
this.captureSpectrum = Boolean(options.captureSpectrum);
|
|
82
|
+
this.ring = new Float32Array(RING_SIZE);
|
|
83
|
+
this.writeIndex = 0;
|
|
84
|
+
this.analysis = new Float32Array(WINDOW_SIZE);
|
|
85
|
+
this.overlap = new Float32Array(WINDOW_SIZE);
|
|
86
|
+
this.fftRe = new Float64Array(FFT_SIZE);
|
|
87
|
+
this.fftIm = new Float64Array(FFT_SIZE);
|
|
88
|
+
this.specRe = [new Float32Array(BIN_COUNT), new Float32Array(BIN_COUNT)];
|
|
89
|
+
this.specIm = [new Float32Array(BIN_COUNT), new Float32Array(BIN_COUNT)];
|
|
90
|
+
this.curRe = new Float32Array(BIN_COUNT);
|
|
91
|
+
this.curIm = new Float32Array(BIN_COUNT);
|
|
92
|
+
// Capture-only buffers. They are allocated once per session; processHop
|
|
93
|
+
// never allocates. The input spectra wait in a short ring until the
|
|
94
|
+
// matching emitted output window exists, and the output ring holds the
|
|
95
|
+
// last 768 emitted samples.
|
|
96
|
+
this.inputHistory = this.captureSpectrum
|
|
97
|
+
? Array.from({ length: SPECTRUM_HISTORY }, () => new Float32Array(SPEC_BINS))
|
|
98
|
+
: null;
|
|
99
|
+
this.outputRing = this.captureSpectrum ? new Float32Array(WINDOW_SIZE) : null;
|
|
100
|
+
this.outputRingWrite = 0;
|
|
101
|
+
this.features = new Float32Array(BIN_COUNT);
|
|
102
|
+
this.mask = new Float32Array(BIN_COUNT);
|
|
103
|
+
this.hopOutput = new Float32Array(HOP_SIZE);
|
|
104
|
+
// Applied mask floor in dB and the value the slider is moving toward. They
|
|
105
|
+
// start equal, so an unchanged session is bit-identical to the offline path.
|
|
106
|
+
this.maxAttenuationDb = normalizeMaxAttenuationDb(options.maxAttenuationDb);
|
|
107
|
+
this.attenuationTarget = this.maxAttenuationDb;
|
|
108
|
+
this.spectrumInput = this.captureSpectrum ? new Float32Array(SPEC_BINS) : null;
|
|
109
|
+
this.spectrumOutput = this.captureSpectrum ? new Float32Array(SPEC_BINS) : null;
|
|
110
|
+
this.spectrumValid = false;
|
|
111
|
+
this.frames = 0;
|
|
112
|
+
this.hopMsTotal = 0;
|
|
113
|
+
// A live session must not reset the engine after the stream starts.
|
|
114
|
+
engine.reset();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get averageHopMs() {
|
|
118
|
+
return this.frames > 0 ? this.hopMsTotal / this.frames : 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Request a new mask floor in dB. The value is applied with the 100 ms
|
|
123
|
+
* linear-in-dB ramp in `processHop`, never by resetting the engine.
|
|
124
|
+
* @param {number} value in `[-80, 0]` dB
|
|
125
|
+
*/
|
|
126
|
+
setMaxAttenuationDb(value) {
|
|
127
|
+
this.attenuationTarget = normalizeMaxAttenuationDb(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Push one 256-sample hop and return 256 output samples (reused array).
|
|
132
|
+
* @param {Float32Array} hop
|
|
133
|
+
* @returns {Float32Array}
|
|
134
|
+
*/
|
|
135
|
+
processHop(hop) {
|
|
136
|
+
if (!(hop instanceof Float32Array) || hop.length !== HOP_SIZE) {
|
|
137
|
+
throw fail(`A live hop must contain exactly ${HOP_SIZE} samples.`);
|
|
138
|
+
}
|
|
139
|
+
const started = clock();
|
|
140
|
+
|
|
141
|
+
// Slew the applied mask floor toward the slider value once per hop. A
|
|
142
|
+
// target equal to the current value is a no-op, so an untouched session is
|
|
143
|
+
// bit-identical to the offline pipeline.
|
|
144
|
+
if (this.maxAttenuationDb !== this.attenuationTarget) {
|
|
145
|
+
this.maxAttenuationDb =
|
|
146
|
+
this.maxAttenuationDb < this.attenuationTarget
|
|
147
|
+
? Math.min(this.attenuationTarget, this.maxAttenuationDb + ATTENUATION_STEP)
|
|
148
|
+
: Math.max(this.attenuationTarget, this.maxAttenuationDb - ATTENUATION_STEP);
|
|
149
|
+
}
|
|
150
|
+
const maskFloor = maskFloorForDb(this.maxAttenuationDb);
|
|
151
|
+
|
|
152
|
+
// Ring-buffer the new hop, then read the most recent 768 samples in order.
|
|
153
|
+
let write = this.writeIndex;
|
|
154
|
+
const ring = this.ring;
|
|
155
|
+
for (let i = 0; i < HOP_SIZE; i++) ring[(write + i) & RING_MASK] = hop[i];
|
|
156
|
+
write = (write + HOP_SIZE) & RING_MASK;
|
|
157
|
+
this.writeIndex = write;
|
|
158
|
+
|
|
159
|
+
const analysis = this.analysis;
|
|
160
|
+
let read = (write - WINDOW_SIZE) & RING_MASK;
|
|
161
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
162
|
+
analysis[i] = ring[read];
|
|
163
|
+
read = (read + 1) & RING_MASK;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Forward transform of the windowed 768 samples, zero-padded to 1024.
|
|
167
|
+
const fftRe = this.fftRe;
|
|
168
|
+
const fftIm = this.fftIm;
|
|
169
|
+
fftRe.fill(0);
|
|
170
|
+
fftIm.fill(0);
|
|
171
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
172
|
+
fftRe[i] = Math.fround(analysis[i] * WINDOW[i]);
|
|
173
|
+
}
|
|
174
|
+
FFT_1024.forward(fftRe, fftIm);
|
|
175
|
+
|
|
176
|
+
const curRe = this.curRe;
|
|
177
|
+
const curIm = this.curIm;
|
|
178
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
179
|
+
curRe[k] = fftRe[k];
|
|
180
|
+
curIm[k] = fftIm[k];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Input panel: the windowed analysis spectrum that enters the model. It
|
|
184
|
+
// is emitted once the matching output window has been produced.
|
|
185
|
+
if (this.captureSpectrum) {
|
|
186
|
+
fillDbColumn(
|
|
187
|
+
curRe,
|
|
188
|
+
curIm,
|
|
189
|
+
SPEC_BINS,
|
|
190
|
+
SPECTRUM_NORM,
|
|
191
|
+
this.inputHistory[this.frames % SPECTRUM_HISTORY]
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const features = this.features;
|
|
196
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
197
|
+
const re = curRe[k];
|
|
198
|
+
const im = curIm[k];
|
|
199
|
+
const power = Math.fround(Math.fround(re * re) + Math.fround(im * im));
|
|
200
|
+
const scaled = Math.fround(power * FEATURE_POWER_SCALE);
|
|
201
|
+
features[k] = Math.fround(Math.log(Math.fround(scaled + EPSILON)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const prediction = this.engine.process(features);
|
|
205
|
+
|
|
206
|
+
const mask = this.mask;
|
|
207
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
208
|
+
const re = prediction[k];
|
|
209
|
+
const im = prediction[k + BIN_COUNT];
|
|
210
|
+
const power = Math.fround(
|
|
211
|
+
Math.fround(Math.fround(re * re) + Math.fround(im * im)) + EPSILON
|
|
212
|
+
);
|
|
213
|
+
const magnitude = Math.fround(Math.sqrt(power));
|
|
214
|
+
const m = Math.fround(magnitude * MASK_GAIN);
|
|
215
|
+
mask[k] = applyMaskFloor(m, maskFloor);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The mask from this frame is applied to the spectrum from two hops ago.
|
|
219
|
+
const frame = this.frames;
|
|
220
|
+
const slot = frame & 1;
|
|
221
|
+
const targetRe = this.specRe[slot];
|
|
222
|
+
const targetIm = this.specIm[slot];
|
|
223
|
+
|
|
224
|
+
fftRe.fill(0);
|
|
225
|
+
fftIm.fill(0);
|
|
226
|
+
for (let k = 0; k < BIN_COUNT; k++) {
|
|
227
|
+
const m = mask[k];
|
|
228
|
+
const re = Math.fround(targetRe[k] * m);
|
|
229
|
+
const im = Math.fround(targetIm[k] * m);
|
|
230
|
+
fftRe[k] = re;
|
|
231
|
+
fftIm[k] = im;
|
|
232
|
+
if (k > 0 && k < BIN_COUNT - 1) {
|
|
233
|
+
fftRe[FFT_SIZE - k] = re;
|
|
234
|
+
fftIm[FFT_SIZE - k] = -im;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
FFT_1024.inverse(fftRe, fftIm);
|
|
238
|
+
|
|
239
|
+
const overlap = this.overlap;
|
|
240
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
241
|
+
const reconstructed = Math.fround(fftRe[i]);
|
|
242
|
+
const windowed = Math.fround(Math.fround(reconstructed * WINDOW[i]) * SYNTHESIS_SCALE);
|
|
243
|
+
overlap[i] = Math.fround(overlap[i] + windowed);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The current spectra become the look-ahead history for later frames.
|
|
247
|
+
targetRe.set(curRe);
|
|
248
|
+
targetIm.set(curIm);
|
|
249
|
+
|
|
250
|
+
const output = this.hopOutput;
|
|
251
|
+
for (let i = 0; i < HOP_SIZE; i++) output[i] = overlap[i];
|
|
252
|
+
|
|
253
|
+
this.spectrumValid = false;
|
|
254
|
+
if (this.captureSpectrum) {
|
|
255
|
+
// Remember the hop that is emitted this frame.
|
|
256
|
+
const outputRing = this.outputRing;
|
|
257
|
+
let write = this.outputRingWrite;
|
|
258
|
+
for (let i = 0; i < HOP_SIZE; i++) {
|
|
259
|
+
outputRing[write] = output[i];
|
|
260
|
+
write += 1;
|
|
261
|
+
if (write === WINDOW_SIZE) write = 0;
|
|
262
|
+
}
|
|
263
|
+
this.outputRingWrite = write;
|
|
264
|
+
|
|
265
|
+
if (frame >= SPECTRUM_ALIGN_HOPS) {
|
|
266
|
+
// Output panel: window and transform the real emitted waveform, after
|
|
267
|
+
// the synthesis window and overlap-add.
|
|
268
|
+
fftRe.fill(0);
|
|
269
|
+
fftIm.fill(0);
|
|
270
|
+
let read = this.outputRingWrite;
|
|
271
|
+
for (let i = 0; i < WINDOW_SIZE; i++) {
|
|
272
|
+
fftRe[i] = Math.fround(outputRing[read] * WINDOW[i]);
|
|
273
|
+
read += 1;
|
|
274
|
+
if (read === WINDOW_SIZE) read = 0;
|
|
275
|
+
}
|
|
276
|
+
FFT_1024.forward(fftRe, fftIm);
|
|
277
|
+
fillDbColumn(fftRe, fftIm, SPEC_BINS, SPECTRUM_NORM, this.spectrumOutput);
|
|
278
|
+
|
|
279
|
+
// Input panel: the frame whose analysis window covers the same span.
|
|
280
|
+
this.spectrumInput.set(
|
|
281
|
+
this.inputHistory[(frame - SPECTRUM_ALIGN_HOPS) % SPECTRUM_HISTORY]
|
|
282
|
+
);
|
|
283
|
+
this.spectrumValid = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
overlap.copyWithin(0, HOP_SIZE);
|
|
288
|
+
overlap.fill(0, WINDOW_SIZE - HOP_SIZE);
|
|
289
|
+
|
|
290
|
+
this.frames = frame + 1;
|
|
291
|
+
this.hopMsTotal += clock() - started;
|
|
292
|
+
return output;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Linear sample-rate converter with a fractional read position.
|
|
298
|
+
*
|
|
299
|
+
* Used only when the AudioContext refuses the requested 16 kHz rate; at
|
|
300
|
+
* 16 kHz the live pipeline runs without any conversion.
|
|
301
|
+
*/
|
|
302
|
+
export class LinearResampler {
|
|
303
|
+
constructor(inputRate, outputRate) {
|
|
304
|
+
if (!(inputRate > 0) || !(outputRate > 0)) {
|
|
305
|
+
throw fail('Sample rates must be positive numbers.');
|
|
306
|
+
}
|
|
307
|
+
this.step = inputRate / outputRate;
|
|
308
|
+
this.buffer = new Float32Array(4096);
|
|
309
|
+
this.length = 0;
|
|
310
|
+
this.position = 0;
|
|
311
|
+
this.output = new Float32Array(1024);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @param {Float32Array} input @returns {Float32Array} resampled samples */
|
|
315
|
+
process(input) {
|
|
316
|
+
const chunk = input instanceof Float32Array ? input : Float32Array.from(input);
|
|
317
|
+
this._append(chunk);
|
|
318
|
+
|
|
319
|
+
const needed = Math.ceil(chunk.length / this.step) + 8;
|
|
320
|
+
if (this.output.length < needed) this.output = new Float32Array(needed);
|
|
321
|
+
|
|
322
|
+
const buffer = this.buffer;
|
|
323
|
+
const output = this.output;
|
|
324
|
+
const limit = this.length - 1;
|
|
325
|
+
let position = this.position;
|
|
326
|
+
let count = 0;
|
|
327
|
+
while (position < limit) {
|
|
328
|
+
const index = Math.floor(position);
|
|
329
|
+
const fraction = position - index;
|
|
330
|
+
const a = buffer[index];
|
|
331
|
+
output[count++] = a + (buffer[index + 1] - a) * fraction;
|
|
332
|
+
position += this.step;
|
|
333
|
+
}
|
|
334
|
+
this.position = position;
|
|
335
|
+
|
|
336
|
+
if (this.position > 2048) this._compact();
|
|
337
|
+
return output.subarray(0, count);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_append(chunk) {
|
|
341
|
+
if (this.length + chunk.length > this.buffer.length) {
|
|
342
|
+
this._compact();
|
|
343
|
+
if (this.length + chunk.length > this.buffer.length) {
|
|
344
|
+
const next = new Float32Array(
|
|
345
|
+
Math.max(this.buffer.length * 2, this.length + chunk.length + 16)
|
|
346
|
+
);
|
|
347
|
+
next.set(this.buffer.subarray(0, this.length));
|
|
348
|
+
this.buffer = next;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
this.buffer.set(chunk, this.length);
|
|
352
|
+
this.length += chunk.length;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_compact() {
|
|
356
|
+
const drop = Math.floor(this.position) - 1;
|
|
357
|
+
if (drop > 0) {
|
|
358
|
+
this.buffer.copyWithin(0, drop, this.length);
|
|
359
|
+
this.length -= drop;
|
|
360
|
+
this.position -= drop;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function normalizeGain(value) {
|
|
366
|
+
const gain = Number(value);
|
|
367
|
+
if (!Number.isFinite(gain) || gain < 0) return 1;
|
|
368
|
+
return Math.min(8, Math.max(0, gain));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Block-based live denoiser shared by the AudioWorklet and the Node test.
|
|
373
|
+
*
|
|
374
|
+
* Input and output blocks are at the same rate (the AudioContext rate):
|
|
375
|
+
* capture is converted to 16 kHz when needed, processed one 256-sample hop at
|
|
376
|
+
* a time, then converted back for playback. The engine keeps advancing in Raw
|
|
377
|
+
* mode so an A/B switch never resets the model.
|
|
378
|
+
*/
|
|
379
|
+
export class LiveDenoiser {
|
|
380
|
+
constructor(engine, options = {}) {
|
|
381
|
+
if (!(engine instanceof VoiceEngine)) throw fail('The denoising engine is not valid.');
|
|
382
|
+
const inputRate = Number(options.inputRate) > 0 ? Number(options.inputRate) : SAMPLE_RATE;
|
|
383
|
+
const outputRate = Number(options.outputRate) > 0 ? Number(options.outputRate) : SAMPLE_RATE;
|
|
384
|
+
this.inputRate = inputRate;
|
|
385
|
+
this.outputRate = outputRate;
|
|
386
|
+
this.streaming = new StreamingDenoiser(engine, {
|
|
387
|
+
captureSpectrum: Boolean(options.captureSpectrum),
|
|
388
|
+
maxAttenuationDb: options.maxAttenuationDb,
|
|
389
|
+
});
|
|
390
|
+
this.captureSpectrum = Boolean(options.captureSpectrum);
|
|
391
|
+
this.onSpectrum = typeof options.onSpectrum === 'function' ? options.onSpectrum : null;
|
|
392
|
+
this.gain = normalizeGain(options.gain);
|
|
393
|
+
this.bypass = Boolean(options.bypass);
|
|
394
|
+
this.down = inputRate === SAMPLE_RATE ? null : new LinearResampler(inputRate, SAMPLE_RATE);
|
|
395
|
+
this.up = outputRate === SAMPLE_RATE ? null : new LinearResampler(SAMPLE_RATE, outputRate);
|
|
396
|
+
this.hop = new Float32Array(HOP_SIZE);
|
|
397
|
+
this.hopFill = 0;
|
|
398
|
+
this.queue = new Float32Array(8192);
|
|
399
|
+
this.queueHead = 0;
|
|
400
|
+
this.queueTail = 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
get frames() {
|
|
404
|
+
return this.streaming.frames;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get averageHopMs() {
|
|
408
|
+
return this.streaming.averageHopMs;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
get latencySamples() {
|
|
412
|
+
return LATENCY_SAMPLES;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
setGain(value) {
|
|
416
|
+
this.gain = normalizeGain(value);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
setMaxAttenuationDb(value) {
|
|
420
|
+
this.streaming.setMaxAttenuationDb(value);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
setBypass(value) {
|
|
424
|
+
this.bypass = Boolean(value);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Process one block at the context rate.
|
|
429
|
+
* @param {Float32Array} input
|
|
430
|
+
* @param {Float32Array} output same length as input
|
|
431
|
+
*/
|
|
432
|
+
processBlock(input, output) {
|
|
433
|
+
if (!(input instanceof Float32Array) || !(output instanceof Float32Array)) {
|
|
434
|
+
throw fail('Live audio blocks must be Float32Array instances.');
|
|
435
|
+
}
|
|
436
|
+
const length = Math.min(input.length, output.length);
|
|
437
|
+
if (length === 0) return;
|
|
438
|
+
|
|
439
|
+
// 1. Downsample the capture to 16 kHz when the context rate differs.
|
|
440
|
+
let pcm = input;
|
|
441
|
+
let count = length;
|
|
442
|
+
if (this.down) {
|
|
443
|
+
pcm = this.down.process(length === input.length ? input : input.subarray(0, length));
|
|
444
|
+
count = pcm.length;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 2. Accumulate hops; the engine is advanced in Raw mode too.
|
|
448
|
+
for (let i = 0; i < count; i++) {
|
|
449
|
+
this.hop[this.hopFill++] = pcm[i];
|
|
450
|
+
if (this.hopFill === HOP_SIZE) {
|
|
451
|
+
const hopOutput = this.streaming.processHop(this.hop);
|
|
452
|
+
if (this.captureSpectrum && this.onSpectrum && this.streaming.spectrumValid) {
|
|
453
|
+
this.onSpectrum(this.streaming);
|
|
454
|
+
}
|
|
455
|
+
const gain = this.gain;
|
|
456
|
+
if (gain === 1) {
|
|
457
|
+
this._enqueue(hopOutput, HOP_SIZE);
|
|
458
|
+
} else {
|
|
459
|
+
for (let j = 0; j < HOP_SIZE; j++) hopOutput[j] *= gain;
|
|
460
|
+
this._enqueue(hopOutput, HOP_SIZE);
|
|
461
|
+
}
|
|
462
|
+
this.hopFill = 0;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 3. Playback block.
|
|
467
|
+
if (this.bypass) {
|
|
468
|
+
output.set(input.subarray(0, length));
|
|
469
|
+
// Keep the processed queue in step without playing it, so switching back
|
|
470
|
+
// to Denoised does not replay stale audio.
|
|
471
|
+
this._discard(length);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let written = 0;
|
|
476
|
+
const available = this.queueTail - this.queueHead;
|
|
477
|
+
if (available > 0) {
|
|
478
|
+
written = Math.min(available, length);
|
|
479
|
+
output.set(this.queue.subarray(this.queueHead, this.queueHead + written), 0);
|
|
480
|
+
this.queueHead += written;
|
|
481
|
+
if (this.queueHead === this.queueTail) {
|
|
482
|
+
this.queueHead = 0;
|
|
483
|
+
this.queueTail = 0;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (written < length) output.fill(0, written, length);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_enqueue(samples, count) {
|
|
490
|
+
let source = samples;
|
|
491
|
+
let sourceCount = count;
|
|
492
|
+
if (this.up) {
|
|
493
|
+
const resampled = this.up.process(samples.subarray(0, count));
|
|
494
|
+
source = resampled;
|
|
495
|
+
sourceCount = resampled.length;
|
|
496
|
+
}
|
|
497
|
+
if (sourceCount === 0) return;
|
|
498
|
+
|
|
499
|
+
if (this.queueTail + sourceCount > this.queue.length) {
|
|
500
|
+
const used = this.queueTail - this.queueHead;
|
|
501
|
+
if (used + sourceCount > this.queue.length) {
|
|
502
|
+
const next = new Float32Array(
|
|
503
|
+
Math.max(this.queue.length * 2, used + sourceCount + 64)
|
|
504
|
+
);
|
|
505
|
+
next.set(this.queue.subarray(this.queueHead, this.queueTail));
|
|
506
|
+
this.queue = next;
|
|
507
|
+
this.queueHead = 0;
|
|
508
|
+
this.queueTail = used;
|
|
509
|
+
} else {
|
|
510
|
+
this.queue.copyWithin(0, this.queueHead, this.queueTail);
|
|
511
|
+
this.queueHead = 0;
|
|
512
|
+
this.queueTail = used;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
this.queue.set(source.subarray(0, sourceCount), this.queueTail);
|
|
516
|
+
this.queueTail += sourceCount;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
_discard(count) {
|
|
520
|
+
const available = this.queueTail - this.queueHead;
|
|
521
|
+
const take = Math.min(available, count);
|
|
522
|
+
this.queueHead += take;
|
|
523
|
+
if (this.queueHead === this.queueTail) {
|
|
524
|
+
this.queueHead = 0;
|
|
525
|
+
this.queueTail = 0;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleanfield/web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CleanField Voice 1.0 noise suppression for the browser (AudioWorklet + WebAssembly).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": ["index.js", "cf_wasm.wasm", "worklet-processor.js", "live-dsp.js", "dsp.js"],
|
|
8
|
+
"license": "MIT"
|
|
9
|
+
}
|
|
10
|
+
|