import { Trans, useLingui } from "@lingui/react/macro"; import type { VoiceCatalogEntry, VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts"; import { preferVoicesForLocale } from "@rakazo/core"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle, Field, FieldLabel, Input, NativeSelect, NativeSelectOption, Switch, } from "@rakazo/ui-web"; import { ChevronDownIcon, XIcon } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { ooSound } from "../lib/oo-sound"; import { readOoSoundEnabled } from "../lib/oo-sound-pref"; import { rpc } from "../lib/rpc"; import { readUiLocale } from "../lib/ui-locale"; /** Must match packages/adapters KokoroVoiceProvider placeholder. */ const KOKORO_LOCAL_TOKEN = "local-kokoro"; const LOCAL_PROVIDER_ID = "kokoro"; export function VoiceSettingsOverlay({ onClose, embedded = false, onBusyChange, activeBotId, }: { onClose: () => void; /** Render panel body only for the shared Settings shell. */ embedded?: boolean; onBusyChange?: (busy: boolean) => void; /** Active bot whose autoSpeak toggle is controlled here. */ activeBotId?: string; }) { const { t } = useLingui(); const apiKeyId = useId(); const voiceSelectId = useId(); const [catalog, setCatalog] = useState([]); const [credentials, setCredentials] = useState([]); const [status, setStatus] = useState(null); const [voices, setVoices] = useState([]); const [voiceId, setVoiceId] = useState(""); const [autoSpeak, setAutoSpeak] = useState(false); const [loading, setLoading] = useState(true); const [pending, setPending] = useState< "connect" | "disconnect" | "voice" | "test" | "speak" | null >(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled()); const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedProvider, setAdvancedProvider] = useState(""); const [advancedApiKey, setAdvancedApiKey] = useState(""); const advancedChoice = useRef(""); const busy = pending !== null; const uiLocale = readUiLocale(); const localConnected = credentials.some((entry) => entry.provider === LOCAL_PROVIDER_ID); const advancedProviders = catalog.filter((entry) => entry.id !== LOCAL_PROVIDER_ID); const selectedAdvanced = advancedProviders.find((entry) => entry.id === advancedProvider) ?? advancedProviders[0]; const advancedCredential = credentials.find((entry) => entry.provider === advancedProvider); useEffect(() => { return () => onBusyChange?.(false); }, [onBusyChange]); function markPending(next: "connect" | "disconnect" | "voice" | "test" | "speak" | null) { setPending(next); onBusyChange?.(next !== null); } async function refresh() { const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([ rpc.voice.catalog(), rpc.voice.credentials(), rpc.voice.status(), ]); setCatalog(nextCatalog); setCredentials(nextCredentials); setStatus(nextStatus); const localCred = nextCredentials.find((entry) => entry.provider === LOCAL_PROVIDER_ID); const activeVoice = localCred?.voiceId ?? nextStatus.voiceId ?? ""; setVoiceId(activeVoice); if (localCred || nextStatus.ready) { const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); setVoices(listed); if (!activeVoice && listed[0]) setVoiceId(listed[0].id); } else { setVoices([]); } if (activeBotId) { const bots = await rpc.bots.list(); const bot = bots.find((entry) => entry.id === activeBotId); setAutoSpeak(bot?.autoSpeak ?? false); } if (!advancedChoice.current) { const connected = nextCredentials.find((entry) => entry.provider !== LOCAL_PROVIDER_ID); const preferred = nextCatalog.find((entry) => entry.id === connected?.provider) ?? nextCatalog.find((entry) => entry.id !== LOCAL_PROVIDER_ID); if (preferred) { advancedChoice.current = preferred.id; setAdvancedProvider(preferred.id); } } } useEffect(() => { void refresh() .catch((err: unknown) => setError(err instanceof Error ? err.message : t`Could not load voice settings`), ) .finally(() => setLoading(false)); }, [activeBotId]); const voiceOptions = useMemo(() => { const listed = preferVoicesForLocale(voices, uiLocale); if (listed.length) return listed; return voiceId ? [{ id: voiceId, label: voiceId }] : []; }, [voices, voiceId, uiLocale]); const speakingReady = Boolean(localConnected && voiceId && status?.ready); async function ensureLocalVoice(): Promise { if (localConnected) return true; markPending("connect"); setError(null); try { await rpc.voice.connect({ provider: LOCAL_PROVIDER_ID, apiKey: KOKORO_LOCAL_TOKEN, voiceId: voiceId || undefined, }); await refresh(); return true; } catch (err) { setError(err instanceof Error ? err.message : t`Could not enable speaking`); return false; } finally { markPending(null); } } async function chooseVoice(nextVoiceId: string) { setVoiceId(nextVoiceId); if (!localConnected) return; markPending("voice"); setError(null); try { await rpc.voice.setVoice({ voiceId: nextVoiceId, provider: LOCAL_PROVIDER_ID }); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : t`Could not save that voice`); } finally { markPending(null); } } async function setSpeakingEnabled(next: boolean) { setAutoSpeak(next); markPending("speak"); setError(null); setNotice(null); try { if (next) { const connected = await ensureLocalVoice(); if (!connected) { setAutoSpeak(false); return; } const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); const nextVoice = voiceId || listed[0]?.id; if (nextVoice) { await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID }); setVoiceId(nextVoice); } } if (activeBotId) { await rpc.bots.update({ botId: activeBotId, autoSpeak: next }); } await refresh(); setNotice(next ? t`Speaking is on.` : t`Speaking is off.`); } catch (err) { setAutoSpeak(!next); setError(err instanceof Error ? err.message : t`Could not update speaking`); } finally { markPending(null); } } async function connectAdvanced() { if (!selectedAdvanced || advancedApiKey.trim().length < 8) return; markPending("connect"); setError(null); setNotice(null); try { await rpc.voice.connect({ provider: selectedAdvanced.id, apiKey: advancedApiKey.trim(), }); setAdvancedApiKey(""); await refresh(); setNotice(t`Connected ${selectedAdvanced.name}.`); } catch (err) { setError(err instanceof Error ? err.message : t`Could not connect this voice provider`); } finally { markPending(null); } } async function disconnectAdvanced() { if (!advancedCredential) return; markPending("disconnect"); setError(null); try { await rpc.voice.disconnect({ provider: advancedCredential.provider }); setAdvancedApiKey(""); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : t`Could not disconnect this voice provider`); } finally { markPending(null); } } async function testVoice() { setError(null); setNotice(null); markPending("test"); try { const { speaker } = await import("../lib/tts.js"); await speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`); if (speaker.state.error) { setError(speaker.state.error); return; } setNotice(t`If you heard that, voice is ready.`); } catch (err) { setError(err instanceof Error ? err.message : t`Could not play a test clip`); } finally { markPending(null); } } const body = ( <> {!embedded ? (
Voice
} >
) : null}
{loading ? (

Loading voice settings…

) : null} {error ?

{error}

: null} {notice ?

{notice}

: null}

{autoSpeak ? Can speak : Does not speak}

Your Oo reads replies aloud when this is on.

void setSpeakingEnabled(checked)} />
{autoSpeak && speakingReady ? ( <> Voice void chooseVoice(event.target.value)} > {voiceOptions.map((voice) => ( {voice.label} {voice.description ? ` · ${voice.description}` : ""} ))} ) : null}

Sound feedback

Calm audio cues for finished work, voice phases, and handoffs. Respects reduced motion.

{advancedProviders.length > 0 ? (
{advancedOpen && selectedAdvanced ? (
Provider { advancedChoice.current = event.target.value; setAdvancedProvider(event.target.value); setAdvancedApiKey(""); setError(null); setNotice(null); }} > {advancedProviders.map((entry) => ( {entry.name} ))} API key setAdvancedApiKey(event.target.value)} placeholder={ advancedCredential ? t`Paste a replacement key` : t`Paste your API key` } /> {advancedCredential ? ( ) : null}
) : null}
) : null}
); if (embedded) { return (
{body}
); } return ( { if (open) return; if (busy) { details.cancel(); return; } onClose(); }} > {body} ); }