pulse-zax/apps/client/src/components/server-screens/user-settings/devices/index.tsx

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 };