feat(stt): add server-side STT provider via OpenAI-compatible Whisper endpoint (#860)

Co-authored-by: Alexander Busse <alex@ableph.net>
This commit is contained in:
Alexander Busse
2026-04-11 22:47:33 +03:00
committed by GitHub
co-authored by Alexander Busse
parent 75a10ea66c
commit 3746d99a85
6 changed files with 836 additions and 18 deletions
@@ -16,6 +16,7 @@ import { NumberInput } from '@/components/ui/number-input';
import { RiPlayLine, RiStopLine, RiCloseLine, RiAppleLine, RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { audioStreamService } from '@/lib/voice/audioStreamService';
import { cn } from '@/lib/utils';
const LANGUAGE_OPTIONS = [
@@ -71,6 +72,19 @@ export const VoiceSettings: React.FC = () => {
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
const setOpenaiApiKey = useConfigStore((state) => state.setOpenaiApiKey);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
// STT settings
const sttProvider = useConfigStore((state) => state.sttProvider);
const setSttProvider = useConfigStore((state) => state.setSttProvider);
const sttServerUrl = useConfigStore((state) => state.sttServerUrl);
const setSttServerUrl = useConfigStore((state) => state.setSttServerUrl);
const sttModel = useConfigStore((state) => state.sttModel);
const setSttModel = useConfigStore((state) => state.setSttModel);
const sttLanguage = useConfigStore((state) => state.sttLanguage);
const setSttLanguage = useConfigStore((state) => state.setSttLanguage);
const sttSilenceThresholdDb = useConfigStore((state) => state.sttSilenceThresholdDb);
const setSttSilenceThresholdDb = useConfigStore((state) => state.setSttSilenceThresholdDb);
const sttSilenceHoldMs = useConfigStore((state) => state.sttSilenceHoldMs);
const setSttSilenceHoldMs = useConfigStore((state) => state.setSttSilenceHoldMs);
const setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons);
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled);
@@ -563,6 +577,146 @@ export const VoiceSettings: React.FC = () => {
</section>
</div>
{/* Speech Recognition */}
{voiceModeEnabled && (
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Speech Recognition
</h3>
</div>
<section className="px-2 pb-2 pt-0 space-y-0">
<div className="pb-1.5 pt-0.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground">Provider</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
<ul className="space-y-1">
<li><strong>Browser:</strong> Web Speech API (Chrome/Edge). Free, no setup.</li>
<li><strong>Server:</strong> OpenAI-compatible Whisper server. Better accuracy, any language.</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="outline"
size="xs"
onClick={() => setSttProvider('browser')}
className={cn(
'!font-normal',
sttProvider === 'browser'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
Browser
</Button>
<Button
variant="outline"
size="xs"
onClick={() => setSttProvider('server')}
className={cn(
'!font-normal',
sttProvider === 'server'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
Server
</Button>
</div>
</div>
</div>
{sttProvider === 'server' && (
<div className="py-1.5 space-y-2">
{!audioStreamService.isSupported() && (
<p className="typography-meta text-[var(--status-error)]">
MediaRecorder or AudioContext is not available in this browser. Server STT may not work.
</p>
)}
<div>
<span className={cn("typography-ui-label text-foreground", !sttServerUrl.trim() && "text-[var(--status-error)]")}>
Server URL
</span>
<span className="typography-meta ml-2 text-muted-foreground">
Base URL of the Whisper-compatible server
</span>
<div className="relative mt-1.5 max-w-xs">
<input
type="text"
value={sttServerUrl}
onChange={(e) => setSttServerUrl(e.target.value)}
placeholder="http://localhost:8001/v1"
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/>
{sttServerUrl && (
<button
type="button"
onClick={() => setSttServerUrl('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">Model</span>
<div className="relative mt-1.5 max-w-xs">
<input
type="text"
value={sttModel}
onChange={(e) => setSttModel(e.target.value)}
placeholder="deepdml/faster-whisper-large-v3-turbo-ct2"
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/>
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">Language</span>
<span className="typography-meta ml-2 text-muted-foreground">
BCP-47 code (e.g. en, fr). Leave blank for auto-detect.
</span>
<div className="relative mt-1.5 max-w-[8rem]">
<input
type="text"
value={sttLanguage}
onChange={(e) => setSttLanguage(e.target.value)}
placeholder="auto"
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/>
</div>
</div>
<div className="flex items-center gap-8 py-0.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Silence Threshold</span>
<div className="flex items-center gap-2 w-fit">
{!isMobile && <input type="range" min={-60} max={-20} step={1} value={sttSilenceThresholdDb} onChange={(e) => setSttSilenceThresholdDb(Number(e.target.value))} className={sliderClass} />}
<span className="typography-ui-label text-foreground tabular-nums min-w-[3.5rem] text-right">
{sttSilenceThresholdDb} dB
</span>
</div>
</div>
<div className="flex items-center gap-8 py-0.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Silence Hold</span>
<div className="flex items-center gap-2 w-fit">
{!isMobile && <input type="range" min={500} max={3000} step={100} value={sttSilenceHoldMs} onChange={(e) => setSttSilenceHoldMs(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={sttSilenceHoldMs} onValueChange={setSttSilenceHoldMs} min={500} max={3000} step={100} className="w-20 tabular-nums" />
<span className="typography-meta text-muted-foreground">ms</span>
</div>
</div>
</div>
)}
</section>
</div>
)}
{/* Playback & Summarization */}
<div className="mb-8">
<div className="mb-1 px-1">