evOo-local/apps/web/src/pages/VoiceSettingsOverlay.tsx

398 lines
14 KiB
TypeScript

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<VoiceCredential[]>([]);
const [status, setStatus] = useState<VoiceStatus | null>(null);
const [voices, setVoices] = useState<VoiceInfo[]>([]);
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<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled());
const [previewId, setPreviewId] = useState<string | null>(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<boolean> {
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 ? (
<div className="flex items-start justify-between px-6 pt-6 sm:px-8 sm:pt-7">
<div>
<DialogTitle className="text-2xl font-medium text-foreground">
<Trans>Voice</Trans>
</DialogTitle>
</div>
<DialogClose
aria-label={t`Close voice settings`}
disabled={busy}
render={<Button variant="ghost" size="icon-sm" />}
>
<XIcon />
</DialogClose>
</div>
) : null}
<div className="rk-scroll min-h-0 flex-1 overflow-y-auto px-6 py-6 sm:px-8">
{loading ? (
<p className="text-sm text-muted-foreground">
<Trans>Loading voice settings…</Trans>
</p>
) : null}
{error ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
{notice ? <p className="mb-4 text-sm text-success">{notice}</p> : null}
<div className="rounded-xl border border-border px-4 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-ui-body font-medium text-foreground">
{autoSpeak ? <Trans>Can speak</Trans> : <Trans>Does not speak</Trans>}
</p>
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>Your Oo talks with you naturally when this is on.</Trans>
</p>
</div>
<Switch
checked={autoSpeak}
disabled={busy}
aria-label={t`Speaking on or off`}
onCheckedChange={(checked) => void setSpeakingEnabled(checked)}
/>
</div>
</div>
{speakingReady && voiceOptions.length > 0 ? (
<div
data-testid="voice-carousel"
className="rk-scroll -mx-6 mt-6 flex snap-x snap-mandatory scroll-px-6 gap-3 overflow-x-auto px-6 pb-2 sm:-mx-8 sm:scroll-px-8 sm:px-8"
>
{voiceOptions.map((voice) => {
const active = voice.id === voiceId;
const playing = previewId === voice.id;
return (
<div
key={voice.id}
data-testid="voice-card"
className={cn(
"relative w-40 shrink-0 snap-start rounded-xl border px-4 py-3 transition-colors",
active
? "border-primary bg-muted ring-1 ring-primary"
: "border-border bg-card hover:bg-accent",
)}
>
<div className="flex items-center justify-between gap-2">
<button
type="button"
aria-pressed={active}
disabled={busy}
onClick={() => void chooseVoice(voice.id)}
className="min-w-0 truncate text-start text-ui-body font-medium text-foreground after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
{voice.label}
</button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="relative z-10 shrink-0 rounded-full"
aria-label={playing ? t`Stop ${voice.label}` : t`Play ${voice.label}`}
onClick={() => togglePreview(voice)}
>
{playing ? <SquareIcon /> : <PlayIcon />}
</Button>
</div>
{voice.description ? (
<p className="mt-0.5 text-ui-body-sm text-muted-foreground">
{voice.description}
</p>
) : null}
</div>
);
})}
</div>
) : null}
<div className="mt-8 rounded-xl border border-border px-4 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-ui-body font-medium text-foreground">
<Trans>Sound feedback</Trans>
</p>
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>
Calm audio cues for finished work, voice phases, and handoffs. Respects reduced
motion.
</Trans>
</p>
</div>
<label className="flex shrink-0 items-center gap-2 text-ui-body-sm">
<input
type="checkbox"
checked={soundEnabled}
disabled={busy}
onChange={(event) => {
const next = event.target.checked;
setSoundEnabled(next);
ooSound.setEnabled(next);
if (next) ooSound.play("message-sent");
}}
className="size-4 rounded border-border"
/>
<Trans>On</Trans>
</label>
</div>
</div>
</div>
</>
);
if (embedded) {
return (
<div data-testid="voice-settings" className="flex min-h-0 flex-1 flex-col overflow-hidden">
{body}
</div>
);
}
return (
<Dialog
open
onOpenChange={(open, details) => {
if (open) return;
if (busy) {
details.cancel();
return;
}
onClose();
}}
>
<DialogContent
data-testid="voice-settings"
aria-describedby={undefined}
showCloseButton={false}
className="flex h-[min(680px,calc(100%-2rem))] w-[min(720px,calc(100%-2rem))] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:h-[min(680px,calc(100%-5rem))] sm:max-w-[calc(100%-5rem)]"
>
{body}
</DialogContent>
</Dialog>
);
}