532 lines
17 KiB
TypeScript
532 lines
17 KiB
TypeScript
import { useDevices } from '@/components/devices-provider/hooks/use-devices';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Group } from '@/components/ui/group';
|
|
import { LoadingCard } from '@/components/ui/loading-card';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from '@/components/ui/select';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { closeServerScreens } from '@/features/server-screens/actions';
|
|
import { useCurrentVoiceChannelId } from '@/features/server/channels/hooks';
|
|
import { useVoice } from '@/features/server/voice/hooks';
|
|
import { useForm } from '@/hooks/use-form';
|
|
import { Resolution } from '@/types';
|
|
import { Download, Trash2, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
|
import { memo, useCallback, useEffect, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { useAvailableDevices } from './hooks/use-available-devices';
|
|
import ResolutionFpsControl from './resolution-fps-control';
|
|
|
|
const DEFAULT_NAME = 'default';
|
|
type TMicProcessingSnapshot = {
|
|
active: boolean;
|
|
chain: string;
|
|
note: string;
|
|
updatedAt?: number;
|
|
};
|
|
|
|
const MIC_STATUS_STORAGE_KEY = 'pulse.micProcessingStatus';
|
|
|
|
const loadStoredMicStatus = (): TMicProcessingSnapshot | null => {
|
|
try {
|
|
const raw = localStorage.getItem(MIC_STATUS_STORAGE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as TMicProcessingSnapshot;
|
|
if (
|
|
typeof parsed?.active !== 'boolean' ||
|
|
typeof parsed?.chain !== 'string' ||
|
|
typeof parsed?.note !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getConfiguredMicChain = (values: {
|
|
noiseSuppressionDeepFilterNet?: boolean;
|
|
noiseSuppressionRnnoise?: boolean;
|
|
keyboardSuppression?: boolean;
|
|
noiseSuppressionEnhanced?: boolean;
|
|
echoCancellation?: boolean;
|
|
noiseSuppression?: boolean;
|
|
autoGainControl?: boolean;
|
|
}) => {
|
|
const chain: string[] = [];
|
|
if (values.noiseSuppressionDeepFilterNet) chain.push('DeepFilterNet');
|
|
if (values.noiseSuppressionRnnoise) chain.push('RNNoise');
|
|
if (values.keyboardSuppression) chain.push('Keyboard Gate');
|
|
if (values.noiseSuppressionEnhanced) chain.push('Enhanced (browser)');
|
|
if (
|
|
!chain.length &&
|
|
(values.echoCancellation || values.noiseSuppression || values.autoGainControl)
|
|
) {
|
|
chain.push('Browser DSP');
|
|
}
|
|
return chain.length ? chain.join(' + ') : 'none';
|
|
};
|
|
|
|
const Devices = memo(() => {
|
|
const currentVoiceChannelId = useCurrentVoiceChannelId();
|
|
const {
|
|
inputDevices,
|
|
videoDevices,
|
|
loading: availableDevicesLoading
|
|
} = useAvailableDevices();
|
|
const { micProcessingStatus } = useVoice();
|
|
const { devices, saveDevices, loading: devicesLoading } = useDevices();
|
|
const { values, onChange } = useForm(devices);
|
|
const [storedMicStatus, setStoredMicStatus] =
|
|
useState<TMicProcessingSnapshot | null>(null);
|
|
|
|
useEffect(() => {
|
|
setStoredMicStatus(loadStoredMicStatus());
|
|
|
|
const onStatus = (event: Event) => {
|
|
const custom = event as CustomEvent<TMicProcessingSnapshot>;
|
|
if (!custom.detail) return;
|
|
setStoredMicStatus(custom.detail);
|
|
};
|
|
|
|
window.addEventListener('pulse:mic-processing-status', onStatus as EventListener);
|
|
return () => {
|
|
window.removeEventListener(
|
|
'pulse:mic-processing-status',
|
|
onStatus as EventListener
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
const configuredChain = getConfiguredMicChain(values);
|
|
const runtimeUnavailable =
|
|
!micProcessingStatus.active &&
|
|
micProcessingStatus.chain === 'none' &&
|
|
micProcessingStatus.note === 'Runtime status unavailable on this screen';
|
|
const effectiveStatus = runtimeUnavailable && storedMicStatus
|
|
? storedMicStatus
|
|
: micProcessingStatus;
|
|
|
|
const handleEnhancedToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange('noiseSuppressionEnhanced', checked);
|
|
if (checked) {
|
|
onChange('noiseSuppressionRnnoise', false);
|
|
onChange('noiseSuppressionDeepFilterNet', false);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleRnnoiseToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange('noiseSuppressionRnnoise', checked);
|
|
if (checked) {
|
|
onChange('noiseSuppressionEnhanced', false);
|
|
onChange('noiseSuppressionDeepFilterNet', false);
|
|
onChange('echoCancellation', false);
|
|
onChange('autoGainControl', false);
|
|
onChange('noiseSuppression', false);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleDeepFilterToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange('noiseSuppressionDeepFilterNet', checked);
|
|
if (checked) {
|
|
onChange('noiseSuppressionEnhanced', false);
|
|
onChange('noiseSuppressionRnnoise', false);
|
|
onChange('echoCancellation', false);
|
|
onChange('autoGainControl', false);
|
|
onChange('noiseSuppression', false);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleEchoCancellationToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange('echoCancellation', checked);
|
|
if (checked) {
|
|
onChange('noiseSuppressionRnnoise', false);
|
|
onChange('noiseSuppressionDeepFilterNet', false);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleNoiseSuppressionToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange('noiseSuppression', checked);
|
|
if (checked) {
|
|
onChange('noiseSuppressionRnnoise', false);
|
|
onChange('noiseSuppressionDeepFilterNet', false);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleAutoGainControlToggle = useCallback(
|
|
(checked: boolean) => {
|
|
onChange('autoGainControl', checked);
|
|
if (checked) {
|
|
onChange('noiseSuppressionRnnoise', false);
|
|
onChange('noiseSuppressionDeepFilterNet', false);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const hasWasmSuppression =
|
|
!!values.noiseSuppressionRnnoise || !!values.noiseSuppressionDeepFilterNet;
|
|
|
|
const saveDeviceSettings = useCallback(() => {
|
|
saveDevices(values);
|
|
toast.success('Device settings saved');
|
|
}, [saveDevices, values]);
|
|
|
|
if (availableDevicesLoading || devicesLoading) {
|
|
return <LoadingCard className="h-[600px]" />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Group label="Microphone">
|
|
<Select
|
|
onValueChange={(value) => onChange('microphoneId', value)}
|
|
value={values.microphoneId}
|
|
>
|
|
<SelectTrigger className="w-[500px]">
|
|
<SelectValue placeholder="Select the input device" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{inputDevices.map((device) => (
|
|
<SelectItem
|
|
key={device?.deviceId}
|
|
value={device?.deviceId || DEFAULT_NAME}
|
|
>
|
|
{device?.label.trim() || 'Default Microphone'}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="flex gap-8">
|
|
<Group label="Echo cancellation">
|
|
<Switch
|
|
checked={!!values.echoCancellation}
|
|
onCheckedChange={handleEchoCancellationToggle}
|
|
/>
|
|
</Group>
|
|
|
|
<Group label="Noise suppression">
|
|
<Switch
|
|
checked={!!values.noiseSuppression}
|
|
onCheckedChange={handleNoiseSuppressionToggle}
|
|
/>
|
|
</Group>
|
|
|
|
{hasWasmSuppression ? (
|
|
<div className="text-xs text-muted-foreground max-w-xs">
|
|
Browser echo/noise/AGC are disabled while RNNoise or DeepFilterNet is active.
|
|
</div>
|
|
) : null}
|
|
|
|
<Group label="Enhanced noise suppression">
|
|
<Switch
|
|
checked={!!values.noiseSuppressionEnhanced}
|
|
onCheckedChange={handleEnhancedToggle}
|
|
/>
|
|
</Group>
|
|
|
|
<Group label="RNNoise (WASM)">
|
|
<Switch
|
|
checked={!!values.noiseSuppressionRnnoise}
|
|
onCheckedChange={handleRnnoiseToggle}
|
|
/>
|
|
</Group>
|
|
|
|
<Group label="DeepFilterNet (WASM)">
|
|
<Switch
|
|
checked={!!values.noiseSuppressionDeepFilterNet}
|
|
onCheckedChange={handleDeepFilterToggle}
|
|
/>
|
|
</Group>
|
|
|
|
<Group
|
|
label="Keyboard suppression"
|
|
description="Speech-safe gate to reduce key clicks while keeping voice pickup."
|
|
>
|
|
<Switch
|
|
checked={!!values.keyboardSuppression}
|
|
onCheckedChange={(checked) =>
|
|
onChange('keyboardSuppression', checked)
|
|
}
|
|
/>
|
|
</Group>
|
|
|
|
<Group
|
|
label="Voice sensitivity"
|
|
description="Higher values pick up quieter speech; lower values suppress more background."
|
|
>
|
|
<Slider
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={[values.voiceSensitivity ?? 70]}
|
|
onValueChange={([sensitivity]) =>
|
|
onChange('voiceSensitivity', sensitivity)
|
|
}
|
|
rightSlot={
|
|
<span className="text-xs text-muted-foreground w-9 text-right">
|
|
{Math.round(values.voiceSensitivity ?? 70)}
|
|
</span>
|
|
}
|
|
/>
|
|
</Group>
|
|
|
|
<Group
|
|
label="Mic Processing Status"
|
|
description="Runtime status from the active voice pipeline."
|
|
>
|
|
<div className="rounded-md border border-border px-3 py-2 text-xs leading-5">
|
|
<div>
|
|
<strong>Configured:</strong> {configuredChain}
|
|
</div>
|
|
<div>
|
|
<strong>Runtime chain:</strong> {effectiveStatus.chain}
|
|
</div>
|
|
<div>
|
|
<strong>Active:</strong> {effectiveStatus.active ? 'yes' : 'no'}
|
|
</div>
|
|
<div>
|
|
<strong>Note:</strong> {runtimeUnavailable && !storedMicStatus
|
|
? 'Open this panel while connected to voice to read live processing status.'
|
|
: effectiveStatus.note}
|
|
</div>
|
|
</div>
|
|
</Group>
|
|
|
|
<Group label="Automatic gain control">
|
|
<Switch
|
|
checked={!!values.autoGainControl}
|
|
onCheckedChange={handleAutoGainControlToggle}
|
|
/>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
|
|
<Group label="Webcam">
|
|
<Select
|
|
onValueChange={(value) => onChange('webcamId', value)}
|
|
value={values.webcamId}
|
|
>
|
|
<SelectTrigger className="w-[500px]">
|
|
<SelectValue placeholder="Select the input device" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{videoDevices.map((device) => (
|
|
<SelectItem
|
|
key={device?.deviceId}
|
|
value={device?.deviceId || DEFAULT_NAME}
|
|
>
|
|
{device?.label.trim() || 'Default Webcam'}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<ResolutionFpsControl
|
|
framerate={values.webcamFramerate}
|
|
resolution={values.webcamResolution}
|
|
onFramerateChange={(value) => onChange('webcamFramerate', value)}
|
|
onResolutionChange={(value) =>
|
|
onChange('webcamResolution', value as Resolution)
|
|
}
|
|
/>
|
|
</Group>
|
|
|
|
<Group label="Screen Sharing" description={currentVoiceChannelId ? 'Screen sharing settings take effect on your next share.' : undefined}>
|
|
<ResolutionFpsControl
|
|
framerate={values.screenFramerate}
|
|
resolution={values.screenResolution}
|
|
onFramerateChange={(value) => onChange('screenFramerate', value)}
|
|
onResolutionChange={(value) =>
|
|
onChange('screenResolution', value as Resolution)
|
|
}
|
|
/>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Group label="Audio Bitrate">
|
|
<Select
|
|
value={(values.screenAudioBitrate ?? 128).toString()}
|
|
onValueChange={(value) =>
|
|
onChange('screenAudioBitrate', +value)
|
|
}
|
|
>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder="Audio bitrate" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="64">64 kbps</SelectItem>
|
|
<SelectItem value="96">96 kbps</SelectItem>
|
|
<SelectItem value="128">128 kbps</SelectItem>
|
|
<SelectItem value="192">192 kbps</SelectItem>
|
|
<SelectItem value="256">256 kbps</SelectItem>
|
|
<SelectItem value="320">320 kbps</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
{/* macOS Audio Driver — only shown in Electron on macOS */}
|
|
<MacOSAudioDriverSection />
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<Button variant="outline" onClick={closeServerScreens}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={saveDeviceSettings}>Save Changes</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/** macOS system audio capture driver management section */
|
|
const MacOSAudioDriverSection = memo(() => {
|
|
const isMacElectron = window.pulseDesktop?.platform === 'darwin';
|
|
const [driverStatus, setDriverStatus] = useState<{
|
|
supported: boolean;
|
|
fileInstalled: boolean;
|
|
active: boolean;
|
|
} | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const refreshStatus = useCallback(async () => {
|
|
if (!window.pulseDesktop?.audioDriver) return;
|
|
try {
|
|
const status = await window.pulseDesktop.audioDriver.getStatus();
|
|
setDriverStatus(status);
|
|
} catch {
|
|
setDriverStatus(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isMacElectron) refreshStatus();
|
|
}, [isMacElectron, refreshStatus]);
|
|
|
|
if (!isMacElectron || !driverStatus?.supported) return null;
|
|
|
|
const handleInstall = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await window.pulseDesktop!.audioDriver.install();
|
|
if (result.success) {
|
|
toast.success('Audio driver installed — "Pulse Audio" device is now available');
|
|
} else if (result.error) {
|
|
toast.error(result.error);
|
|
}
|
|
} catch {
|
|
toast.error('Failed to install audio driver');
|
|
} finally {
|
|
setLoading(false);
|
|
refreshStatus();
|
|
}
|
|
};
|
|
|
|
const handleUninstall = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await window.pulseDesktop!.audioDriver.uninstall();
|
|
if (result.success) {
|
|
toast.success('Audio driver uninstalled');
|
|
} else if (result.error) {
|
|
toast.error(result.error);
|
|
}
|
|
} catch {
|
|
toast.error('Failed to uninstall audio driver');
|
|
} finally {
|
|
setLoading(false);
|
|
refreshStatus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Group label="System Audio Capture (macOS)">
|
|
<p className="text-sm text-muted-foreground">
|
|
Share system audio during screen sharing. Requires a virtual audio driver
|
|
installed to <code>/Library/Audio/Plug-Ins/HAL/</code>.
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
{driverStatus.active ? (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
<span className="text-sm text-green-500">Driver installed and active</span>
|
|
</>
|
|
) : driverStatus.fileInstalled ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 text-yellow-500 animate-spin" />
|
|
<span className="text-sm text-yellow-500">Driver installed but not active (restart coreaudiod)</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<XCircle className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">Driver not installed</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{!driverStatus.active && (
|
|
<Button
|
|
size="sm"
|
|
onClick={handleInstall}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="mr-2 h-4 w-4" />
|
|
)}
|
|
Install Driver
|
|
</Button>
|
|
)}
|
|
{(driverStatus.fileInstalled || driverStatus.active) && (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={handleUninstall}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
)}
|
|
Uninstall Driver
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
);
|
|
});
|
|
|
|
export { Devices };
|