Compare commits

...
Author SHA1 Message Date
jared 8794465034 Merge pull request 'fix(status): read the public Kuma page, with notices scoped by a prefix' (#486) from fix/status-banner-public-page into lotus
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 28m11s
2026-11-04 21:47:13 -05:00
jaredandClaude Opus 5.5 ace084c91c fix(status): read the public Kuma page, with notices scoped by a prefix
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Build & Quality Checks (pull_request) Successful in 3m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m51s
The banner read Kuma's "matrix" status page, which was merged into "public"
on 2026-10-11, so it has returned 404 since and the banner stayed silent
(by design it fails quiet). Point it at "public", whose Matrix group is now
split into Homeserver / Voice calls / Discord.

That page is shared with other services, so add an optional noticePrefix:
only incidents and maintenance titled "Matrix: ..." reach Lotus users, shown
without the prefix. A media server's maintenance can't say messages may be
interrupted, and the Discord group can't raise a banner at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-11-04 21:25:22 -05:00
jared c73a3f8b31 Merge pull request 'feat(calls): change noise suppression mid-call without rejoining' (#485) from feat/live-denoise-switch into lotus
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Build & Quality Checks (push) Successful in 1m41s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 22s
CI / Playwright smoke (e2e) (push) Successful in 25m24s
2026-10-30 21:40:26 -04:00
Lotus CIandClaude Opus 5.5 504899aa14 feat(calls): change noise suppression mid-call without rejoining
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 2m5s
CI / Build & Quality Checks (pull_request) Successful in 3m1s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m2s
Changing Noise suppression (off / browser-native / ML), the ML model, the
gate or "stack browser NS" in Settings now applies to the call you're in.
useCallDenoise sends the fork's io.lotus.set_denoise whenever those
settings change; CallControl join-gates it like set_quality and re-sends
it after an EC reconnect (the remounted fork handler starts again from
the join URL). The "requires rejoining the call" note is replaced.

Bumps Element Call to 0.28.0-lotus.4, which adds io.lotus.set_denoise and
fixes mic re-captures silently keeping the old noiseSuppression (the mic
level meter's clone held the old track open), the meter following the
old device after a mic switch, and voiceIsolation staying on in off/ML
modes (element-call#66).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 21:19:56 -04:00
jared 7d31b2b2f7 Merge pull request 'chore(deps): Element Call 0.28.0-lotus.3 (DeepFilterNet fixes)' (#484) from chore/ec-0.28.0-lotus.3 into lotus
CI / Secret scan (gitleaks) (push) Successful in 1m22s
CI / Build & Quality Checks (push) Successful in 2m12s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 18m27s
2026-10-30 20:20:00 -04:00
jared 61d2433721 Merge pull request 'fix(denoise-tester): play models in both ears; match the call's DeepFilterNet strength' (#483) from fix/denoise-tester-audio into lotus
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 5s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 18m59s
2026-10-30 19:30:49 -04:00
Lotus CIandClaude Opus 5.5 78b19f1ced chore(deps): Element Call 0.28.0-lotus.3 (DeepFilterNet strength 25, model-bytes guard)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 20m55s
Picks up element-call#64 (DeepFilterNet no longer silently passes raw
audio through if its model is served Content-Encoding: gzip) and #65
(attenuation limit 60 -> 25 dB, fixing choppy word endings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 19:11:01 -04:00
Lotus CIandClaude Opus 5.5 2f5bcf2b91 fix(denoise-tester): play models in both ears; match the call's DeepFilterNet strength
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 21s
CI / Build & Quality Checks (pull_request) Successful in 7m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 27m13s
- Recorded clips often decode as stereo (Chrome Opus/WebM). The models
  only process channel 0, and the default channel mode ignores
  channelCount, so RNNoise/Speex played the cleaned audio in the LEFT EAR
  ONLY. Downmix to mono before the models (as Lotus Call does, #24) and
  pin the RNNoise/Speex/gate nodes to explicit mono. Verified in Chromium
  with a stereo speech clip through RNNoise: before L 16.1 / R 0.0, after
  L 16.0 / R 15.9.
- DeepFilterNet ran at attenuation limit 80 here vs 60 in calls; both
  muted the background outright, which sounds like the mic cutting out
  at word ends. Use 25, matching element-call#65.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 18:53:17 -04:00
jared d1a9625b1c Merge pull request 'feat(settings): make DTLN the default ML noise-suppression model' (#482) from feat/denoise-dtln-default into lotus
CI / Secret scan (gitleaks) (push) Successful in 47s
CI / Build & Quality Checks (push) Successful in 3m56s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 34m55s
2026-10-30 15:42:48 -04:00
Lotus CIandClaude Opus 5.5 9ab01ff6f6 feat(settings): make DTLN the default ML noise-suppression model
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Build & Quality Checks (pull_request) Successful in 3m45s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 33m3s
Browser-native stays the overall default; this only changes which model
the ML option starts on.

Measured on real speech mixed with fan, keyboard, street and babble noise
(SNR 5/15 dB), scored against the clean voice:
- DTLN: most natural voice (best PESQ/STOI of the four), strong on fans
  and typing, ~9% of one core.
- DeepFilterNet 3: removes the most noise (typing, traffic) but voices
  can sound slightly muffled, ~30% of one core.
- RNNoise: steady noise only; little help with typing.
- Speex: barely reduces noise.
- No model removes other people talking in the background.

The model list is now ordered by recommendation, with descriptions and
ratings from these results. The "Transients" column is renamed
"Typing & clicks".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 15:05:31 -04:00
jared d2c3c56b18 Merge pull request 'fix(settings): measured CPU and download figures for the noise-suppression models' (#481) from fix/denoise-cpu-labels into lotus
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 23m23s
2026-10-29 21:57:14 -04:00
jared e2ce177930 Merge pull request 'fix(call): keep browser AGC on with ML noise suppression (DeepFilterNet "makes people quiet")' (#480) from fix/denoise-keep-agc into lotus
CI / Build & Quality Checks (push) Successful in 1m43s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Canceled after 10m55s
2026-10-29 21:44:22 -04:00
Lotus CIandClaude Opus 5.5 1ebc42863e fix(settings): measured CPU and download figures for the noise-suppression models
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 19m45s
The CPU column was guessed (DeepFilterNet "25-50%", DTLN "10-20%") with no
unit, so it read like Task Manager's whole-CPU figure and looked wildly off.
Measured with each model running exactly as Lotus Call runs it (real-time
speech, browser process CPU time from /proc minus a no-model baseline,
Ryzen 7 PRO 4750GE): DeepFilterNet ~30%, DTLN ~9%, RNNoise ~3%, Speex ~2%
of one core. Label it "one core" and explain the Task Manager relation.
DTLN's download was listed as ~4 MB but fetches ~20 MB (17 MB worklet with
the runtime inlined, plus 4 MB of models); DFN's ~18 MB is right (17.6 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-29 21:35:20 -04:00
Lotus CIandClaude Opus 5.5 58f7b18746 fix(call): keep browser AGC on with ML noise suppression
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Build & Quality Checks (pull_request) Successful in 1m30s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 16m57s
Choosing an ML model (RNNoise / Speex / DTLN / DeepFilterNet) turned the
browser's auto gain control off along with its noise suppressor, so the
call carried the mic's raw level: voices came out 10-20 dB quieter than
in the browser tier and DeepFilterNet "made people quiet". Measured with
speech through each model exactly as the call runs it, the models keep
the voice within ~1.5 dB (DTLN -0.1, DFN3 -1.7, RNNoise -1.3, Speex -1.5)
while cutting background noise 6-29 dB, so the loss was AGC, not the
models. Keep AGC on in every tier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-29 21:25:34 -04:00
jared 43445db5cc Merge pull request 'feat(bridge): Discord badge on bridged users and a header chip on bridged rooms' (#479) from feat/discord-bridge-badge into lotus
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Build & Quality Checks (push) Successful in 2m40s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 23m48s
2026-10-28 22:27:41 -04:00
Lotus CIandClaude Opus 5.5 3c33169297 feat(bridge): Discord badge on bridged users and a header chip on bridged rooms
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Build & Quality Checks (pull_request) Successful in 2m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m26s
People who come in from Discord through mautrix-discord now carry a
Discord icon next to their name in the timeline, member list and profile,
and a room bridged to a Discord channel shows a "Discord" chip in its
header that names the channel and server and opens it in Discord.

Ghosts are recognised by mautrix-discord's @discord_<snowflake> user IDs;
rooms by their m.bridge / uk.half-shot.bridge state (protocol discordgo).
Only http(s) channel links are made clickable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 22:07:03 -04:00
jared 3163829c57 Merge pull request 'fix(call): send theme changes only once the call frame is ready (postMessage origin error)' (#478) from fix/call-theme-before-ready into lotus
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Build & Quality Checks (push) Successful in 2m43s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 23m49s
2026-10-28 20:52:06 -04:00
jared 2264c79833 Merge pull request 'fix(denoise): ship the DeepFilterNet 3 worklet as a file so it starts in the desktop app' (#477) from fix/dfn-worklet-file into lotus
CI / Build & Quality Checks (push) Canceled after 9m42s
CI / Secret scan (gitleaks) (push) Canceled after 5s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-28 20:42:24 -04:00
Lotus CIandClaude Opus 5.5 e8a04fee20 fix(denoise): ship the DeepFilterNet 3 worklet as a file so it starts in the desktop app
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 32s
CI / Build & Quality Checks (pull_request) Successful in 3m17s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 18m41s
Reported: DeepFilterNet 3 "did not start" in a desktop call. Console:
"Loading the script blob:http://127.0.0.1:44548/… violates … script-src",
then "[lotus] denoise setProcessor failed … Failed to load worklet module".
deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
from a blob: URL; audio worklets fall under script-src, and the desktop CSP
has no blob: there (the web call page has no script-src, so web was fine).

Instead of loosening the CSP, the build now writes the inline worklet to
denoise/deepfilternet/dfn-worklet.js and patches the copied module to load
it by URL (script-src self). The build fails if the library changes shape.

Verified in Chromium under the exact desktop CSP with the built assets:
before, DeepFilterNet fails as reported; after, it creates its node and, fed
4 s of white noise, cuts it by 60 dB (its configured level). RNNoise, Speex
and DTLN load as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 20:19:02 -04:00
29 changed files with 656 additions and 49 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"slug": "public",
"noticePrefix": "Matrix:",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
"@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.28.0-lotus.2",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
"version": "0.28.0-lotus.4",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.4/element-call-embedded-0.28.0-lotus.4.tgz",
"integrity": "sha512-g+1mkdpGqUkE7JJo3b2G0XBiwDFwEedWul20hwD0iBpDyW67OafjbhrzRwfROY8Q9bx6BnulJHiR6Ll98KjguA==",
"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.28.0-lotus.2",
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+2
View File
@@ -60,6 +60,7 @@ import { SrOnly } from '../features/call-status/styles.css';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
import { useCallDenoise } from '../hooks/useCallDenoise';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -836,6 +837,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useMutedTalkWarning(embed, joined);
useCallThemeSync(embed);
useCallQuality(embed);
useCallDenoise(embed);
useCallHangupEvent(
embed,
useCallback(() => {
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Icon, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
import {
BRIDGE_NETWORK_NAME,
BridgeNetwork,
describeRoomBridge,
getBridgedUserNetwork,
} from '../../utils/bridge';
import { useRoomBridges } from '../../hooks/useRoomBridges';
// Discord glyph (simple-icons, 24x24 viewBox like the folds icons).
const DiscordIcon: IconSrc = () => (
<path
fill="currentColor"
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
/>
);
const BRIDGE_ICON: Record<BridgeNetwork, IconSrc> = {
discord: DiscordIcon,
};
const BRIDGE_COLOR: Record<BridgeNetwork, string> = {
discord: '#5865F2',
};
type UserBridgeBadgeProps = {
userId: string;
size?: '50' | '100' | '200';
};
/** Network icon for a user who is bridged in from another chat network, else nothing. */
export function UserBridgeBadge({ userId, size = '100' }: UserBridgeBadgeProps) {
const network = getBridgedUserNetwork(userId);
if (!network) return null;
const label = `On ${BRIDGE_NETWORK_NAME[network]} (bridged)`;
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<span
ref={ref}
role="img"
aria-label={label}
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
>
<Icon size={size} src={BRIDGE_ICON[network]} style={{ color: BRIDGE_COLOR[network] }} />
</span>
)}
</TooltipProvider>
);
}
type RoomBridgeChipProps = {
room: Room;
};
/** Header chip for a room bridged to another network; links to the channel when known. */
export function RoomBridgeChip({ room }: RoomBridgeChipProps) {
const bridges = useRoomBridges(room);
const bridge = bridges[0];
if (!bridge) return null;
const description = describeRoomBridge(bridge);
const linkProps = bridge.externalUrl
? { as: 'a' as const, href: bridge.externalUrl, target: '_blank', rel: 'noreferrer noopener' }
: {};
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>
{bridge.externalUrl
? `${description}. Open in ${BRIDGE_NETWORK_NAME[bridge.network]}.`
: description}
</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
size="400"
variant="Secondary"
radii="300"
aria-label={description}
before={
<Icon
size="50"
src={BRIDGE_ICON[bridge.network]}
style={{ color: BRIDGE_COLOR[bridge.network] }}
/>
}
{...linkProps}
>
<Text size="T200">{BRIDGE_NETWORK_NAME[bridge.network]}</Text>
</Chip>
)}
</TooltipProvider>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './BridgeBadge';
@@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useLocalTime } from '../../hooks/useLocalTime';
import { UserBridgeBadge } from '../bridge';
type UserHeroProps = {
userId: string;
@@ -143,6 +144,7 @@ export function UserHeroName({
>
{displayName ?? username ?? userId}
</Text>
<UserBridgeBadge userId={userId} size="200" />
</Box>
{pronouns && (
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
+2
View File
@@ -71,6 +71,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/use
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
import { UserBridgeBadge } from '../../components/bridge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
@@ -178,6 +179,7 @@ function MemberItem({
{presence && (
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
)}
<UserBridgeBadge userId={member.userId} />
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
{typing && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+2
View File
@@ -34,6 +34,7 @@ import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { RoomBridgeChip } from '../../components/bridge';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
@@ -690,6 +691,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)}
</TooltipProvider>
)}
<RoomBridgeChip room={room} />
</Box>
{topic && (
<UseStateProvider initial={false}>
@@ -90,6 +90,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
import { UserBridgeBadge } from '../../../components/bridge';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
@@ -1084,6 +1085,7 @@ export const Message = React.memo(
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
<UserBridgeBadge userId={senderId} />
</Box>
<Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && (
@@ -6,6 +6,7 @@ import {
DenoiseNode,
buildGateNode,
buildModelNode,
monoDownmix,
readDb,
sampleRateFor,
} from '../../../utils/denoisePipeline';
@@ -139,7 +140,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
inAnalyser.fftSize = 1024;
source.connect(inAnalyser);
let head: AudioNode = source;
let head: AudioNode = source.connect(monoDownmix(ctx));
let gate: AudioWorkletNode | null = null;
if (useGate) {
gate = await buildGateNode(ctx, gateThreshold);
@@ -308,10 +309,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
const source = ctx.createBufferSource();
source.buffer = clip;
const mono = source.connect(monoDownmix(ctx));
let playGate: AudioWorkletNode | null = null;
let playModelNode: DenoiseNode | null = null;
if (playModel) {
let head: AudioNode = source;
let head: AudioNode = mono;
if (useGate) {
playGate = await buildGateNode(ctx, gateThreshold);
head.connect(playGate);
@@ -321,7 +323,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
head.connect(playModelNode.node);
playModelNode.node.connect(ctx.destination);
} else {
source.connect(ctx.destination);
mono.connect(ctx.destination);
}
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
// panel unmounted)? Tear down this now-orphaned graph instead of storing
@@ -2037,7 +2037,7 @@ export function Calls() {
<Text size="L400">Model</Text>
<SettingTile
title="ML model"
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
description="Choose the machine learning model used for noise removal. DTLN is the best all-rounder; DeepFilterNet removes the most noise at a higher CPU cost. None of the models remove other people talking in the background. CPU figures are the share of one processor core during a call on a mid-range desktop; Task Manager shows that divided by your number of cores, and slower computers use more."
after={
<SettingsSelect<DenoiseModelId>
value={callDenoiseModel}
@@ -2064,9 +2064,9 @@ export function Calls() {
<Box direction="Row" gap="400" wrap="Wrap">
{(
[
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
{ label: 'CPU (one core)', value: selectedDenoiseModel.cpuUsage },
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
{ label: 'Transients', value: selectedDenoiseModel.transients },
{ label: 'Typing & clicks', value: selectedDenoiseModel.transients },
{ label: 'Download', value: selectedDenoiseModel.binarySize },
] as const
).map((stat) => (
@@ -2113,7 +2113,7 @@ export function Calls() {
</Box>
<Box grow="Yes">
<Text size="T200" priority="300">
Transients
Typing & clicks
</Text>
</Box>
</Box>
@@ -2137,7 +2137,7 @@ export function Calls() {
</details>
<Text size="T200" priority="400">
Note: Applying changes requires rejoining the call.
Changes apply straight away, even during a call.
</Text>
</Box>
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { settingsAtom } from '../state/settings';
import { buildDenoisePayload } from '../utils/callDenoise';
/**
* Apply noise-suppression setting changes to a call that's already running
* (the fork's `io.lotus.set_denoise`): switching between off, browser-native
* and an ML model, or between models, takes effect without rejoining. The join
* itself still carries the settings in the call URL, so the first send is a
* no-op fork-side.
*/
export function useCallDenoise(embed: CallEmbed): void {
const {
callNoiseSuppression,
callDenoiseModel,
callDenoiseNativeNS,
callDenoiseGate,
callDenoiseGateThreshold,
} = useAtomValue(settingsAtom);
useEffect(() => {
embed.control.setDenoise(
buildDenoisePayload({
callNoiseSuppression,
callDenoiseModel,
callDenoiseNativeNS,
callDenoiseGate,
callDenoiseGateThreshold,
}),
);
}, [
embed,
callNoiseSuppression,
callDenoiseModel,
callDenoiseNativeNS,
callDenoiseGate,
callDenoiseGateThreshold,
]);
}
+32
View File
@@ -0,0 +1,32 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
import { useStateEventCallback } from './useStateEventCallback';
import { useForceUpdate } from './useForceUpdate';
import { getRoomBridges, RoomBridgeInfo } from '../utils/bridge';
import { StateEvent } from '../../types/matrix/room';
export const useRoomBridges = (room: Room): RoomBridgeInfo[] => {
const [updateCount, forceUpdate] = useForceUpdate();
useStateEventCallback(
room.client,
useCallback(
(event) => {
const type = event.getType();
if (
event.getRoomId() === room.roomId &&
(type === StateEvent.Bridge || type === StateEvent.BridgeUnstable)
) {
forceUpdate();
}
},
[room, forceUpdate],
),
);
return useMemo(
() => getRoomBridges(room),
// eslint-disable-next-line react-hooks/exhaustive-deps
[room, updateCount],
);
};
+1 -1
View File
@@ -62,7 +62,7 @@ export const useServerStatus = (
]);
if (stopped) return;
failures = 0;
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
setStatus(last);
// While this client's connection is lost, keep asking every minute: that
// is how it learns Kuma has checked the server since the drop.
+23
View File
@@ -4,6 +4,7 @@ import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { canDelegateCapability } from './utils';
import { CallErrorState, parseCallError } from './callError';
import type { LotusDenoisePayload } from '../../utils/callDenoise';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -106,6 +107,11 @@ export class CallControl extends EventEmitter implements CallControlState {
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
private lastQuality: LotusQualityPayload | null = null;
// Last noise-suppression choice (io.lotus.set_denoise), held like
// lastQuality: sent once joined and re-sent after an EC reconnect, whose
// remounted handler starts again from the settings in the join URL.
private lastDenoise: LotusDenoisePayload | null = null;
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
// user-initiated unmute that auto-undeafens the user.
@@ -191,6 +197,7 @@ export class CallControl extends EventEmitter implements CallControlState {
this.joined = true;
this.sendDeafenState();
this.sendQuality();
this.sendDenoise();
this.sendHotkeyCodes();
this.sendFrameScreenshareAllowed();
}
@@ -204,6 +211,7 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
this.sendDenoise();
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
@@ -762,6 +770,21 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
}
/**
* Switch noise suppression on the live mic (the fork's `io.lotus.set_denoise`):
* browser-native on/off and/or the ML model, without rejoining. Join-gated
* like setQuality().
*/
public setDenoise(payload: LotusDenoisePayload): void {
this.lastDenoise = payload;
this.sendDenoise();
}
private sendDenoise(): void {
if (!this.joined || !this.lastDenoise) return;
this.call.transport.send('io.lotus.set_denoise', this.lastDenoise).catch(() => undefined);
}
public dispose() {
// [Gitea #56] Don't let a manual focus pin outlive the call.
this.clearFocusParticipant();
+8 -4
View File
@@ -237,10 +237,14 @@ export class CallEmbed {
denoiseMode === 'browser' ||
(denoiseMode === 'ml' && denoiseNativeNS)
).toString(),
// Turn the browser's auto gain control OFF for the ML tier only: its
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
// tiers keep the browser's normal capture pipeline (AGC on).
autoGainControl: (denoiseMode !== 'ml').toString(),
// Keep the browser's auto gain control ON in every tier. It used to be
// turned off for the ML tier ("pumping"), but AGC is what brings a quiet
// mic up to speaking level: without it ML-tier voices went out 10-20 dB
// quieter than the browser tier, while the models themselves keep speech
// within ~1.5 dB (measured: RNNoise, Speex, DTLN, DeepFilterNet3). Chrome's
// AGC2 only adapts during speech, and the model runs after it, so it still
// removes the noise AGC leaves in the pauses.
autoGainControl: 'true',
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
+4 -2
View File
@@ -377,9 +377,11 @@ const defaultSettings: Settings = {
cameraOnJoin: false,
// Tier default stays browser-native (known-good; best-perceived in testing so
// far). If a user opts into the ML tier, default to the highest-quality model.
// far). If a user opts into the ML tier, default to DTLN: in our measurements
// it keeps voices the most natural, removes fans and typing well, and costs a
// third of DeepFilterNet's CPU.
callNoiseSuppression: 'browser',
callDenoiseModel: 'deepfilternet',
callDenoiseModel: 'dtln',
// "Series suppression" (stack the browser's native NS before the ML model) is
// off by default — best practice is a single NS stage; it's an opt-in test aid.
callDenoiseNativeNS: false,
+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import {
describeRoomBridge,
getBridgedUserNetwork,
getRoomBridges,
parseBridgeEvent,
} from './bridge';
const bridgeEvent = (type: string, stateKey: string, content: Record<string, unknown>) =>
new MatrixEvent({ type, state_key: stateKey, content, room_id: '!r:x', sender: '@discordbot:x' });
// Shape of what mautrix-discord 0.7.7 sends for a guild channel.
const discordContent = {
bridgebot: '@discordbot:matrix.lotusguild.org',
protocol: { id: 'discordgo', displayname: 'Discord' },
channel: {
id: '1565159372122882070',
displayname: '#general',
external_url: 'https://discord.com/channels/1/1565159372122882070',
},
network: { id: '1', displayname: 'Lotus Guild' },
};
const roomWithState = (events: MatrixEvent[]): Room =>
({
getLiveTimeline: () => ({
getState: () => ({
getStateEvents: (type: string) => events.filter((e) => e.getType() === type),
}),
}),
}) as unknown as Room;
test('Discord ghosts are recognised; the bridge bot and lookalikes are not', () => {
assert.equal(
getBridgedUserNetwork('@discord_123456789012345678:matrix.lotusguild.org'),
'discord',
);
assert.equal(getBridgedUserNetwork('@discordbot:matrix.lotusguild.org'), undefined);
assert.equal(getBridgedUserNetwork('@discord_fan:matrix.lotusguild.org'), undefined);
assert.equal(getBridgedUserNetwork('@discord-relay:matrix.lotusguild.org'), undefined);
assert.equal(getBridgedUserNetwork('@jared:matrix.lotusguild.org'), undefined);
});
test('parseBridgeEvent reads a mautrix-discord m.bridge event', () => {
assert.deepEqual(parseBridgeEvent(bridgeEvent('m.bridge', 'k', discordContent)), {
network: 'discord',
channelName: '#general',
networkName: 'Lotus Guild',
externalUrl: 'https://discord.com/channels/1/1565159372122882070',
});
});
test('parseBridgeEvent ignores unknown protocols and empty content', () => {
assert.equal(
parseBridgeEvent(bridgeEvent('m.bridge', 'k', { protocol: { id: 'telegram' } })),
undefined,
);
assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', {})), undefined);
});
test('only http(s) channel links are kept', () => {
const content = {
...discordContent,
channel: { displayname: '#x', external_url: ['javascript', 'alert(1)'].join(':') },
};
assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', content))?.externalUrl, undefined);
});
test('getRoomBridges merges m.bridge with its unstable copy, one entry per channel', () => {
const room = roomWithState([
bridgeEvent('m.bridge', 'fi.mau.discord://discord/1/2', discordContent),
bridgeEvent('uk.half-shot.bridge', 'fi.mau.discord://discord/1/2', discordContent),
]);
assert.equal(getRoomBridges(room).length, 1);
assert.equal(getRoomBridges(roomWithState([])).length, 0);
});
test('describeRoomBridge degrades gracefully without names', () => {
assert.equal(
describeRoomBridge({ network: 'discord', channelName: '#general', networkName: 'Lotus Guild' }),
'Bridged to #general in Lotus Guild on Discord',
);
assert.equal(describeRoomBridge({ network: 'discord' }), 'Bridged to Discord');
});
+80
View File
@@ -0,0 +1,80 @@
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvents } from './room';
export type BridgeNetwork = 'discord';
export const BRIDGE_NETWORK_NAME: Record<BridgeNetwork, string> = {
discord: 'Discord',
};
// Ghost (puppet) users a bridge creates for people on the other network.
// mautrix-discord's default username_template is discord_{{.}} with a numeric
// Discord snowflake; the bridge bot itself (@discordbot) is not a ghost.
const GHOST_PATTERNS: [RegExp, BridgeNetwork][] = [[/^@discord_\d+:/, 'discord']];
export const getBridgedUserNetwork = (userId: string): BridgeNetwork | undefined =>
GHOST_PATTERNS.find(([pattern]) => pattern.test(userId))?.[1];
// m.bridge protocol.id values that mean each network.
const PROTOCOL_NETWORKS: Record<string, BridgeNetwork> = {
discordgo: 'discord',
discord: 'discord',
};
export type RoomBridgeInfo = {
network: BridgeNetwork;
channelName?: string;
networkName?: string;
externalUrl?: string;
};
const asString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
// Only http(s) links are made clickable; the bridge (or anyone with state
// power in the room) controls this value.
const safeExternalUrl = (value: unknown): string | undefined => {
const raw = asString(value);
if (!raw) return undefined;
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
} catch {
return undefined;
}
};
export const parseBridgeEvent = (event: MatrixEvent): RoomBridgeInfo | undefined => {
const content = event.getContent();
const protocolId = asString(content?.protocol?.id)?.toLowerCase();
const network = protocolId ? PROTOCOL_NETWORKS[protocolId] : undefined;
if (!network) return undefined;
return {
network,
channelName: asString(content.channel?.displayname),
networkName: asString(content.network?.displayname),
externalUrl: safeExternalUrl(content.channel?.external_url),
};
};
/** Known bridges in a room, one per bridged channel (m.bridge preferred over the unstable copy). */
export const getRoomBridges = (room: Room): RoomBridgeInfo[] => {
const byKey = new Map<string, RoomBridgeInfo>();
[StateEvent.BridgeUnstable, StateEvent.Bridge].forEach((type) => {
getStateEvents(room, type).forEach((event) => {
const info = parseBridgeEvent(event);
if (info) byKey.set(event.getStateKey() ?? '', info);
});
});
return Array.from(byKey.values());
};
export const describeRoomBridge = (info: RoomBridgeInfo): string => {
const network = BRIDGE_NETWORK_NAME[info.network];
if (info.channelName && info.networkName) {
return `Bridged to ${info.channelName} in ${info.networkName} on ${network}`;
}
if (info.channelName) return `Bridged to ${info.channelName} on ${network}`;
return `Bridged to ${network}`;
};
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDenoisePayload } from './callDenoise';
test('buildDenoisePayload passes the settings through', () => {
assert.deepEqual(
buildDenoisePayload({
callNoiseSuppression: 'ml',
callDenoiseModel: 'dtln',
callDenoiseNativeNS: false,
callDenoiseGate: true,
callDenoiseGateThreshold: -50,
}),
{ mode: 'ml', model: 'dtln', nativeNS: false, gate: true, gateThreshold: -50 },
);
});
test('buildDenoisePayload uses the same defaults as the join URL', () => {
assert.deepEqual(buildDenoisePayload({}), {
mode: 'browser',
model: 'rnnoise',
nativeNS: true,
gate: false,
gateThreshold: -45,
});
});
+31
View File
@@ -0,0 +1,31 @@
import { DenoiseModelId, NoiseSuppressionMode } from '../state/settings';
/**
* Payload for the fork's `io.lotus.set_denoise` action, which switches noise
* suppression on the live mic mid-call (browser suppressor on/off and/or the
* ML model) instead of needing a rejoin.
*/
export type LotusDenoisePayload = {
mode: NoiseSuppressionMode;
model: DenoiseModelId;
nativeNS: boolean;
gate: boolean;
gateThreshold: number;
};
export type DenoiseSettings = {
callNoiseSuppression?: NoiseSuppressionMode;
callDenoiseModel?: DenoiseModelId;
callDenoiseNativeNS?: boolean;
callDenoiseGate?: boolean;
callDenoiseGateThreshold?: number;
};
/** Same defaults as the call URL built at join (useCallStart/createCallEmbed). */
export const buildDenoisePayload = (s: DenoiseSettings): LotusDenoisePayload => ({
mode: s.callNoiseSuppression ?? 'browser',
model: s.callDenoiseModel ?? 'rnnoise',
nativeNS: s.callDenoiseNativeNS ?? true,
gate: s.callDenoiseGate ?? false,
gateThreshold: s.callDenoiseGateThreshold ?? -45,
});
+23 -1
View File
@@ -21,6 +21,20 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/+$/, '')}/public/element-cal
*/
export const sampleRateFor = (model: DenoiseModelId): number => (model === 'dtln' ? 16000 : 48000);
/**
* A pass-through node that downmixes whatever feeds it to ONE channel. Every
* model only processes channel 0, and a recorded clip often decodes as stereo
* (Chrome's Opus/WebM), so without this RNNoise/Speex played the cleaned audio
* in the left ear only. Lotus Call does the same (#24). A mono output is then
* upmixed to both ears by the destination.
*/
export const monoDownmix = (ctx: BaseAudioContext): GainNode =>
new GainNode(ctx, {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
});
export type DenoiseNode = {
node: AudioWorkletNode;
dispose: () => void;
@@ -85,7 +99,10 @@ export async function buildModelNode(
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
const core = new mod.DeepFilterNet3Core({
sampleRate: sampleRateFor(model),
noiseReductionLevel: 80,
// Same attenuation limit as Lotus Call (dB). Higher values mute the
// background outright, so words drop into dead silence and sound
// clipped at the end.
noiseReductionLevel: 25,
assetConfig: { cdnUrl: dfnBase },
});
await core.initialize();
@@ -105,6 +122,8 @@ export async function buildModelNode(
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
const node = new AudioWorkletNode(ctx, cfg.proc, {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
numberOfInputs: 1,
numberOfOutputs: 1,
processorOptions: { maxChannels: 1, wasmBinary },
@@ -127,6 +146,9 @@ export async function buildGateNode(
): Promise<AudioWorkletNode> {
await addModuleOnce(ctx, 'noiseGateWorklet.js');
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
processorOptions: {
openThreshold: thresholdDb,
closeThreshold: thresholdDb - 5,
+72
View File
@@ -285,3 +285,75 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)',
'Read the guide docs',
);
});
test('[noticePrefix] resolveStatusPage keeps it only when set', () => {
const base = { url: 'https://kuma.example', slug: 'public' };
assert.equal(
resolveStatusPage({ hs: { ...base, noticePrefix: ' Matrix: ' } }, 'hs')?.noticePrefix,
'Matrix:',
);
assert.equal(resolveStatusPage({ hs: base }, 'hs')?.noticePrefix, undefined);
});
test('[noticePrefix] on a shared page only prefixed notices reach users, without the prefix', () => {
const page = {
publicGroupList: [],
maintenanceList: [
{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' },
{ id: 2, title: 'matrix: Synapse upgrade', status: 'under-maintenance' },
],
incidents: [
{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' },
{ id: 4, title: 'Matrix: Calls are flaky', content: 'y', style: 'warning' },
{ id: 5, title: 'Matrix:', content: 'nothing left after the prefix', style: 'info' },
{ id: 6, title: 'Matrixish news', content: 'z', style: 'info' },
],
};
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS, 'Matrix:');
assert.deepEqual(
s.maintenance.map((m) => [m.id, m.title]),
[['2', 'Synapse upgrade']],
);
assert.deepEqual(
s.incidents.map((i) => [i.id, i.title]),
[['4', 'Calls are flaky']],
);
});
test('[noticePrefix] without one, every notice shows as before', () => {
const page = {
publicGroupList: [],
maintenanceList: [{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' }],
incidents: [{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' }],
};
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS);
assert.equal(s.maintenance[0].title, 'Jellyfin upgrade');
assert.equal(s.incidents[0].title, 'Storage redundancy restored');
});
test('[split public page] a Discord group going down touches neither homeserver nor calls', () => {
const up = [
{ status: 1, time: '2026-11-05 01:00:00' },
{ status: 1, time: '2026-11-05 01:01:00' },
];
const down = [
{ status: 0, time: '2026-11-05 01:00:00' },
{ status: 0, time: '2026-11-05 01:01:00' },
];
const page = {
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 30 }] },
{ name: 'Voice calls', monitorList: [{ id: 37 }] },
{ name: 'Discord', monitorList: [{ id: 49 }] },
{ name: 'Services', monitorList: [{ id: 27 }] },
],
};
const s = parseKumaStatus(
page,
{ heartbeatList: { 30: up, 37: up, 49: down, 27: down } },
DEFAULT_GROUPS,
);
assert.equal(s.homeserver, 'up');
assert.equal(s.calls, 'up');
assert.equal(s.login, 'unknown');
});
+28 -5
View File
@@ -7,6 +7,11 @@
* "login": "Login" } } }
* Users of any other homeserver never contact Kuma.
*
* Optional "noticePrefix": on a status page shared with other services, only
* incidents and maintenance titled with this prefix (e.g. "Matrix:") reach
* Lotus users, shown without it, so a media server's maintenance doesn't say
* messages may be interrupted. Without it, every notice on the page shows.
*
* Kuma 2.x public JSON:
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
* maintenanceList (only windows UNDER maintenance right now), incidents
@@ -19,7 +24,12 @@
*/
export type StatusGroups = { homeserver?: string; calls?: string; login?: string };
export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups };
export type StatusPageConfig = {
url: string;
slug: string;
groups: StatusGroups;
noticePrefix?: string;
};
export type ServiceState = 'up' | 'down' | 'unknown';
export type KumaMaintenance = { id: string; title: string; description: string; end?: number };
@@ -69,9 +79,11 @@ export const resolveStatusPage = (
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
const g = obj(entry.groups) ?? {};
const noticePrefix = str(entry.noticePrefix).trim();
return {
url: url.origin,
slug,
...(noticePrefix ? { noticePrefix } : {}),
groups: {
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
calls: str(g.calls) || DEFAULT_GROUPS.calls,
@@ -133,10 +145,18 @@ const groupState = (
return known > 0 ? 'up' : 'unknown';
};
/** The title without the prefix, or undefined when it doesn't carry it. */
const noticeTitle = (title: string, prefix: string | undefined): string | undefined => {
if (!prefix) return title;
if (title.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return undefined;
return title.slice(prefix.length).trim() || undefined;
};
export const parseKumaStatus = (
pageJson: unknown,
heartbeatJson: unknown,
groupNames: StatusGroups,
noticePrefix?: string,
): ServerStatus => {
const page = obj(pageJson) ?? {};
const groups = new Map<string, string[]>();
@@ -155,14 +175,14 @@ export const parseKumaStatus = (
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
)
.map((m) => obj(m))
.filter((m): m is Record<string, unknown> => !!m && str(m.title) !== '')
.filter((m): m is Record<string, unknown> => !!m && !!noticeTitle(str(m.title), noticePrefix))
.filter((m) => !m.status || m.status === 'under-maintenance')
.map((m) => {
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
const end = Date.parse(str(slot?.endDate));
return {
id: String(m.id ?? str(m.title)),
title: str(m.title),
title: noticeTitle(str(m.title), noticePrefix) ?? '',
description: str(m.description),
...(Number.isFinite(end) ? { end } : {}),
};
@@ -170,10 +190,13 @@ export const parseKumaStatus = (
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
.map((i) => obj(i))
.filter((i): i is Record<string, unknown> => !!i && str(i.title) !== '' && i.active !== false)
.filter(
(i): i is Record<string, unknown> =>
!!i && !!noticeTitle(str(i.title), noticePrefix) && i.active !== false,
)
.map((i) => ({
id: String(i.id ?? str(i.title)),
title: str(i.title),
title: noticeTitle(str(i.title), noticePrefix) ?? '',
content: str(i.content),
style: str(i.style) || 'info',
updated: str(i.lastUpdatedDate) || str(i.createdDate),
+2 -2
View File
@@ -5,10 +5,10 @@ import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from '.
// ── Model catalog (data integrity) ──────────────────────────────────────────
test('DENOISE_MODELS lists the four models ordered best-quality (highest CPU) first', () => {
test('DENOISE_MODELS lists the four models most-recommended first', () => {
assert.deepEqual(
DENOISE_MODELS.map((m) => m.id),
['deepfilternet', 'dtln', 'rnnoise', 'speex'],
['dtln', 'deepfilternet', 'rnnoise', 'speex'],
);
});
+26 -20
View File
@@ -1,12 +1,18 @@
/**
* Detection utilities + model catalog for Lotus ML noise suppression
* (DeepFilterNet 3 / DTLN / RNNoise / Speex). The catalog is ordered by
* quality (and, correspondingly, CPU cost) — highest first — and drives the
* order of the model dropdown in settings.
* (DTLN / DeepFilterNet 3 / RNNoise / Speex). The catalog is ordered by
* recommendation — most recommended first — and drives the order of the model
* dropdown in settings.
*/
import { DenoiseModelId } from '../state/settings';
// CPU = share of ONE core while in a call, measured with each model running
// exactly as Lotus Call runs it (Ryzen 7 PRO 4750GE, Chromium); download = bytes
// actually fetched on first use. Re-measure when a model or its settings change.
// Ratings come from scoring each model against the clean voice (PESQ/STOI) on
// real speech mixed with fan, keyboard, street and babble noise at SNR 5 and
// 15 dB; `transients` is how well it removes typing and clicks.
export type DenoiseModel = {
id: DenoiseModelId;
name: string;
@@ -17,25 +23,25 @@ export type DenoiseModel = {
voiceQuality: 'Moderate' | 'High' | 'Very High';
};
// Ordered best-quality (highest CPU) first — this is the dropdown order.
// Ordered most-recommended first — this is the dropdown order.
export const DENOISE_MODELS: DenoiseModel[] = [
{
id: 'deepfilternet',
name: 'DeepFilterNet 3 (beta)',
id: 'dtln',
name: 'DTLN (recommended)',
description:
'Studio-grade deep-learning model (48 kHz fullband, ONNX). Best quality; highest CPU and a larger one-time download.',
cpuUsage: '25-50%',
binarySize: '~18 MB',
'Keeps voices the most natural of the four. Removes fans and typing well, but not traffic or people talking in the background.',
cpuUsage: '~9%',
binarySize: '~20 MB',
transients: 'Excellent',
voiceQuality: 'Very High',
},
{
id: 'dtln',
name: 'DTLN (beta)',
id: 'deepfilternet',
name: 'DeepFilterNet 3 (strongest)',
description:
'Dual-signal deep-learning model (16 kHz). Strong on transient noise; moderate CPU.',
cpuUsage: '10-20%',
binarySize: '~4 MB',
'Removes the most noise, including loud typing and traffic, but voices can sound slightly muffled. Uses about three times the CPU of DTLN.',
cpuUsage: '~30%',
binarySize: '~18 MB',
transients: 'Excellent',
voiceQuality: 'High',
},
@@ -43,18 +49,18 @@ export const DENOISE_MODELS: DenoiseModel[] = [
id: 'rnnoise',
name: 'RNNoise',
description:
'Lightweight hybrid model (48 kHz). Very low CPU; good for steady noise like fans, but can sound processed at full strength.',
cpuUsage: '< 5%',
'Very light. Handles steady noise like fans and traffic; does little for typing and can sound slightly processed.',
cpuUsage: '~3%',
binarySize: '< 1 MB',
transients: 'Good',
transients: 'Poor',
voiceQuality: 'Moderate',
},
{
id: 'speex',
name: 'Speex (Legacy)',
name: 'Speex (legacy)',
description:
'Classic DSP noise suppressor. Minimal CPU, gentlest on voice; weakest suppression.',
cpuUsage: '< 2%',
'Classic suppressor kept for very slow computers. Barely reduces noise in our tests; prefer any other option.',
cpuUsage: '~2%',
binarySize: '< 1 MB',
transients: 'Poor',
voiceQuality: 'Moderate',
+4
View File
@@ -50,6 +50,10 @@ export enum StateEvent {
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
// state-key, aggregated with parent-space packs like custom emoji.
LotusSoundboardRoom = 'io.lotus.soundboard',
// MSC2346 bridge info; state_key identifies the bridged channel. Bridges
// still send the pre-stable uk.half-shot.bridge copy alongside it.
Bridge = 'm.bridge',
BridgeUnstable = 'uk.half-shot.bridge',
}
export enum MessageEvent {
+31
View File
@@ -136,6 +136,36 @@ function denoiseSources() {
};
}
// deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
// from a blob: URL. Audio worklets fall under script-src, and the desktop app's
// CSP (rightly) has no blob: there, so DeepFilterNet 3 failed to start ("denoise
// setProcessor failed … Failed to load worklet module script: blob:…"). Write
// the worklet out as a real file next to the module and load it by URL instead,
// which script-src 'self' allows everywhere. Fails the build if the library's
// shape changes, rather than shipping a model that cannot start.
const DFN_WORKLET_FILE = 'dfn-worklet.js';
const DFN_BLOB_LOAD = ` const blob = new Blob([workletCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
await audioContext.audioWorklet.addModule(blobUrl);`;
function dfnWorkletToFile(esmPath) {
const src = fs.readFileSync(esmPath, 'utf8');
const literal = src.match(/^var workletCode = (".*");\s*$/m)?.[1];
const code = literal ? JSON.parse(literal) : undefined;
if (!code?.includes('registerProcessor(') || !src.includes(DFN_BLOB_LOAD)) {
throw new Error(
`[lotus-denoise] deepfilternet3-noise-filter changed shape (inline worklet / blob loader not found in ${esmPath}) — build aborted; update dfnWorkletToFile in vite.config.js.`,
);
}
fs.writeFileSync(path.join(path.dirname(esmPath), DFN_WORKLET_FILE), code);
fs.writeFileSync(
esmPath,
src.replace(
DFN_BLOB_LOAD,
` // [lotus] Loaded from a file, not a blob: URL (CSP script-src 'self').\n await audioContext.audioWorklet.addModule(new URL('./${DFN_WORKLET_FILE}', import.meta.url).href);`,
),
);
}
// 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
@@ -212,6 +242,7 @@ function lotusDenoise() {
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
});
dfnWorkletToFile(path.join(denoiseDir, 'deepfilternet/index.esm.js'));
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
// Our forked Element Call now runs ML denoise in-source as a real LiveKit