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) Successful in 18m16s
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
jared 906a034e26 Merge pull request 'fix(calls): starting a call in a voice channel no longer @room-pings everyone (#295)' (#378) from fix/voice-room-no-room-ping into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 20:38:37 -04:00
jared b855eb505d Merge pull request 'fix(reactions): the reactions row no longer flickers or loses its spacing (#305)' (#377) from fix/reactions-collapse-loop into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 20:07:27 -04:00
jared cc8e7444f6 Merge pull request 'fix(rooms): members can start and join calls in new private rooms (#328)' (#376) from fix/group-call-permission into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 19:54:43 -04:00
Lotus CIandClaude Opus 5.5 d7ea408fd0 chore(call): pin element-call-embedded 0.25.0-lotus.23 (sendNotificationType=none, #295)
CI / Build & Quality Checks (pull_request) Successful in 2m48s
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) Successful in 15m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:42:52 -04:00
Lotus CI d3f01b5116 Merge remote-tracking branch 'origin/lotus' into fix/voice-room-no-room-ping 2026-10-17 18:40:48 -04:00
jared f84c0d6e9e Merge pull request 'fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)' (#369) from fix/dialog-enter-closes into lotus
CI / Build & Quality Checks (push) Successful in 3m14s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 18:35:54 -04:00
Lotus CIandClaude Opus 5.5 1c62fb4722 fix(reactions): the reactions row no longer flickers or loses its spacing (#305)
CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m57s
Two bugs in the one-row "+N" collapse:
- measure() used the border box, which grows by 4 px while collapsed
  (margin -2 / padding 2), so a row within 4 px of fitting flipped
  collapsed↔expanded every frame (flicker, layout shifts, ~11 % of a core
  per looping message). It now measures the content box.
- The collapsed style used the `margin` shorthand, which overrode the
  timeline's marginTop and, when removed, cleared it; the row then stuck
  to the message text. Longhand margins keep marginTop (minus 2 px).

Verified headless (12 reactions, viewport sweep 1300–1400 px): before,
90 style flips in 1.5 s at 1344 px and margin-top 0px after a cycle;
now 0 flips at every width and margin-top stays 8px.

Fixes #305

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:26:16 -04:00
Lotus CIandClaude Opus 5.5 a6c7333ea3 fix(rooms): members can start and join calls in new private rooms (#328)
CI / Build & Quality Checks (pull_request) Successful in 2m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m30s
createRoom granted org.matrix.msc3401.call.member at user level only for
voice rooms. Every other room fell back to Synapse's state_default (50),
so in a private group room (where Lotus shows the call button and rings)
only admins could start a call; members saw "1 Live · Join" and then
"You don't have permission to join!". All new non-space rooms now get
call.member: 0 (spaces keep their own override).

Verified headless through Create Room → Private: before, no call.member
entry (state_default 50); now 0. Existing rooms are unchanged; an admin
can set it in room permissions.

Fixes #328

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:09:33 -04:00
jared f0b7400d1b Merge branch 'lotus' into fix/dialog-enter-closes
CI / Build & Quality Checks (pull_request) Successful in 3m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m57s
2026-10-17 17:53:08 -04:00
Lotus CIandClaude Opus 5.5 a86947be48 fix(calls): starting a call in a voice channel no longer @room-pings everyone (#295)
CallEmbed asked Element Call to send a call-start notification for every
non-DM call. In persistent voice channels and space rooms it carries an
@room mention: every member got a highlighted ping (and phone push)
whenever a moderator or admin joined first, and other clients showed it
as a missed call. Now only rooms that can ring (isRingableRoom: DMs and
invite-only groups outside a space) get ring/notification; everything
else passes sendNotificationType=none (element-call-embedded 0.25.0-lotus.23).

Verified with a local build of the fork: an admin joining the Voice Lounge
sends none, and Bob gets no new notification (before: +1 highlighted).

Fixes #295

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:08:22 -04:00
Lotus CIandClaude Opus 5.5 b6f9af3f58 fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)
CI / Build & Quality Checks (pull_request) Successful in 3m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m27s
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).

The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.

Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.

Fixes #315

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:58:05 -04:00
15 changed files with 146 additions and 94 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"version": "0.25.0-lotus.23",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+1 -1
View File
@@ -297,7 +297,7 @@ export function AccountDataEditor({
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
Setup Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
Reset Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+5 -2
View File
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation,
data.additionalCreators,
),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
// [Gitea #328] Every non-space room gets call.member at user level, not just
// voice rooms: the call button is shown in private group rooms too, and with
// Synapse's state_default (50) only admins could start or join a call there
// ("You don't have permission to join!").
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
initial_state: initialState,
};
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
@@ -612,13 +612,7 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
// [Gitea #304] The board opens with focus in its search field, where
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
escapeDeactivates: stopPropagation,
}}
>
<EmojiBoardLayout
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
+1 -1
View File
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
Create Poll
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
Report Room
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
Report User
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
{title}
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+14 -2
View File
@@ -725,7 +725,13 @@ export const MessageDeleteItem = as<
Delete Message
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -865,7 +871,13 @@ export const MessageReportItem = as<
Report Message
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+24 -4
View File
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
const measure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const rowWidth = container.getBoundingClientRect().width;
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
// [Gitea #305] The width the chips actually get (content box). The border
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
const cs = getComputedStyle(container);
const rowWidth =
container.clientWidth -
(parseFloat(cs.paddingLeft) || 0) -
(parseFloat(cs.paddingRight) || 0);
const gap = parseFloat(cs.columnGap) || 8;
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
style={{
...props.style,
...(collapsed && rowHeight
? // 2px breathing room so row-1 focus outlines are not clipped.
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
// margins so the timeline's marginTop survives (Gitea #305: the
// `margin` shorthand overrode it and, once removed, cleared it).
{
maxHeight: rowHeight + 4,
overflow: 'hidden',
padding: 2,
marginLeft: -2,
marginRight: -2,
marginBottom: -2,
marginTop:
props.style?.marginTop !== undefined
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
: -2,
}
: {}),
}}
ref={(el) => {
+15 -1
View File
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -217,6 +218,9 @@ export class CallEmbed {
// - transparent background so the room wallpaper shows through natively
lotusCallState: '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
// sets its root color-scheme from the theme, instead of us injecting CSS.
lotusHostControls: 'true',
@@ -251,8 +255,18 @@ export class CallEmbed {
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
}
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
// (DMs, invite-only groups outside a space). In a voice channel or space
// room the notification carries an @room mention, so every member got a
// highlighted ping (and phone push) whenever a moderator hopped in, and
// other clients showed it as a missed call.
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
if (CallEmbed.startingCall(intent)) {
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
let notify = 'none';
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
}
params.append('sendNotificationType', notify);
}
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
+73 -64
View File
@@ -87,97 +87,106 @@ function copyPdfWorker() {
};
}
// Lotus ML noise suppression: ship the RNNoise worklet/wasm + our getUserMedia
// shim alongside the vendored Element Call bundle, and inject the shim <script>
// into EC's index.html so it runs before EC captures the mic. Runs after
// viteStaticCopy has populated dist/public/element-call (closeBundle order).
// The denoise assets the fork loads from ./denoise/ at runtime, as
// [source, path under denoise/] pairs; `dir: true` entries are whole trees.
// Shared by the build copy and the dev server (Gitea #293).
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() {
const DEV_PREFIX = '/public/element-call/denoise/';
return {
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() {
const ecDir = path.resolve('dist/public/element-call');
if (!fs.existsSync(ecDir)) return;
const denoiseDir = path.join(ecDir, 'denoise');
fs.mkdirSync(denoiseDir, { recursive: true });
const { sapphi, dtln, dfn } = denoiseSources();
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
// All bundled denoise assets are REQUIRED: every entry backs a model the
// UI can select (RNNoise, Speex) or the optional noise gate. A missing
// source means a partial/changed install would otherwise silently ship a
// 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);
// A missing source means a partial/changed install would otherwise
// silently ship a broken ML feature (worklet 404 -> raw mic), so we fail
// the build instead.
const missing = sapphi.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (missing.length > 0) {
throw new Error(
`[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
// 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');
const dtlnEntry = path.join(dtln[0], '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 });
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
// DeepFilterNet 3 (deepfilternet3-noise-filter): the npm package ships only
// 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);
const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (dfnMissing.length > 0) {
throw new Error(
'[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/.)',
`[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/.)`,
);
}
dfnAssets.forEach(([s, d]) => {
dfn.forEach(([s, rel]) => {
const d = path.join(denoiseDir, rel);
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
});