Compare commits
2 Commits
6634b2b8a2
...
86272b6b08
| Author | SHA1 | Date | |
|---|---|---|---|
| 86272b6b08 | |||
| 89a2321dd4 |
+100
-57
@@ -54,6 +54,13 @@
|
||||
script: 'speexWorklet.js',
|
||||
wasm: 'speex.wasm',
|
||||
},
|
||||
dtln: {
|
||||
// @workadventure/noise-suppression is a self-contained ES module that
|
||||
// resolves its own AudioWorklet processor + LiteRT WASM + TFLite models
|
||||
// via import.meta.url. We dynamic-import this helper and let it build the
|
||||
// node, rather than addModule-ing a flat worklet ourselves.
|
||||
helper: 'workadventure/audio-worklet.js',
|
||||
},
|
||||
gate: {
|
||||
name: '@sapphi-red/web-noise-suppressor/noise-gate',
|
||||
script: 'noiseGateWorklet.js',
|
||||
@@ -113,8 +120,10 @@
|
||||
} catch (e) {}
|
||||
return Promise.reject(new Error('SampleRate mismatch: ' + ctx.sampleRate));
|
||||
}
|
||||
// Load required modules
|
||||
var scripts = [PROCESSORS[MODEL].script];
|
||||
// Load worklet modules. DTLN registers its own processor via the
|
||||
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
||||
var scripts = [];
|
||||
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
||||
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
||||
|
||||
return Promise.all(
|
||||
@@ -138,6 +147,36 @@
|
||||
|
||||
var hasNotifiedActive = false;
|
||||
|
||||
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
||||
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
||||
// from @workadventure's self-contained helper, which we dynamic-import; it
|
||||
// resolves its own processor + LiteRT WASM + TFLite models internally and
|
||||
// returns the node. Resolves to { node, ready, dispose }.
|
||||
function buildMlNode(ctx, wasmBinary) {
|
||||
if (MODEL === 'dtln') {
|
||||
return import(ASSET_BASE + PROCESSORS.dtln.helper).then(function (mod) {
|
||||
// bypassUntilReady: pass raw audio through until the model is loaded so
|
||||
// the call never has a silent/missing track during init.
|
||||
return mod.createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady: true });
|
||||
});
|
||||
}
|
||||
var node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||
channelCount: 1,
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
processorOptions: { maxChannels: 1, wasmBinary: wasmBinary },
|
||||
});
|
||||
return Promise.resolve({
|
||||
node: node,
|
||||
ready: Promise.resolve(),
|
||||
dispose: function () {
|
||||
try {
|
||||
node.port.postMessage('destroy');
|
||||
} catch (e) {}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function processStream(stream) {
|
||||
var audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) return Promise.resolve(stream);
|
||||
@@ -166,68 +205,72 @@
|
||||
}
|
||||
|
||||
// 2. ML Processor
|
||||
var mlNode = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||
channelCount: 1,
|
||||
channelCountMode: 'explicit',
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [1],
|
||||
processorOptions: { maxChannels: 1, wasmBinary: wasmBinary },
|
||||
});
|
||||
head.connect(mlNode);
|
||||
mlNode.connect(dest);
|
||||
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
||||
var mlNode = ml.node;
|
||||
head.connect(mlNode);
|
||||
mlNode.connect(dest);
|
||||
|
||||
var origTrack = audioTracks[0];
|
||||
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||
// Surface async init failures (e.g. DTLN model load) without blocking
|
||||
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
||||
if (ml.ready && typeof ml.ready.then === 'function') {
|
||||
ml.ready.catch(function (err) {
|
||||
var m = err instanceof Error ? err.message : String(err);
|
||||
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
||||
});
|
||||
}
|
||||
|
||||
var torndown = false;
|
||||
function cleanup() {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
try {
|
||||
mlNode.port.postMessage('destroy');
|
||||
} catch (e) {}
|
||||
try {
|
||||
source.disconnect();
|
||||
mlNode.disconnect();
|
||||
} catch (e) {}
|
||||
try {
|
||||
origTrack.stop();
|
||||
} catch (e) {}
|
||||
}
|
||||
var origTrack = audioTracks[0];
|
||||
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||
|
||||
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||
processedTrack.stop = function () {
|
||||
cleanup();
|
||||
rawStop();
|
||||
};
|
||||
origTrack.addEventListener('ended', function () {
|
||||
try {
|
||||
var torndown = false;
|
||||
function cleanup() {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
try {
|
||||
ml.dispose();
|
||||
} catch (e) {}
|
||||
try {
|
||||
source.disconnect();
|
||||
mlNode.disconnect();
|
||||
} catch (e) {}
|
||||
try {
|
||||
origTrack.stop();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||
processedTrack.stop = function () {
|
||||
cleanup();
|
||||
rawStop();
|
||||
} catch (e) {}
|
||||
cleanup();
|
||||
});
|
||||
};
|
||||
origTrack.addEventListener('ended', function () {
|
||||
try {
|
||||
rawStop();
|
||||
} catch (e) {}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
if (!hasNotifiedActive) {
|
||||
hasNotifiedActive = true;
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'lotus-denoise-status',
|
||||
active: true,
|
||||
model: MODEL,
|
||||
nativeNS: USE_NATIVE_NS,
|
||||
gate: USE_GATE,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
if (!hasNotifiedActive) {
|
||||
hasNotifiedActive = true;
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'lotus-denoise-status',
|
||||
active: true,
|
||||
model: MODEL,
|
||||
nativeNS: USE_NATIVE_NS,
|
||||
gate: USE_GATE,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
var out = new MediaStream();
|
||||
out.addTrack(processedTrack);
|
||||
stream.getVideoTracks().forEach(function (t) {
|
||||
out.addTrack(t);
|
||||
var out = new MediaStream();
|
||||
out.addTrack(processedTrack);
|
||||
stream.getVideoTracks().forEach(function (t) {
|
||||
out.addTrack(t);
|
||||
});
|
||||
return out;
|
||||
});
|
||||
return out;
|
||||
})
|
||||
.catch(function (e) {
|
||||
var msg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
Generated
+16
@@ -26,6 +26,7 @@
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
"blurhash": "2.0.5",
|
||||
@@ -4856,6 +4857,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@workadventure/noise-suppression": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@workadventure/noise-suppression/-/noise-suppression-0.0.4.tgz",
|
||||
"integrity": "sha512-v8DQgV2TQAWh7YLo7bZ1grV3iDNltRuvPaIYTcaBWoOjUaxDp/j5zrFLz4ZuijPGxzqcQxeW7ql/HJltMuLDtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fft.js": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@xobotyi/scrollbar-width": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
|
||||
@@ -7619,6 +7629,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fft.js": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz",
|
||||
"integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
"blurhash": "2.0.5",
|
||||
|
||||
@@ -1441,6 +1441,7 @@ function Calls() {
|
||||
options={[
|
||||
{ value: 'rnnoise', label: 'RNNoise' },
|
||||
{ value: 'speex', label: 'Speex (Legacy)' },
|
||||
{ value: 'dtln', label: 'DTLN (beta)' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||
// - 'browser' : WebRTC built-in suppression (Element Call noiseSuppression param)
|
||||
// - 'ml' : client-side RNNoise ML suppression (Lotus denoise shim)
|
||||
export type NoiseSuppressionMode = 'off' | 'browser' | 'ml';
|
||||
// Only self-hostable, build-bundled models are exposed. DTLN/DeepFilterNet were
|
||||
// evaluated but rely on remote-style asset loading incompatible with our
|
||||
// self-hosted/Tauri-CSP strategy (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
||||
export type DenoiseModelId = 'rnnoise' | 'speex';
|
||||
// Self-hostable, build-bundled ML models. DeepFilterNet remains excluded — it
|
||||
// loads its runtime/models from external CDNs, which breaks the self-hosted /
|
||||
// Tauri-CSP strategy (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
||||
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln';
|
||||
export type ChatBackground =
|
||||
| 'none'
|
||||
| 'blueprint'
|
||||
@@ -263,7 +263,9 @@ export const getSettings = (): Settings => {
|
||||
// Coerce any retired/unknown persisted model (e.g. 'dtln', 'deepfilternet'
|
||||
// from earlier beta builds) back to the default working model.
|
||||
callDenoiseModel:
|
||||
saved.callDenoiseModel === 'rnnoise' || saved.callDenoiseModel === 'speex'
|
||||
saved.callDenoiseModel === 'rnnoise' ||
|
||||
saved.callDenoiseModel === 'speex' ||
|
||||
saved.callDenoiseModel === 'dtln'
|
||||
? saved.callDenoiseModel
|
||||
: defaultSettings.callDenoiseModel,
|
||||
composerToolbarButtons: {
|
||||
|
||||
@@ -31,6 +31,15 @@ export const DENOISE_MODELS: DenoiseModel[] = [
|
||||
transients: 'Poor',
|
||||
voiceQuality: 'Moderate',
|
||||
},
|
||||
{
|
||||
id: 'dtln',
|
||||
name: 'DTLN (beta)',
|
||||
description: 'Deep-learning model (TFLite). Stronger on transient noise; higher CPU.',
|
||||
cpuUsage: '10-20%',
|
||||
binarySize: '~4 MB',
|
||||
transients: 'Excellent',
|
||||
voiceQuality: 'High',
|
||||
},
|
||||
];
|
||||
|
||||
export const isMLDenoiseSupported = (): boolean => {
|
||||
|
||||
@@ -106,6 +106,23 @@ function lotusDenoise() {
|
||||
}
|
||||
assets.forEach(([s, d]) => fs.copyFileSync(s, d));
|
||||
|
||||
// DTLN (@workadventure/noise-suppression): unlike the flat sapphi
|
||||
// worklets, this package is a self-contained ES module that resolves its
|
||||
// own AudioWorklet processor, LiteRT WASM runtime and TFLite models via
|
||||
// `import.meta.url`. So we copy its whole dist/ tree intact to
|
||||
// denoise/workadventure/ (preserving assets/ + vendor/) and let the shim
|
||||
// dynamic-import denoise/workadventure/audio-worklet.js at runtime. The
|
||||
// entry module is required — fail the build if the install is broken.
|
||||
const dtlnSrc = path.resolve('node_modules/@workadventure/noise-suppression/dist');
|
||||
const dtlnEntry = path.join(dtlnSrc, 'audio-worklet.js');
|
||||
if (!fs.existsSync(dtlnEntry)) {
|
||||
throw new Error(
|
||||
`[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` +
|
||||
'Run `npm ci` to install @workadventure/noise-suppression.',
|
||||
);
|
||||
}
|
||||
fs.cpSync(dtlnSrc, path.join(denoiseDir, 'workadventure'), { recursive: true });
|
||||
|
||||
const shimSrc = path.resolve('build/lotus-denoise.js');
|
||||
if (!fs.existsSync(shimSrc)) {
|
||||
throw new Error(`[lotus-denoise] Missing shim source ${shimSrc} — build aborted.`);
|
||||
|
||||
Reference in New Issue
Block a user