import { Trans, useLingui } from "@lingui/react/macro"; import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts"; import { GERMAN_VOICE_PRESETS, voiceIdForLocale, voicesForLocale } from "@rakazo/core"; import { Button, cn, Dialog, DialogClose, DialogContent, DialogTitle, Switch, } from "@rakazo/ui-web"; import { PlayIcon, SquareIcon, XIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { ooSound } from "../lib/oo-sound"; import { readOoSoundEnabled } from "../lib/oo-sound-pref"; import { rpc } from "../lib/rpc"; import { speaker } from "../lib/tts"; import { readUiLocale } from "../lib/ui-locale"; import { alignVoiceWithLocale, KOKORO_LOCAL_TOKEN, LOCAL_VOICE_PROVIDER, } from "../lib/voice-locale"; const LOCAL_PROVIDER_ID = LOCAL_VOICE_PROVIDER; const PREVIEW_PREFIX = "voice-preview:"; 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 [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" | "voice" | "speak" | null>(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled()); const [previewId, setPreviewId] = useState(null); const busy = pending !== null; const uiLocale = readUiLocale(); const localConnected = credentials.some((entry) => entry.provider === LOCAL_PROVIDER_ID); useEffect(() => { return () => onBusyChange?.(false); }, [onBusyChange]); useEffect(() => { const unsubscribe = speaker.subscribe((snapshot) => { const id = snapshot.messageId?.startsWith(PREVIEW_PREFIX) ? snapshot.messageId.slice(PREVIEW_PREFIX.length) : null; setPreviewId(snapshot.status === "idle" ? null : id); if (id && snapshot.error) setError(snapshot.error); }); return () => { unsubscribe(); if (speaker.state.messageId?.startsWith(PREVIEW_PREFIX)) speaker.stop(); }; }, []); function markPending(next: "connect" | "voice" | "speak" | null) { setPending(next); onBusyChange?.(next !== null); } async function refresh() { await alignVoiceWithLocale(uiLocale).catch(() => undefined); const [nextCredentials, nextStatus] = await Promise.all([ rpc.voice.credentials(), rpc.voice.status(), ]); 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) setVoiceId(voiceIdForLocale(listed, uiLocale, "") ?? ""); } else { setVoices([]); } if (activeBotId) { const bots = await rpc.bots.list(); const bot = bots.find((entry) => entry.id === activeBotId); setAutoSpeak(bot?.autoSpeak ?? false); } } 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 isGermanLocale = uiLocale.toLowerCase().startsWith("de"); const hasGermanVoices = voices.some((v) => /^d[fm]_/i.test(v.id)); const combined = isGermanLocale && !hasGermanVoices ? [...GERMAN_VOICE_PRESETS, ...voices] : voices; const listed = voicesForLocale(combined, uiLocale); if (listed.length) return listed; if (isGermanLocale) return GERMAN_VOICE_PRESETS; return voiceId ? [{ id: voiceId, label: voiceId }] : []; }, [voices, voiceId, uiLocale]); const speakingReady = Boolean( (localConnected && voiceId && status?.ready) || (autoSpeak && voiceOptions.length > 0), ); 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) { if (typeof window !== "undefined" && "speechSynthesis" in window) { return true; } 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) { if (activeBotId) { await rpc.bots.update({ botId: activeBotId, voiceId: nextVoiceId }).catch(() => undefined); } 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 { let chosenVoice = voiceId; if (next) { const connected = await ensureLocalVoice(); if (!connected) { setAutoSpeak(false); return; } const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); const isGermanLocale = uiLocale.toLowerCase().startsWith("de"); const pool = isGermanLocale && !listed.some((v) => /^d[fm]_/i.test(v.id)) ? [...GERMAN_VOICE_PRESETS, ...listed] : listed; const nextVoice = voiceIdForLocale(pool, uiLocale, voiceId); if (nextVoice) { chosenVoice = nextVoice; setVoiceId(nextVoice); await rpc.voice .setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID }) .catch(() => undefined); } } if (activeBotId) { await rpc.bots.update({ botId: activeBotId, autoSpeak: next, ...(chosenVoice ? { voiceId: chosenVoice } : {}), }); } await refresh(); if (!voiceId && chosenVoice) setVoiceId(chosenVoice); 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); } } function togglePreview(voice: VoiceInfo) { if (previewId === voice.id) { speaker.stop(); return; } setError(null); const isGermanVoice = /^d[fm]_/i.test(voice.id) || uiLocale.toLowerCase().startsWith("de"); const sampleText = isGermanVoice ? `Hallo, ich bin ${voice.label}. Lass uns direkt miteinander sprechen.` : t`Hi, this is how I'll sound when we talk.`; void speaker.speak(sampleText, { voiceId: voice.id, messageId: `${PREVIEW_PREFIX}${voice.id}`, }); } const body = ( <> {!embedded ? (
Voice
} >
) : null}
{loading ? (

Loading voice settings…

) : null} {error ?

{error}

: null} {notice ?

{notice}

: null}

{autoSpeak ? Can speak : Does not speak}

Your Oo talks with you naturally when this is on.

void setSpeakingEnabled(checked)} />
{speakingReady && voiceOptions.length > 0 ? (
{voiceOptions.map((voice) => { const active = voice.id === voiceId; const playing = previewId === voice.id; return (
{voice.description ? (

{voice.description}

) : null}
); })}
) : null}

Sound feedback

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

); if (embedded) { return (
{body}
); } return ( { if (open) return; if (busy) { details.cancel(); return; } onClose(); }} > {body} ); }