398 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|