Reorganize ML noise suppression settings UI

Move the model comparison out of the always-visible Noise Suppression
description and into the ML-only sub-settings. Add a compact info card
for the selected model (CPU / voice quality / transients / download) plus
a collapsible 4-model comparison. Group ML sub-settings into Model,
Enhancements, and Test & calibrate sections with clear labels and
separators. Fix invented --lt-border-color token and hardcoded
rgba background to real TDS tokens. Build the model dropdown and
DenoiseTester labels/compare buttons from DENOISE_MODELS so
DeepFilterNet 3 is handled correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:02:16 -04:00
parent 04b56ffacd
commit cf7c66b99a
3 changed files with 166 additions and 102 deletions
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text } from 'folds'; import { Box, Button, Text } from 'folds';
import { DenoiseModelId } from '../../../state/settings'; import { DenoiseModelId } from '../../../state/settings';
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
import { import {
DenoiseNode, DenoiseNode,
buildGateNode, buildGateNode,
@@ -307,7 +308,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
[stopLive, stopPlayback], [stopLive, stopPlayback],
); );
const modelLabel = model === 'rnnoise' ? 'RNNoise' : model === 'speex' ? 'Speex' : 'DTLN'; const modelLabel = DENOISE_MODELS.find((m) => m.id === model)?.name ?? model;
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
@@ -367,6 +368,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
{ label: 'RNNoise', model: 'rnnoise' }, { label: 'RNNoise', model: 'rnnoise' },
{ label: 'Speex', model: 'speex' }, { label: 'Speex', model: 'speex' },
{ label: 'DTLN', model: 'dtln' }, { label: 'DTLN', model: 'dtln' },
{ label: 'DeepFilterNet', model: 'deepfilternet' },
] as const ] as const
).map((b) => ( ).map((b) => (
<Button <Button
+160 -100
View File
@@ -1240,6 +1240,7 @@ function Calls() {
const deafenBind = useKeyBind(setDeafenKey); const deafenBind = useKeyBind(setDeafenKey);
const mlSupported = isMLDenoiseSupported(); const mlSupported = isMLDenoiseSupported();
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
@@ -1258,46 +1259,9 @@ function Calls() {
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Text> <Text>
Filter background noise from your mic during calls. Browser-native uses the built-in Filter background noise from your mic during calls. Browser-native uses the built-in
WebRTC suppressor (Google NSNet2). WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text> </Text>
<Box direction="Column" gap="100" style={{ overflowX: 'auto' }}>
<Box
direction="Row"
gap="100"
style={{ borderBottom: '1px solid var(--lt-border-color)', paddingBottom: '4px' }}
>
<Box style={{ width: '120px' }}>
<Text size="T200">Model</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">CPU</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">Quality</Text>
</Box>
<Box grow="Yes">
<Text size="T200">Transients</Text>
</Box>
</Box>
{DENOISE_MODELS.map((model) => (
<Box key={model.id} direction="Row" gap="100">
<Box style={{ width: '120px' }}>
<Text size="T200">{model.name}</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">{model.cpuUsage}</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">{model.voiceQuality}</Text>
</Box>
<Box grow="Yes">
<Text size="T200">{model.transients}</Text>
</Box>
</Box>
))}
</Box>
{!mlSupported && ( {!mlSupported && (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="T200" priority="400"> <Text size="T200" priority="400">
@@ -1312,11 +1276,6 @@ function Calls() {
</Box> </Box>
</Box> </Box>
)} )}
{callNoiseSuppression === 'ml' && (
<Text size="T200" priority="400">
Note: Applying changes requires rejoining the call.
</Text>
)}
</Box> </Box>
} }
after={ after={
@@ -1339,73 +1298,174 @@ function Calls() {
{callNoiseSuppression === 'ml' && ( {callNoiseSuppression === 'ml' && (
<Box <Box
direction="Column" direction="Column"
gap="300" gap="400"
style={{ style={{
padding: '16px', padding: '16px',
marginTop: '8px', marginTop: '8px',
borderTop: '1px solid var(--lt-border-color)', borderTop: '1px solid var(--border-color)',
background: 'rgba(0,0,0,0.1)', background: 'var(--bg-card)',
}} }}
> >
<SettingTile {/* ── Model selection ───────────────────────────────────────── */}
title="ML Model" <Box direction="Column" gap="200">
description="Choose the machine learning model to use for noise removal." <Text size="L400">Model</Text>
after={ <SettingTile
<SettingsSelect<DenoiseModelId> title="ML Model"
value={callDenoiseModel} description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
onChange={setCallDenoiseModel} after={
options={[ <SettingsSelect<DenoiseModelId>
{ value: 'rnnoise', label: 'RNNoise' }, value={callDenoiseModel}
{ value: 'speex', label: 'Speex (Legacy)' }, onChange={setCallDenoiseModel}
{ value: 'dtln', label: 'DTLN (beta)' }, options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))}
{ value: 'deepfilternet', label: 'DeepFilterNet 3 (beta)' }, />
]} }
/> />
} {selectedDenoiseModel && (
/> <Box
direction="Column"
<SettingTile gap="200"
title="Series Suppression" style={{
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum." padding: '12px',
after={ borderRadius: '8px',
<Switch border: '1px solid var(--border-color)',
variant="Primary" background: 'var(--bg-input)',
value={callDenoiseNativeNS} }}
onChange={setCallDenoiseNativeNS} >
/> <Text size="T300">{selectedDenoiseModel.name}</Text>
} <Text size="T200" priority="300">
/> {selectedDenoiseModel.description}
</Text>
<SettingTile <Box direction="Row" gap="400" wrap="Wrap">
title="Noise Gate" {(
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences." [
after={ { label: 'CPU', value: selectedDenoiseModel.cpuUsage },
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} /> { label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
} { label: 'Transients', value: selectedDenoiseModel.transients },
/> { label: 'Download', value: selectedDenoiseModel.binarySize },
] as const
{callDenoiseGate && ( ).map((stat) => (
<Box direction="Column" gap="100"> <Box key={stat.label} direction="Column" gap="100">
<Box direction="Row" justifyContent="SpaceBetween"> <Text size="T200" priority="300">
<Text size="T200">Gate Threshold</Text> {stat.label}
<Text size="T200">{callDenoiseGateThreshold} dB</Text> </Text>
<Text size="T300">{stat.value}</Text>
</Box>
))}
</Box>
</Box> </Box>
<input )}
type="range"
min="-100"
max="0"
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
/>
</Box>
)}
<details>
<summary style={{ cursor: 'pointer' }}>
<Text as="span" size="T200" priority="300">
Compare all models
</Text>
</summary>
<Box direction="Column" gap="100" style={{ overflowX: 'auto', marginTop: '8px' }}>
<Box
direction="Row"
gap="100"
style={{
borderBottom: '1px solid var(--border-color)',
paddingBottom: '4px',
}}
>
<Box style={{ width: '160px' }}>
<Text size="T200" priority="300">
Model
</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200" priority="300">
CPU
</Text>
</Box>
<Box style={{ width: '90px' }}>
<Text size="T200" priority="300">
Quality
</Text>
</Box>
<Box grow="Yes">
<Text size="T200" priority="300">
Transients
</Text>
</Box>
</Box>
{DENOISE_MODELS.map((model) => (
<Box key={model.id} direction="Row" gap="100">
<Box style={{ width: '160px' }}>
<Text size="T200">{model.name}</Text>
</Box>
<Box style={{ width: '80px' }}>
<Text size="T200">{model.cpuUsage}</Text>
</Box>
<Box style={{ width: '90px' }}>
<Text size="T200">{model.voiceQuality}</Text>
</Box>
<Box grow="Yes">
<Text size="T200">{model.transients}</Text>
</Box>
</Box>
))}
</Box>
</details>
<Text size="T200" priority="400">
Note: Applying changes requires rejoining the call.
</Text>
</Box>
{/* ── Enhancement toggles ───────────────────────────────────── */}
<Box
direction="Column"
gap="300"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
>
<Text size="L400">Enhancements</Text>
<SettingTile
title="Series Suppression"
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
after={
<Switch
variant="Primary"
value={callDenoiseNativeNS}
onChange={setCallDenoiseNativeNS}
/>
}
/>
<SettingTile
title="Noise Gate"
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
after={
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
}
/>
{callDenoiseGate && (
<Box direction="Column" gap="100">
<Box direction="Row" justifyContent="SpaceBetween">
<Text size="T200">Gate Threshold</Text>
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
</Box>
<input
type="range"
min="-100"
max="0"
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
/>
</Box>
)}
</Box>
{/* ── Test & calibrate ──────────────────────────────────────── */}
<Box <Box
direction="Column" direction="Column"
gap="200" gap="200"
style={{ paddingTop: '8px', borderTop: '1px solid var(--border-color)' }} style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
> >
<Text size="L400">Test &amp; calibrate</Text> <Text size="L400">Test &amp; calibrate</Text>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
+3 -1
View File
@@ -2,8 +2,10 @@
* Detection utilities for Lotus ML noise suppression (RNNoise). * Detection utilities for Lotus ML noise suppression (RNNoise).
*/ */
import { DenoiseModelId } from '../state/settings';
export type DenoiseModel = { export type DenoiseModel = {
id: string; id: DenoiseModelId;
name: string; name: string;
description: string; description: string;
cpuUsage: string; cpuUsage: string;