@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.
@@ -0,0 +1,327 @@
1
+ /**
2
+ * CleanField Voice 1.0 — live AudioWorklet processor.
3
+ *
4
+ * Runs on the audio render thread: one microphone input, the voice engine, and
5
+ * the denoised output. The wasm engine is instantiated *inside* the worklet.
6
+ * AudioWorkletGlobalScope has no `fetch` in current browsers, so the worklet
7
+ * first resolves the wasm URL from its own module URL and asks the page for the
8
+ * bytes; in a browser that exposes `fetch` on the worklet global, it fetches
9
+ * directly instead.
10
+ *
11
+ * All parameters (bypass, output gain, maximum attenuation) arrive as messages
12
+ * and are applied without resetting the engine or reloading the model.
13
+ */
14
+
15
+ import { HOP_SIZE, LATENCY_SAMPLES, SAMPLE_RATE, VoiceEngine } from './dsp.js';
16
+ import { LiveDenoiser } from './live-dsp.js';
17
+ import {
18
+ SPEC_BATCH_COLUMNS,
19
+ SPEC_BINS,
20
+ writeQuantizedColumn,
21
+ } from './spectrogram-core.js';
22
+
23
+ const WASM_FILE = 'cf_wasm.wasm';
24
+ const RENDER_QUANTUM = 128;
25
+
26
+ function toDb(value) {
27
+ return value > 0 ? Math.max(-100, 20 * Math.log10(value)) : -100;
28
+ }
29
+
30
+ function resolveSiblingUrl(file, fallback) {
31
+ try {
32
+ if (typeof import.meta !== 'undefined' && import.meta.url) {
33
+ return import.meta.url.slice(0, import.meta.url.lastIndexOf('/') + 1) + file;
34
+ }
35
+ } catch {
36
+ // Fall through to the URL supplied by the page.
37
+ }
38
+ return fallback || null;
39
+ }
40
+
41
+ function clampGain(value) {
42
+ const gain = Number(value);
43
+ if (!Number.isFinite(gain) || gain < 0) return 1;
44
+ return Math.min(8, gain);
45
+ }
46
+
47
+ function clampMaxAttenuationDb(value) {
48
+ const db = Number(value);
49
+ if (!Number.isFinite(db)) return -80;
50
+ return db < -80 ? -80 : db > 0 ? 0 : db;
51
+ }
52
+
53
+ class LiveDenoiseProcessor extends AudioWorkletProcessor {
54
+ constructor(options) {
55
+ super();
56
+ const processorOptions = (options && options.processorOptions) || {};
57
+ this.wasmUrl = resolveSiblingUrl(WASM_FILE, processorOptions.wasmUrl);
58
+ this.engine = null;
59
+ this.live = null;
60
+ this.failed = false;
61
+ this.wasmWaiter = null;
62
+ this.gain = clampGain(processorOptions.gain);
63
+ this.maxAttenuationDb = clampMaxAttenuationDb(processorOptions.maxAttenuationDb);
64
+ this.bypass = Boolean(processorOptions.bypass);
65
+ this.latencySamples = LATENCY_SAMPLES;
66
+ this.frames = 0;
67
+ this.lastFrames = 0;
68
+ this.lastHopMs = 0;
69
+ this.frameMs = 0;
70
+ this.rtf = 0;
71
+ this.levelBlocks = 0;
72
+ this.reportEvery = Math.max(1, Math.round((0.1 * sampleRate) / RENDER_QUANTUM));
73
+ this.inSquares = 0;
74
+ this.outSquares = 0;
75
+ this.peakIn = 0;
76
+ this.peakOut = 0;
77
+ this.levelSamples = 0;
78
+ this.levelIn = -100;
79
+ this.levelOut = -100;
80
+ // Batched spectrogram columns: [input plane | output plane] per batch.
81
+ this.specBatch = new Uint8Array(SPEC_BATCH_COLUMNS * SPEC_BINS * 2);
82
+ this.specCount = 0;
83
+
84
+ this.port.onmessage = (event) => this.handleMessage(event.data);
85
+ this.port.postMessage({
86
+ type: 'hello',
87
+ contextSampleRate: sampleRate,
88
+ wasmUrl: this.wasmUrl,
89
+ });
90
+ void this.start();
91
+ }
92
+
93
+ handleMessage(message) {
94
+ if (!message || typeof message !== 'object') return;
95
+ if (message.type === 'wasm' && message.bytes) {
96
+ if (this.wasmWaiter) {
97
+ const waiter = this.wasmWaiter;
98
+ this.wasmWaiter = null;
99
+ waiter.resolve(message.bytes);
100
+ }
101
+ return;
102
+ }
103
+ if (message.type === 'wasm-error') {
104
+ if (this.wasmWaiter) {
105
+ const waiter = this.wasmWaiter;
106
+ this.wasmWaiter = null;
107
+ waiter.reject(new Error(message.message || 'Could not load the engine.'));
108
+ }
109
+ return;
110
+ }
111
+ if (message.type === 'bypass') {
112
+ this.bypass = Boolean(message.value);
113
+ if (this.live) this.live.setBypass(this.bypass);
114
+ return;
115
+ }
116
+ if (message.type === 'gain') {
117
+ if (Number.isFinite(message.value)) {
118
+ this.gain = clampGain(message.value);
119
+ if (this.live) this.live.setGain(this.gain);
120
+ }
121
+ return;
122
+ }
123
+ if (message.type === 'attenuation') {
124
+ if (Number.isFinite(message.value)) {
125
+ this.maxAttenuationDb = clampMaxAttenuationDb(message.value);
126
+ if (this.live) this.live.setMaxAttenuationDb(this.maxAttenuationDb);
127
+ }
128
+ }
129
+ }
130
+
131
+ async start() {
132
+ try {
133
+ let instance = null;
134
+ let source = 'page';
135
+ if (typeof fetch === 'function' && typeof WebAssembly.instantiateStreaming === 'function') {
136
+ const response = await fetch(this.wasmUrl);
137
+ if (!response.ok) {
138
+ throw new Error(`Could not load the engine (HTTP ${response.status}).`);
139
+ }
140
+ const result = await WebAssembly.instantiateStreaming(response, {});
141
+ instance = result.instance || result;
142
+ source = 'worklet-fetch';
143
+ } else {
144
+ if (!this.wasmUrl) throw new Error('Could not resolve the engine URL.');
145
+ const bytes = await this.requestWasm();
146
+ const result = await WebAssembly.instantiate(bytes, {});
147
+ instance = result.instance || result;
148
+ }
149
+
150
+ this.engine = new VoiceEngine(instance);
151
+ this.live = new LiveDenoiser(this.engine, {
152
+ inputRate: sampleRate,
153
+ outputRate: sampleRate,
154
+ gain: this.gain,
155
+ maxAttenuationDb: this.maxAttenuationDb,
156
+ bypass: this.bypass,
157
+ captureSpectrum: true,
158
+ onSpectrum: (streaming) => this.captureSpectrum(streaming),
159
+ });
160
+ this.latencySamples = this.live.latencySamples;
161
+
162
+ this.port.postMessage({
163
+ type: 'ready',
164
+ contextSampleRate: sampleRate,
165
+ wasmSource: source,
166
+ backend: this.engine.backendName || 'scalar',
167
+ modelBytes: this.engine.modelImageBytes,
168
+ latencySamples: this.latencySamples,
169
+ });
170
+ } catch (error) {
171
+ this.failed = true;
172
+ this.port.postMessage({
173
+ type: 'error',
174
+ stage: 'engine',
175
+ message: error && error.message ? error.message : 'Could not start the engine.',
176
+ });
177
+ }
178
+ }
179
+
180
+ requestWasm() {
181
+ return new Promise((resolve, reject) => {
182
+ this.wasmWaiter = { resolve, reject };
183
+ this.port.postMessage({ type: 'need-wasm', url: this.wasmUrl });
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Quantise one aligned spectrum pair into the batch. Runs on the audio
189
+ * thread, but only copies 2 × 513 bytes and does no rendering.
190
+ */
191
+ captureSpectrum(streaming) {
192
+ const capture = streaming.capture || streaming;
193
+ const column = this.specCount;
194
+ const base = column * SPEC_BINS;
195
+ writeQuantizedColumn(capture.spectrumInput, this.specBatch, base);
196
+ writeQuantizedColumn(
197
+ capture.spectrumOutput,
198
+ this.specBatch,
199
+ SPEC_BATCH_COLUMNS * SPEC_BINS + base
200
+ );
201
+ this.specCount += 1;
202
+ if (this.specCount >= SPEC_BATCH_COLUMNS) this.flushSpectrum();
203
+ }
204
+
205
+ /** Send the batched columns to the page; the main thread only paints. */
206
+ flushSpectrum() {
207
+ if (this.specCount === 0) return;
208
+ const count = this.specCount;
209
+ this.specCount = 0;
210
+ const bins = SPEC_BINS;
211
+ // The batch stores the output plane at a fixed maximum-batch offset so
212
+ // capture is a plain copy. A report can flush a partial batch, so move the
213
+ // output plane down to the count-based offset the packed format expects.
214
+ if (count < SPEC_BATCH_COLUMNS) {
215
+ this.specBatch.copyWithin(
216
+ count * bins,
217
+ SPEC_BATCH_COLUMNS * bins,
218
+ SPEC_BATCH_COLUMNS * bins + count * bins
219
+ );
220
+ }
221
+ const packed = this.specBatch.slice(0, count * bins * 2);
222
+ this.port.postMessage(
223
+ {
224
+ type: 'spectrum',
225
+ count,
226
+ bins,
227
+ frames: this.live ? this.live.frames : 0,
228
+ columns: packed,
229
+ },
230
+ [packed.buffer]
231
+ );
232
+ }
233
+
234
+ process(inputs, outputs) {
235
+ const output = outputs[0] && outputs[0][0];
236
+ if (!output) return true;
237
+ const input = inputs[0] && inputs[0][0];
238
+
239
+ if (!input) {
240
+ output.fill(0);
241
+ return true;
242
+ }
243
+ if (!this.live || this.failed) {
244
+ // Stay silent while the engine loads rather than passing the microphone
245
+ // through unprocessed; a failed worklet is reported to the page.
246
+ output.fill(0);
247
+ return true;
248
+ }
249
+
250
+ try {
251
+ this.live.processBlock(input, output);
252
+ } catch (error) {
253
+ this.failed = true;
254
+ output.fill(0);
255
+ this.port.postMessage({
256
+ type: 'error',
257
+ stage: 'process',
258
+ message: error && error.message ? error.message : 'Live processing failed.',
259
+ });
260
+ return true;
261
+ }
262
+
263
+ let inSquares = 0;
264
+ let outSquares = 0;
265
+ const length = Math.min(input.length, output.length);
266
+ for (let i = 0; i < length; i++) {
267
+ const inValue = Math.abs(input[i]);
268
+ const outValue = Math.abs(output[i]);
269
+ if (inValue > this.peakIn) this.peakIn = inValue;
270
+ if (outValue > this.peakOut) this.peakOut = outValue;
271
+ inSquares += input[i] * input[i];
272
+ outSquares += output[i] * output[i];
273
+ }
274
+ this.inSquares += inSquares;
275
+ this.outSquares += outSquares;
276
+ this.levelSamples += length;
277
+ this.levelBlocks += 1;
278
+
279
+ if (this.levelBlocks >= this.reportEvery) this.report();
280
+ return true;
281
+ }
282
+
283
+ report() {
284
+ const frames = this.live.frames;
285
+ const hopMs = this.live.streaming.hopMsTotal;
286
+ const frameDelta = frames - this.lastFrames;
287
+ const msDelta = hopMs - this.lastHopMs;
288
+ if (frameDelta > 0) {
289
+ this.frameMs = msDelta / frameDelta;
290
+ this.rtf = this.frameMs / ((HOP_SIZE / SAMPLE_RATE) * 1000);
291
+ this.lastFrames = frames;
292
+ this.lastHopMs = hopMs;
293
+ }
294
+ if (this.levelSamples > 0) {
295
+ const inRms = Math.sqrt(this.inSquares / this.levelSamples);
296
+ const outRms = Math.sqrt(this.outSquares / this.levelSamples);
297
+ this.levelIn = toDb(inRms);
298
+ this.levelOut = toDb(outRms);
299
+ }
300
+ this.inSquares = 0;
301
+ this.outSquares = 0;
302
+ this.levelSamples = 0;
303
+ this.levelBlocks = 0;
304
+
305
+ this.flushSpectrum();
306
+
307
+ this.port.postMessage({
308
+ type: 'stats',
309
+ frames,
310
+ frameMs: this.frameMs,
311
+ rtf: this.rtf,
312
+ levelIn: this.levelIn,
313
+ levelOut: this.levelOut,
314
+ peakIn: this.peakIn,
315
+ peakOut: this.peakOut,
316
+ contextSampleRate: sampleRate,
317
+ bypass: this.bypass,
318
+ gain: this.gain,
319
+ maxAttenuationDb: this.maxAttenuationDb,
320
+ latencySamples: this.latencySamples,
321
+ });
322
+ this.peakIn = 0;
323
+ this.peakOut = 0;
324
+ }
325
+ }
326
+
327
+ registerProcessor('cleanfield-live', LiveDenoiseProcessor);
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.