Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 cd760ebc3c fix(dev): ML noise suppression works on npm start; no lotusTheme warning per call (#293)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) In progress
The denoise worklets/wasm/models were only copied into dist/ by the
lotus-denoise closeBundle hook, so on the dev server the call frame got
the SPA fallback for them and silently used the raw mic. The asset list
is now shared (denoiseSources) between that copy and a dev-server
middleware serving the same paths. Also send lotusTheme=true with
lotusTransparent, which the fork warned about in every call.

Fixes #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:24:03 -04:00
2 changed files with 76 additions and 64 deletions
+3
View File
@@ -218,6 +218,9 @@ export class CallEmbed {
// - transparent background so the room wallpaper shows through natively // - transparent background so the room wallpaper shows through natively
lotusCallState: 'true', lotusCallState: 'true',
lotusTransparent: 'true', lotusTransparent: 'true',
// [Gitea #293] the fork applies the theme with transparency anyway, and
// warned in every call that lotusTheme wasn't set
lotusTheme: 'true',
// [Gitea #43] The fork hides its own footer (we draw the call bar) and // [Gitea #43] The fork hides its own footer (we draw the call bar) and
// sets its root color-scheme from the theme, instead of us injecting CSS. // sets its root color-scheme from the theme, instead of us injecting CSS.
lotusHostControls: 'true', lotusHostControls: 'true',
+73 -64
View File
@@ -87,97 +87,106 @@ function copyPdfWorker() {
}; };
} }
// Lotus ML noise suppression: ship the RNNoise worklet/wasm + our getUserMedia // The denoise assets the fork loads from ./denoise/ at runtime, as
// shim alongside the vendored Element Call bundle, and inject the shim <script> // [source, path under denoise/] pairs; `dir: true` entries are whole trees.
// into EC's index.html so it runs before EC captures the mic. Runs after // Shared by the build copy and the dev server (Gitea #293).
// viteStaticCopy has populated dist/public/element-call (closeBundle order). function denoiseSources() {
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
return {
// All bundled denoise assets are REQUIRED: every entry backs a model the
// UI can select (RNNoise, Speex) or the optional noise gate.
sapphi: [
[path.join(sapphi, 'rnnoise/workletProcessor.js'), 'rnnoiseWorklet.js'],
[path.join(sapphi, 'rnnoise.wasm'), 'rnnoise.wasm'],
[path.join(sapphi, 'rnnoise_simd.wasm'), 'rnnoise_simd.wasm'],
[path.join(sapphi, 'speex/workletProcessor.js'), 'speexWorklet.js'],
[path.join(sapphi, 'speex.wasm'), 'speex.wasm'],
[path.join(sapphi, 'noiseGate/workletProcessor.js'), 'noiseGateWorklet.js'],
],
// DTLN (@workadventure/noise-suppression): a self-contained ES module that
// resolves its own worklet, LiteRT WASM runtime and TFLite models via
// `import.meta.url`, so its whole dist/ tree is served intact.
dtln: [path.resolve('node_modules/@workadventure/noise-suppression/dist'), 'workadventure'],
// DeepFilterNet 3: the ESM (worklet inlined) plus the vendored wasm and ONNX
// model (CDN-free / Tauri-CSP safe), in the package's expected v2/ layout.
dfn: [
[
path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js'),
'deepfilternet/index.esm.js',
],
[path.join(dfnVendor, 'v2/pkg/df_bg.wasm'), 'deepfilternet/v2/pkg/df_bg.wasm'],
[
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
],
],
};
}
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
// serves the same files at the same URLs, so `npm start` can test denoise.
function lotusDenoise() { function lotusDenoise() {
const DEV_PREFIX = '/public/element-call/denoise/';
return { return {
name: 'lotus-denoise', name: 'lotus-denoise',
configureServer(server) {
const { sapphi, dtln, dfn } = denoiseSources();
const files = new Map([...sapphi, ...dfn].map(([src, rel]) => [rel, src]));
const types = { '.js': 'text/javascript', '.wasm': 'application/wasm' };
server.middlewares.use((req, res, next) => {
const url = (req.url ?? '').split('?')[0];
if (!url.startsWith(DEV_PREFIX)) return next();
const rel = decodeURIComponent(url.slice(DEV_PREFIX.length));
let file = files.get(rel);
if (!file && rel.startsWith(`${dtln[1]}/`)) {
const candidate = path.resolve(dtln[0], rel.slice(dtln[1].length + 1));
if (candidate.startsWith(dtln[0] + path.sep)) file = candidate;
}
if (!file || !fs.existsSync(file)) return next();
res.setHeader('Content-Type', types[path.extname(file)] ?? 'application/octet-stream');
fs.createReadStream(file).pipe(res);
return undefined;
});
},
closeBundle() { closeBundle() {
const ecDir = path.resolve('dist/public/element-call'); const ecDir = path.resolve('dist/public/element-call');
if (!fs.existsSync(ecDir)) return; if (!fs.existsSync(ecDir)) return;
const denoiseDir = path.join(ecDir, 'denoise'); const denoiseDir = path.join(ecDir, 'denoise');
fs.mkdirSync(denoiseDir, { recursive: true }); fs.mkdirSync(denoiseDir, { recursive: true });
const { sapphi, dtln, dfn } = denoiseSources();
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist'); // A missing source means a partial/changed install would otherwise
// All bundled denoise assets are REQUIRED: every entry backs a model the // silently ship a broken ML feature (worklet 404 -> raw mic), so we fail
// UI can select (RNNoise, Speex) or the optional noise gate. A missing // the build instead.
// source means a partial/changed install would otherwise silently ship a const missing = sapphi.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
// broken ML feature (worklet 404 -> raw mic), so we fail the build instead.
const assets = [
[
path.join(sapphi, 'rnnoise/workletProcessor.js'),
path.join(denoiseDir, 'rnnoiseWorklet.js'),
],
[path.join(sapphi, 'rnnoise.wasm'), path.join(denoiseDir, 'rnnoise.wasm')],
[path.join(sapphi, 'rnnoise_simd.wasm'), path.join(denoiseDir, 'rnnoise_simd.wasm')],
[path.join(sapphi, 'speex/workletProcessor.js'), path.join(denoiseDir, 'speexWorklet.js')],
[path.join(sapphi, 'speex.wasm'), path.join(denoiseDir, 'speex.wasm')],
[
path.join(sapphi, 'noiseGate/workletProcessor.js'),
path.join(denoiseDir, 'noiseGateWorklet.js'),
],
];
const missing = assets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (missing.length > 0) { if (missing.length > 0) {
throw new Error( throw new Error(
`[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`, `[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`,
); );
} }
assets.forEach(([s, d]) => fs.copyFileSync(s, d)); sapphi.forEach(([s, rel]) => fs.copyFileSync(s, path.join(denoiseDir, rel)));
// DTLN (@workadventure/noise-suppression): unlike the flat sapphi const dtlnEntry = path.join(dtln[0], 'audio-worklet.js');
// 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)) { if (!fs.existsSync(dtlnEntry)) {
throw new Error( throw new Error(
`[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` + `[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` +
'Run `npm ci` to install @workadventure/noise-suppression.', 'Run `npm ci` to install @workadventure/noise-suppression.',
); );
} }
fs.cpSync(dtlnSrc, path.join(denoiseDir, 'workadventure'), { recursive: true }); fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
// DeepFilterNet 3 (deepfilternet3-noise-filter): the npm package ships only const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
// its ESM (index.esm.js) with the AudioWorklet processor + wasm-bindgen glue
// INLINED as a string (loaded via a Blob URL, no CDN for the worklet). The
// only runtime CDN fetches are its single-threaded `df_bg.wasm` and the
// ONNX `DeepFilterNet3_onnx.tar.gz` model — which we VENDOR locally (under
// build/denoise-vendor/deepfilternet/) and serve, overriding the package's
// cdnUrl to our self-hosted base. This keeps the feature CDN-free / Tauri-CSP
// safe. We copy the ESM (the shim dynamic-imports it, mirroring DTLN) plus
// the vendored assets, preserving the package's expected v2/... layout. All
// are required — a missing entry means a broken install, so fail the build.
const dfnEsm = path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js');
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
const dfnAssets = [
[dfnEsm, path.join(denoiseDir, 'deepfilternet/index.esm.js')],
[
path.join(dfnVendor, 'v2/pkg/df_bg.wasm'),
path.join(denoiseDir, 'deepfilternet/v2/pkg/df_bg.wasm'),
],
[
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
path.join(denoiseDir, 'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz'),
],
];
const dfnMissing = dfnAssets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (dfnMissing.length > 0) { if (dfnMissing.length > 0) {
throw new Error( throw new Error(
'[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid ' + `[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${dfnMissing.join('\n ')}\n(Run \`npm ci\`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)`,
'shipping a broken ML feature:\n ' +
dfnMissing.join('\n ') +
'\n(Run `npm ci`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)',
); );
} }
dfnAssets.forEach(([s, d]) => { dfn.forEach(([s, rel]) => {
const d = path.join(denoiseDir, rel);
fs.mkdirSync(path.dirname(d), { recursive: true }); fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d); fs.copyFileSync(s, d);
}); });