Pick the voice from a carousel in the app language, local voice only

Voice settings now list only the voices that speak the app language
(German shows German voices, English shows English ones) as a horizontal
carousel: one card per voice, a play button next to the name, tap to
make it the active voice. Changing the app language moves the saved
voice to that language. The hosted voice providers (ElevenLabs, OpenAI,
Cartesia, Fish Audio) are removed; Kokoro on the server is the only
voice. E2E runs against a stub Kokoro server so the carousel is covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57
This commit is contained in:
Claude 2026-10-05 14:44:03 +00:00
parent e55207f5d2
commit bc7353f1f2
No known key found for this signature in database
42 changed files with 623 additions and 1950 deletions

View File

@ -49,7 +49,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
- TypeScript monorepo (pnpm + Turborepo) - TypeScript monorepo (pnpm + Turborepo)
- React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile) - React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile)
- Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker - Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker
- Pi model routing, Docker sandboxes, Voice (Kokoro local / BYO keys) - Pi model routing, Docker sandboxes, Voice (Kokoro, local)
## Antigravity tools kept on this branch ## Antigravity tools kept on this branch

View File

@ -20,8 +20,8 @@ function setup() {
secret: { findMany: vi.fn(async () => []) }, secret: { findMany: vi.fn(async () => []) },
bot: { findMany: vi.fn(async () => []) }, bot: { findMany: vi.fn(async () => []) },
spaceVoicePreference: { spaceVoicePreference: {
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })), findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })),
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]), findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
}, },
spaceMemoryConfig: { spaceMemoryConfig: {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
@ -104,12 +104,12 @@ describe("consent grants", () => {
it("discloses only the selected voice provider before a voice action", async () => { it("discloses only the selected voice provider before a voice action", async () => {
const { deps, actor } = setup(); const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([ vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
{ credential: { provider: "openai" } }, { credential: { provider: "kokoro" } },
{ credential: { provider: "elevenlabs" } }, { credential: { provider: "scripted" } },
] as never); ] as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] }); const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients).toHaveLength(1); expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("OpenAI"); expect(status.recipients[0]?.name).toBe("Local voice");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled(); expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
}); });
it("discloses memory provider details to space owners", async () => { it("discloses memory provider details to space owners", async () => {
@ -155,7 +155,12 @@ describe("consent grants", () => {
]); ]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl."); expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key"); expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1); // The local voice cannot transcribe, so dictation also goes to the transcription server.
expect(
(await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients.map(
(recipient) => recipient.name,
),
).toEqual(["Local voice", "Transcription (http://127.0.0.1:8080)"]);
}); });
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => { it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup(); const { deps, actor } = setup();

View File

@ -13,18 +13,18 @@ import {
describe("toVoiceStatus", () => { describe("toVoiceStatus", () => {
it("treats a saved key without a voice as configured but not ready", () => { it("treats a saved key without a voice as configured but not ready", () => {
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({ expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({
configured: true, configured: true,
ready: false, ready: false,
transcribe: true, transcribe: false,
provider: "elevenlabs", provider: "kokoro",
voiceId: "", voiceId: "",
}); });
}); });
it("is ready once a voice is chosen", () => { it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true); expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false); expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false);
}); });
it("is off when nothing is connected", () => { it("is off when nothing is connected", () => {

View File

@ -96,7 +96,8 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
const cred = provider const cred = provider
? await findVoiceCredential(deps.prisma, actor, provider) ? await findVoiceCredential(deps.prisma, actor, provider)
: await findDefaultVoiceCredential(deps.prisma, actor); : await findDefaultVoiceCredential(deps.prisma, actor);
if (!cred) return null; // Credentials of providers that were removed from the catalog no longer speak.
if (!cred || !isVoiceProviderId(cred.provider)) return null;
const secret = await deps.prisma.secret.findFirst({ const secret = await deps.prisma.secret.findFirst({
where: { id: cred.secretId, userId: actor.userId, spaceId: null }, where: { id: cred.secretId, userId: actor.userId, spaceId: null },
}); });

View File

@ -46,6 +46,7 @@ import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
import { registerPushToken } from "../lib/push"; import { registerPushToken } from "../lib/push";
import type { AccountUiLocale } from "../lib/ui-locale"; import type { AccountUiLocale } from "../lib/ui-locale";
import { ACCOUNT_UI_LOCALES, UI_LOCALE_LABELS } from "../lib/ui-locale"; import { ACCOUNT_UI_LOCALES, UI_LOCALE_LABELS } from "../lib/ui-locale";
import { alignVoiceWithLocale } from "../lib/voice";
/** Render account settings, including the entry point for voice configuration. */ /** Render account settings, including the entry point for voice configuration. */
export default function Account() { export default function Account() {
@ -183,6 +184,7 @@ export default function Account() {
setLocaleSaving(true); setLocaleSaving(true);
setLocaleError(null); setLocaleError(null);
void setUiLocale(code) void setUiLocale(code)
.then((activated) => alignVoiceWithLocale(activated).catch(() => undefined))
.catch(() => { .catch(() => {
setLocaleError(t("Could not change language")); setLocaleError(t("Could not change language"));
}) })

View File

@ -1,76 +1,51 @@
import { voicesForLocale } from "@rakazo/core";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { NativeSymbol } from "../components/native-symbol";
import { rpc } from "../lib/api"; import { rpc } from "../lib/api";
import { mobileTokens } from "../lib/appearance"; import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n"; import { useI18n } from "../lib/i18n";
import { native, useThemedStyles } from "../lib/native"; import { native, useThemedStyles } from "../lib/native";
import { speakText } from "../lib/voice"; import {
alignVoiceWithLocale,
KOKORO_LOCAL_TOKEN,
LOCAL_VOICE_PROVIDER,
speakText,
} from "../lib/voice";
const KOKORO_LOCAL_TOKEN = "local-kokoro";
type VoiceCatalogEntry = {
id: string;
name: string;
description: string;
transcribe: boolean;
};
type VoiceCredential = { type VoiceCredential = {
id: string; id: string;
provider: string; provider: string;
voiceId: string; voiceId: string;
}; };
type VoiceStatus = {
configured: boolean;
ready: boolean;
provider: string | null;
voiceId: string;
};
type VoiceInfo = { id: string; label: string; description?: string }; type VoiceInfo = { id: string; label: string; description?: string };
export default function VoiceSettings() { export default function VoiceSettings() {
const styles = useThemedStyles(createVoiceStyles); const styles = useThemedStyles(createVoiceStyles);
const { t } = useI18n(); const { locale, t } = useI18n();
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]); const [connected, setConnected] = useState(false);
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
const [status, setStatus] = useState<VoiceStatus | null>(null);
const [voices, setVoices] = useState<VoiceInfo[]>([]); const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [provider, setProvider] = useState("");
const [apiKey, setApiKey] = useState("");
const [voiceId, setVoiceId] = useState(""); const [voiceId, setVoiceId] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | "test" | null>(null); const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | null>(null);
const [playing, setPlaying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async (nextProvider?: string) => { const load = useCallback(async () => {
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([ await alignVoiceWithLocale(locale).catch(() => undefined);
rpc<VoiceCatalogEntry[]>("voice/catalog"), const credentials = await rpc<VoiceCredential[]>("voice/credentials");
rpc<VoiceCredential[]>("voice/credentials"), const cred = credentials.find((entry) => entry.provider === LOCAL_VOICE_PROVIDER);
rpc<VoiceStatus>("voice/status"), setConnected(Boolean(cred));
]);
const selected = nextProvider || nextStatus.provider || nextCatalog[0]?.id || "";
setCatalog(nextCatalog);
setCredentials(nextCredentials);
setStatus(nextStatus);
setProvider(selected);
const cred = nextCredentials.find((entry) => entry.provider === selected);
setVoiceId(cred?.voiceId ?? ""); setVoiceId(cred?.voiceId ?? "");
if (cred) { if (cred) {
setVoices(await rpc<VoiceInfo[]>("voice/voices", { provider: selected })); const listed = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
setVoices(voicesForLocale(listed, locale));
} else { } else {
setVoices([]); setVoices([]);
} }
}, []); }, [locale]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@ -83,24 +58,12 @@ export default function VoiceSettings() {
}, [load]), }, [load]),
); );
const selected = catalog.find((entry) => entry.id === provider);
const isKokoro = selected?.id === "kokoro";
const credential = credentials.find((entry) => entry.provider === provider);
const canConnect = isKokoro || apiKey.trim().length >= 8;
async function connect() { async function connect() {
if (!selected || !canConnect) return;
setPending("connect"); setPending("connect");
setError(null); setError(null);
try { try {
await rpc("voice/connect", { await rpc("voice/connect", { provider: LOCAL_VOICE_PROVIDER, apiKey: KOKORO_LOCAL_TOKEN });
provider: selected.id, await load();
apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(),
voiceId: voiceId || undefined,
});
setApiKey("");
await load(selected.id);
setNotice(t("Connected {name}.", { name: selected.name }));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("Could not connect")); setError(err instanceof Error ? err.message : t("Could not connect"));
} finally { } finally {
@ -109,14 +72,11 @@ export default function VoiceSettings() {
} }
async function disconnect() { async function disconnect() {
if (!credential) return;
setPending("disconnect"); setPending("disconnect");
setError(null); setError(null);
setNotice(null);
try { try {
await rpc("voice/disconnect", { provider: credential.provider }); await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER });
setApiKey(""); await load();
await load(credential.provider);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("Could not disconnect")); setError(err instanceof Error ? err.message : t("Could not disconnect"));
} finally { } finally {
@ -127,173 +87,145 @@ export default function VoiceSettings() {
async function chooseVoice(nextVoiceId: string) { async function chooseVoice(nextVoiceId: string) {
setVoiceId(nextVoiceId); setVoiceId(nextVoiceId);
setPending("voice"); setPending("voice");
setError(null);
try { try {
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id }); await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: LOCAL_VOICE_PROVIDER });
await load(selected?.id);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("Could not save that voice")); setError(err instanceof Error ? err.message : t("Could not save that voice"));
await load().catch(() => undefined);
} finally { } finally {
setPending(null); setPending(null);
} }
} }
async function testVoice() { async function playVoice(voice: VoiceInfo) {
setPending("test"); setPlaying(voice.id);
setError(null); setError(null);
try { try {
const ready = await speakText(t("Hi, this is how I'll sound when I read replies out loud.")); await speakText(t("Hi, this is how I'll sound when I read replies out loud."), {
if (!ready) { voiceId: voice.id,
throw new Error(t("Connect a voice provider first.")); });
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("Could not play a sample")); setError(err instanceof Error ? err.message : t("Could not play a sample"));
} finally { } finally {
setPending(null); setPlaying((current) => (current === voice.id ? null : current));
} }
} }
const busy = pending !== null;
return ( return (
<SafeAreaView edges={["bottom"]} style={styles.screen}> <SafeAreaView edges={["bottom"]} style={styles.screen}>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
{loading ? <ActivityIndicator color={native.secondaryLabel} /> : null} {loading ? <ActivityIndicator color={native.secondaryLabel} /> : null}
{error ? <Text style={styles.error}>{error}</Text> : null} {error ? <Text style={styles.error}>{error}</Text> : null}
{notice ? <Text style={styles.notice}>{notice}</Text> : null} {!loading && !connected ? (
{catalog.map((entry) => { <Pressable
const connected = credentials.some((cred) => cred.provider === entry.id); disabled={busy}
return ( onPress={() => void connect()}
<Pressable style={[styles.button, busy && styles.disabled]}
key={entry.id} >
disabled={pending !== null} <Text style={styles.buttonLabel}>{t("Connect")}</Text>
onPress={() => { </Pressable>
setProvider(entry.id); ) : null}
setPending("voice"); {voices.length ? (
void load(entry.id) <ScrollView
.catch((err: unknown) => horizontal
setError( accessibilityRole="radiogroup"
err instanceof Error ? err.message : t("Could not load voice settings"), showsHorizontalScrollIndicator={false}
), snapToInterval={CARD_WIDTH + CARD_GAP}
) decelerationRate="fast"
.finally(() => setPending(null)); style={styles.carousel}
}} contentContainerStyle={styles.carouselContent}
style={[ >
styles.card, {voices.map((voice) => {
provider === entry.id && styles.cardActive, const active = voiceId === voice.id;
pending !== null && styles.disabled, return (
]} <Pressable
> key={voice.id}
<Text style={styles.cardTitle}>{entry.name}</Text> accessibilityRole="radio"
<Text style={styles.cardMeta}> accessibilityState={{ checked: active, disabled: busy }}
{connected disabled={busy}
? t("Connected") onPress={() => void chooseVoice(voice.id)}
: entry.transcribe style={[styles.card, active && styles.cardActive]}
? t("Speak + transcribe") >
: t("Speak only")} <View style={styles.cardHeader}>
</Text> <Text numberOfLines={1} style={styles.cardTitle}>
</Pressable> {voice.label}
); </Text>
})} <Pressable
{selected ? ( accessibilityRole="button"
<> accessibilityLabel={t("Play {name}", { name: voice.label })}
{isKokoro ? ( disabled={playing !== null}
<Text style={styles.help}> hitSlop={8}
{t("Local voice runs on your server. Tap Connect when it is ready.")} onPress={() => void playVoice(voice)}
</Text> style={styles.play}
) : ( >
<TextInput {playing === voice.id ? (
accessibilityLabel={t("API key")} <ActivityIndicator size="small" color={native.secondaryLabel} />
autoCapitalize="none" ) : (
autoComplete="off" <NativeSymbol ios="play.fill" android="play" size={14} />
autoCorrect={false} )}
importantForAutofill="no" </Pressable>
value={apiKey} </View>
onChangeText={setApiKey} {voice.description ? (
placeholder={credential ? t("Paste a replacement key") : t("Paste your API key")} <Text style={styles.cardMeta}>{voice.description}</Text>
placeholderTextColor={native.tertiaryLabel} ) : null}
secureTextEntry </Pressable>
style={styles.input} );
textContentType="none" })}
/> </ScrollView>
)} ) : null}
<Pressable {connected ? (
disabled={pending !== null || !canConnect} <Pressable
onPress={() => void connect()} disabled={busy}
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]} onPress={() => void disconnect()}
> style={[styles.secondary, busy && styles.disabled]}
<Text style={styles.buttonLabel}> >
{credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")} <Text style={styles.secondaryLabel}>
</Text> {pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
</Pressable> </Text>
{credential ? ( </Pressable>
<Pressable
disabled={pending !== null}
onPress={() => void disconnect()}
style={[styles.secondary, pending !== null && styles.disabled]}
>
<Text style={styles.secondaryLabel}>
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
</Text>
</Pressable>
) : null}
{voices.length ? (
<View style={styles.voices}>
{voices.map((voice) => (
<Pressable
key={voice.id}
disabled={pending !== null}
onPress={() => void chooseVoice(voice.id)}
style={[styles.voiceRow, pending !== null && styles.disabled]}
>
<Text style={styles.voiceLabel}>{voice.label}</Text>
{voiceId === voice.id ? <Text style={styles.check}>✓</Text> : null}
</Pressable>
))}
</View>
) : null}
{status?.ready ? (
<Pressable
disabled={pending !== null}
onPress={() => void testVoice()}
style={styles.secondary}
>
<Text style={styles.secondaryLabel}>{t("Hear a sample")}</Text>
</Pressable>
) : null}
</>
) : null} ) : null}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
const CARD_WIDTH = 160;
const CARD_GAP = 12;
function createVoiceStyles() { function createVoiceStyles() {
const tokens = mobileTokens(); const tokens = mobileTokens();
return StyleSheet.create({ return StyleSheet.create({
screen: { flex: 1, backgroundColor: native.page }, screen: { flex: 1, backgroundColor: native.page },
content: { padding: 20, gap: 10 }, content: { paddingVertical: 20, gap: 10 },
error: { color: tokens.destructive, marginBottom: 8 }, error: { color: tokens.destructive, marginBottom: 8, marginHorizontal: 20 },
notice: { color: tokens.success, marginBottom: 8 }, carousel: { flexGrow: 0 },
carouselContent: { paddingHorizontal: 20, gap: CARD_GAP },
card: { card: {
width: CARD_WIDTH,
borderRadius: 14, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
borderColor: tokens.border, borderColor: tokens.border,
padding: 14,
backgroundColor: tokens.card,
},
cardActive: { borderColor: tokens.ring, backgroundColor: tokens.muted },
cardTitle: { color: native.label, fontSize: 16 },
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 12 },
help: { marginTop: 8, color: native.secondaryLabel, fontSize: 14, lineHeight: 20 },
input: {
marginTop: 8,
borderRadius: 12,
borderWidth: 1,
borderColor: tokens.border,
color: native.label,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
backgroundColor: tokens.card,
},
cardActive: { borderColor: tokens.primary, borderWidth: 2, backgroundColor: tokens.muted },
cardHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
cardTitle: { flexShrink: 1, color: native.label, fontSize: 16, fontWeight: "600" },
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 13 },
play: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: "center",
justifyContent: "center",
backgroundColor: tokens.muted,
}, },
button: { button: {
marginTop: 8, marginHorizontal: 20,
backgroundColor: tokens.primary, backgroundColor: tokens.primary,
borderRadius: 12, borderRadius: 12,
paddingVertical: 12, paddingVertical: 12,
@ -301,17 +233,6 @@ function createVoiceStyles() {
}, },
disabled: { opacity: 0.4 }, disabled: { opacity: 0.4 },
buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" }, buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" },
voices: { marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: tokens.border },
voiceRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: tokens.border,
},
voiceLabel: { color: native.label },
check: { color: tokens.success },
secondary: { marginTop: 16, alignItems: "center" }, secondary: { marginTop: 16, alignItems: "center" },
secondaryLabel: { color: native.secondaryLabel, fontSize: 15 }, secondaryLabel: { color: native.secondaryLabel, fontSize: 15 },
}); });

View File

@ -667,6 +667,7 @@ export const RU_MESSAGES: Record<string, string> = {
Peach: "Персик", Peach: "Персик",
Penguin: "Пингвин", Penguin: "Пингвин",
Pink: "Розовый", Pink: "Розовый",
"Play {name}": "Воспроизвести {name}",
Playful: "Игривость", Playful: "Игривость",
"Reading glasses": "Очки для чтения", "Reading glasses": "Очки для чтения",
Robot: "Робот", Robot: "Робот",

View File

@ -651,6 +651,7 @@ export const ZH_MESSAGES: Record<string, string> = {
Peach: "桃色", Peach: "桃色",
Penguin: "企鹅", Penguin: "企鹅",
Pink: "粉色", Pink: "粉色",
"Play {name}": "播放 {name}",
Playful: "俏皮", Playful: "俏皮",
"Reading glasses": "老花镜", "Reading glasses": "老花镜",
Robot: "机器人", Robot: "机器人",

View File

@ -1,4 +1,5 @@
import { ensureAiDataConsent, readBoundedResponseBytes } from "@rakazo/core"; import type { VoiceInfo } from "@rakazo/contracts";
import { ensureAiDataConsent, readBoundedResponseBytes, voiceIdForLocale } from "@rakazo/core";
import { File, Paths } from "expo-file-system"; import { File, Paths } from "expo-file-system";
import { promptAiConsent } from "./ai-consent"; import { promptAiConsent } from "./ai-consent";
import type { ApiRequestContext } from "./api"; import type { ApiRequestContext } from "./api";
@ -10,6 +11,21 @@ export const VOICE_RESPONSE_TIMEOUT_MS = 70_000;
export const MAX_VOICE_AUDIO_BYTES = 16 * 1024 * 1024; export const MAX_VOICE_AUDIO_BYTES = 16 * 1024 * 1024;
const MAX_VOICE_ERROR_BYTES = 64 * 1024; const MAX_VOICE_ERROR_BYTES = 64 * 1024;
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
export const LOCAL_VOICE_PROVIDER = "kokoro";
/** Keep the saved voice in the UI language, so German speaks German and English speaks English. */
export async function alignVoiceWithLocale(locale: string): Promise<void> {
const status = await rpc<{ provider: string | null; voiceId: string }>("voice/status");
if (status.provider !== LOCAL_VOICE_PROVIDER || !status.voiceId) return;
const voices = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
const next = voiceIdForLocale(voices, locale, status.voiceId);
if (next && next !== status.voiceId) {
await rpc("voice/setVoice", { voiceId: next, provider: LOCAL_VOICE_PROVIDER });
}
}
export async function speakText(text: string, opts: SpeechOptions = {}): Promise<boolean> { export async function speakText(text: string, opts: SpeechOptions = {}): Promise<boolean> {
const requestContext = await captureApiRequestContext(); const requestContext = await captureApiRequestContext();
const prepared = await rpc<{ ready: boolean; utterances: string[] }>( const prepared = await rpc<{ ready: boolean; utterances: string[] }>(

View File

@ -1,10 +1,22 @@
import { expect, test } from "@playwright/test"; import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
test("voice settings connect a key, speak a reply, and open a call", async ({ page }, testInfo) => { async function turnOnSpeaking(page: Page) {
const settings = await openUserSettings(page, "voice");
const voice = settings.getByTestId("voice-settings");
await expect(voice).toBeVisible();
await expect(voice.getByRole("button", { name: "Other voice providers" })).toHaveCount(0);
await expect(voice.getByLabel("API key", { exact: true })).toHaveCount(0);
await voice.getByRole("switch", { name: "Speaking on or off" }).click();
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
return voice;
}
test("voice settings pick a voice from the carousel, speak a reply, and open a call", async ({
page,
}, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
const userName = `Voice ${stamp}`; await signup(page, `voice-${stamp}@rakazo.test`, "password12", `Voice ${stamp}`);
await signup(page, `voice-${stamp}@rakazo.test`, "password12", userName);
await completeOnboarding(page); await completeOnboarding(page);
await expect(page.getByRole("button", { name: "Call", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Call", exact: true })).toHaveCount(0);
@ -12,45 +24,54 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }), page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toHaveCount(1); ).toHaveCount(1);
await captureScreenshot(page, testInfo, "voice-composer"); await captureScreenshot(page, testInfo, "voice-composer");
await page
.getByTestId("composer-bar")
.getByRole("button", { name: "Voice", exact: true })
.click();
await expect(page.getByTestId("voice-settings")).toBeVisible();
await page.getByRole("button", { name: "Other voice providers" }).click();
await expect(page.getByLabel("API key", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Dictate", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "voice-settings");
await page.getByRole("button", { name: "Close voice settings" }).click();
await expect(page.getByTestId("voice-settings")).toHaveCount(0);
const preparedOff = await rpc<{ ready: boolean }>(page, "voice/prepare", { const preparedOff = await rpc<{ ready: boolean }>(page, "voice/prepare", {
text: "Hello there.", text: "Hello there.",
}); });
expect(preparedOff.ready).toBe(false); expect(preparedOff.ready).toBe(false);
await openUserSettings(page, "voice"); const voice = await turnOnSpeaking(page);
await expect(page.getByTestId("voice-settings")).toBeVisible(); const cards = voice.getByTestId("voice-card");
await page.getByRole("button", { name: "Other voice providers" }).click(); // English UI lists only the English voices of the local server.
await page.getByLabel("Provider", { exact: true }).selectOption("scripted"); await expect(cards.filter({ hasText: "Heart" })).toHaveCount(1);
const apiKeyInput = page.getByPlaceholder(/Paste your API key/); await expect(cards.filter({ hasText: "George" })).toHaveCount(1);
await expect(apiKeyInput).toHaveAttribute("autocomplete", "new-password"); await expect(cards.filter({ hasText: "Eva" })).toHaveCount(0);
await apiKeyInput.fill("fake-scripted-voice-key"); await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
await page.getByRole("button", { name: "Connect", exact: true }).click(); "aria-pressed",
await expect(page.getByText("Connected Scripted.")).toBeVisible(); "true",
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
await expect(page.getByRole("button", { name: "Disconnect", exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "voice-settings-connected");
const credentials = await rpc<Array<{ hasKey: boolean; provider: string }>>(
page,
"voice/credentials",
{},
); );
expect(credentials).toEqual([expect.objectContaining({ hasKey: true, provider: "scripted" })]);
expect(JSON.stringify(credentials)).not.toContain("fake-scripted-voice-key");
await voice.getByRole("button", { name: "Bella", exact: true }).click();
await expect(voice.getByRole("button", { name: "Bella", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"false",
);
await expect
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
.toBe("af_bella");
const previewSpoken = page.waitForResponse(
(response) => response.url().includes("/api/voice/speak") && response.ok(),
);
await voice.getByRole("button", { name: "Play Sky", exact: true }).click();
await previewSpoken;
// A preview only plays the voice; the chosen one stays.
await expect(voice.getByRole("button", { name: "Bella", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await captureScreenshot(page, testInfo, `voice-settings-carousel-${colorScheme}`);
}
await page.emulateMedia({ colorScheme: "light" });
await page.getByRole("button", { name: "Close voice settings" }).click(); await page.getByRole("button", { name: "Close voice settings" }).click();
await expect(page.getByTestId("user-settings")).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/); const composer = page.getByPlaceholder(/Message/);
await composer.fill("say hello"); await composer.fill("say hello");
@ -61,44 +82,65 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
// A reply can render more than one text bubble; speak the latest one. // A reply can render more than one text bubble; speak the latest one.
const speakReply = page.getByRole("button", { name: "Speak this reply" }).last(); const speakReply = page.getByRole("button", { name: "Speak this reply" }).last();
await expect(speakReply).toBeVisible({ await expect(speakReply).toBeVisible({ timeout: 30_000 });
timeout: 30_000,
});
const replySpoken = page.waitForResponse( const replySpoken = page.waitForResponse(
(response) => response.url().includes("/api/voice/speak") && response.ok(), (response) => response.url().includes("/api/voice/speak") && response.ok(),
); );
await speakReply.click(); await speakReply.click();
await replySpoken; await replySpoken;
await openUserSettings(page, "voice");
await page.getByRole("button", { name: "Other voice providers" }).click();
await page.getByLabel("Provider", { exact: true }).selectOption("scripted");
await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
await expect(page.getByRole("button", { name: "Disconnect", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Close voice settings" }).click();
await page await page
.getByTestId("composer-bar") .getByTestId("composer-bar")
.getByRole("button", { name: "Voice", exact: true }) .getByRole("button", { name: "Voice", exact: true })
.click(); .click();
await expect(page.getByTestId("call-view")).toBeVisible(); await expect(page.getByTestId("call-view")).toBeVisible();
await expect(page.getByTestId("voice-panel")).toBeVisible(); await expect(page.getByTestId("voice-panel")).toBeVisible();
await expect(page.getByRole("button", { name: "Interrupt" })).toBeVisible();
await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible(); await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible();
await captureScreenshot(page, testInfo, "voice-stage"); await captureScreenshot(page, testInfo, "voice-stage");
await page.getByRole("button", { name: "Hang up" }).click(); await page.getByRole("button", { name: "Hang up" }).click();
await expect(page.getByTestId("call-view")).toHaveCount(0); await expect(page.getByTestId("call-view")).toHaveCount(0);
});
await openUserSettings(page, "voice");
const settings = page.getByTestId("voice-settings"); test("German lists only German voices and switches the saved voice to one", async ({
await settings.getByRole("button", { name: "Other voice providers" }).click(); page,
await settings.getByPlaceholder(/Paste a replacement key/).fill("leftover-voice-key"); }, testInfo) => {
await settings.getByRole("button", { name: "Disconnect", exact: true }).click(); const stamp = Date.now();
await expect(settings.getByRole("button", { name: "Disconnect", exact: true })).toHaveCount(0); await signup(page, `voice-de-${stamp}@rakazo.test`, "password12", `Stimme ${stamp}`);
await expect(settings.getByRole("button", { name: "Connect", exact: true })).toBeVisible(); await completeOnboarding(page);
await expect(settings.getByLabel("API key", { exact: true })).toHaveValue("");
await expect(rpc<Array<{ provider: string }>>(page, "voice/credentials", {})).resolves.toEqual( let voice = await turnOnSpeaking(page);
[], await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
); "aria-pressed",
"true",
);
await page.getByRole("button", { name: "Close voice settings" }).click();
// Switching the app language moves the saved voice to that language too.
const settings = await openUserSettings(page);
await settings.getByTestId("ui-locale-select").click();
await settings.getByRole("option", { name: "Deutsch", exact: true }).click();
await expect(settings.getByRole("heading", { name: "Sprache", exact: true })).toBeVisible();
await expect
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
.toBe("df_eva");
await settings.getByTestId("settings-nav-voice").click();
voice = settings.getByTestId("voice-settings");
const cards = voice.getByTestId("voice-card");
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Bernd" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(0);
await expect(voice.getByRole("button", { name: "Eva", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(voice.getByRole("button", { name: "Bernd anhören", exact: true })).toBeVisible();
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await captureScreenshot(page, testInfo, `voice-settings-carousel-de-${colorScheme}`);
}
await page.emulateMedia({ colorScheme: "light" });
await page.setViewportSize({ width: 390, height: 844 });
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
await captureScreenshot(page, testInfo, "voice-settings-carousel-de-phone");
}); });

View File

@ -1,6 +1,6 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import type { VoiceInfo } from "@rakazo/contracts"; import type { VoiceInfo } from "@rakazo/contracts";
import { defaultVoiceForLocale, preferVoicesForLocale } from "@rakazo/core"; import { defaultVoiceForLocale, voicesForLocale } from "@rakazo/core";
import { Button } from "@rakazo/ui-web"; import { Button } from "@rakazo/ui-web";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { rpc } from "../../lib/rpc"; import { rpc } from "../../lib/rpc";
@ -37,7 +37,7 @@ export function OnboardingVoiceStep({
const listed = await rpc.voice.voices({ provider: "kokoro" }); const listed = await rpc.voice.voices({ provider: "kokoro" });
if (cancelled) return; if (cancelled) return;
const locale = readUiLocale(); const locale = readUiLocale();
const ordered = preferVoicesForLocale(listed, locale).slice(0, 6); const ordered = voicesForLocale(listed, locale).slice(0, 6);
setVoiceOptions(ordered); setVoiceOptions(ordered);
const defaultVoice = defaultVoiceForLocale(listed, locale); const defaultVoice = defaultVoiceForLocale(listed, locale);
if (defaultVoice) onSelectedVoiceIdChange(defaultVoice.id); if (defaultVoice) onSelectedVoiceIdChange(defaultVoice.id);

View File

@ -166,7 +166,7 @@ export class Speaker {
{ signal, context: { spaceId } }, { signal, context: { spaceId } },
); );
if (!body.ready) { if (!body.ready) {
throw new Error("Add a voice provider key and pick a voice in Voice settings."); throw new Error("Turn on speaking in Voice settings.");
} }
return body.utterances ?? []; return body.utterances ?? [];
} }

View File

@ -0,0 +1,17 @@
import { voiceIdForLocale } from "@rakazo/core";
import { rpc } from "./rpc";
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
export const LOCAL_VOICE_PROVIDER = "kokoro";
/** Keep the saved voice in the UI language, so German speaks German and English speaks English. */
export async function alignVoiceWithLocale(locale: string): Promise<void> {
const status = await rpc.voice.status();
if (status.provider !== LOCAL_VOICE_PROVIDER || !status.voiceId) return;
const voices = await rpc.voice.voices({ provider: LOCAL_VOICE_PROVIDER });
const next = voiceIdForLocale(voices, locale, status.voiceId);
if (next && next !== status.voiceId) {
await rpc.voice.setVoice({ voiceId: next, provider: LOCAL_VOICE_PROVIDER });
}
}

View File

@ -635,7 +635,6 @@ msgstr "Die API ist wieder da, aber das Update wurde nicht abgeschlossen. Prüfe
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API-Schlüssel" msgstr "API-Schlüssel"
@ -1346,7 +1345,6 @@ msgstr "Passwort bestätigen"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "Verbinden" msgstr "Verbinden"
@ -1399,11 +1397,6 @@ msgstr "Verbunden"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "Verbunden · {0}" msgstr "Verbunden · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "{0} verbunden."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "Verbunden. Die Tools sind ab deiner nächsten Nachricht verfügbar."
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "Verbindung wird hergestellt…" msgstr "Verbindung wird hergestellt…"
@ -1535,10 +1527,6 @@ msgstr "Verbindung mit {0} fehlgeschlagen"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "Anbieter konnte nicht verbunden werden" msgstr "Anbieter konnte nicht verbunden werden"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "Stimmanbieter konnte nicht verbunden werden"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "Verbindung zum Computerbildschirm fehlgeschlagen" msgstr "Verbindung zum Computerbildschirm fehlgeschlagen"
@ -1604,10 +1592,6 @@ msgstr "Erinnerungsanbieter konnte nicht getrennt werden"
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "OAuth-Verbindung konnte nicht getrennt werden" msgstr "OAuth-Verbindung konnte nicht getrennt werden"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "Stimmanbieter konnte nicht getrennt werden"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "Konnte nicht ausgeblendet werden" msgstr "Konnte nicht ausgeblendet werden"
@ -1679,10 +1663,6 @@ msgstr "In diesem Ordner konnte nicht gesucht werden."
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "Lokale Einstellungen konnten nicht geöffnet werden. Starte den lokalen Server, erstelle sein Besitzerkonto und versuche es dann erneut." msgstr "Lokale Einstellungen konnten nicht geöffnet werden. Starte den lokalen Server, erstelle sein Besitzerkonto und versuche es dann erneut."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "Testclip konnte nicht abgespielt werden"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr "Direktes MCP"
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "Trennen" msgstr "Trennen"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "Verbindung wird getrennt…" msgstr "Verbindung wird getrennt…"
@ -2703,10 +2681,6 @@ msgstr "Kopfhörer"
msgid "Health" msgid "Health"
msgstr "Gesundheit" msgstr "Gesundheit"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "Beispiel anhören"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "Herz" msgstr "Herz"
@ -2785,10 +2759,6 @@ msgstr "Ich höre zu — was hat Priorität?"
msgid "Identity" msgid "Identity"
msgstr "Identität" msgstr "Identität"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "Wenn du das gehört hast, ist die Stimme einsatzbereit."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "Ignorieren" msgstr "Ignorieren"
@ -3792,10 +3762,6 @@ msgstr "Organisations-API-Schlüssel"
msgid "Other model…" msgid "Other model…"
msgstr "Anderes Modell…" msgstr "Anderes Modell…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr "Andere Stimmanbieter"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "Seite" msgstr "Seite"
@ -3832,18 +3798,10 @@ msgstr "Passwort aktualisiert"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Die Passwörter stimmen nicht überein" msgstr "Die Passwörter stimmen nicht überein"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "Ersatzschlüssel einfügen"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "Füge die OpenAI-kompatible Adresse deines Servers ein. Rakazo ergänzt /v1 bei Bedarf." msgstr "Füge die OpenAI-kompatible Adresse deines Servers ein. Rakazo ergänzt /v1 bei Bedarf."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "Füge deinen API-Schlüssel ein"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "Anheften"
msgid "Pink" msgid "Pink"
msgstr "Rosa" msgstr "Rosa"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr "{0} anhören"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "Verspielt" msgstr "Verspielt"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "Wiedergabe…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "Zeig mir das Problem." msgstr "Zeig mir das Problem."
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "Projekte" msgstr "Projekte"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "Anbieter" msgstr "Anbieter"
@ -4232,10 +4190,6 @@ msgstr "Erneut öffnen"
msgid "Replace API key" msgid "Replace API key"
msgstr "API-Schlüssel ersetzen" msgstr "API-Schlüssel ersetzen"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "Schlüssel ersetzen"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "Antworten" msgstr "Antworten"
@ -4871,6 +4825,11 @@ msgstr "Schritte"
msgid "Stop" msgid "Stop"
msgstr "Stoppen" msgstr "Stoppen"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr "{0} stoppen"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "Vorlesen stoppen" msgstr "Vorlesen stoppen"

View File

@ -635,7 +635,6 @@ msgstr "API is back, but the update did not finish. Check the host logs."
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API key" msgstr "API key"
@ -1346,7 +1345,6 @@ msgstr "Confirm password"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "Connect" msgstr "Connect"
@ -1399,11 +1397,6 @@ msgstr "Connected"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "Connected · {0}" msgstr "Connected · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "Connected {0}."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "Connected. Its tools are available from your next message."
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "Connecting…" msgstr "Connecting…"
@ -1535,10 +1527,6 @@ msgstr "Could not connect {0}"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "Could not connect this provider" msgstr "Could not connect this provider"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "Could not connect this voice provider"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "Could not connect to the computer screen" msgstr "Could not connect to the computer screen"
@ -1604,10 +1592,6 @@ msgstr "Could not disconnect memory provider"
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "Could not disconnect OAuth" msgstr "Could not disconnect OAuth"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "Could not disconnect this voice provider"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "Could not dismiss" msgstr "Could not dismiss"
@ -1679,10 +1663,6 @@ msgstr "Could not look in this folder."
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Could not open local settings. Start the local server and create its owner account, then try again."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "Could not play a test clip"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr "Direct MCP"
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "Disconnect" msgstr "Disconnect"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "Disconnecting…" msgstr "Disconnecting…"
@ -2703,10 +2681,6 @@ msgstr "Headphones"
msgid "Health" msgid "Health"
msgstr "Health" msgstr "Health"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "Hear a sample"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "Heart" msgstr "Heart"
@ -2785,10 +2759,6 @@ msgstr "I'm listening — what's the priority?"
msgid "Identity" msgid "Identity"
msgstr "Identity" msgstr "Identity"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "If you heard that, voice is ready."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "Ignore" msgstr "Ignore"
@ -3792,10 +3762,6 @@ msgstr "Organization API key"
msgid "Other model…" msgid "Other model…"
msgstr "Other model…" msgstr "Other model…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr "Other voice providers"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "Page" msgstr "Page"
@ -3832,18 +3798,10 @@ msgstr "Password updated"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Passwords do not match" msgstr "Passwords do not match"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "Paste a replacement key"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgstr "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "Paste your API key"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "Pin"
msgid "Pink" msgid "Pink"
msgstr "Pink" msgstr "Pink"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr "Play {0}"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "Playful" msgstr "Playful"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "Playing…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "Point me at the problem." msgstr "Point me at the problem."
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "Projects" msgstr "Projects"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "Provider" msgstr "Provider"
@ -4232,10 +4190,6 @@ msgstr "Reopen"
msgid "Replace API key" msgid "Replace API key"
msgstr "Replace API key" msgstr "Replace API key"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "Replace key"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
@ -4871,6 +4825,11 @@ msgstr "Steps"
msgid "Stop" msgid "Stop"
msgstr "Stop" msgstr "Stop"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr "Stop {0}"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "Stop speaking" msgstr "Stop speaking"

View File

@ -635,7 +635,6 @@ msgstr "La API volvió, pero la actualización no terminó. Revisa los logs del
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "Clave de API" msgstr "Clave de API"
@ -1346,7 +1345,6 @@ msgstr "Confirmar contraseña"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "Conectar" msgstr "Conectar"
@ -1399,11 +1397,6 @@ msgstr "Conectado"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "Conectado · {0}" msgstr "Conectado · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "Se conectó {0}."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "Conectado: sus herramientas estarán disponibles desde tu próximo mensa
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "Conectando…" msgstr "Conectando…"
@ -1535,10 +1527,6 @@ msgstr "No se pudo conectar {0}"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "No se pudo conectar este proveedor" msgstr "No se pudo conectar este proveedor"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "No se pudo conectar este proveedor de voz"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "No se pudo conectar a la pantalla de la computadora" msgstr "No se pudo conectar a la pantalla de la computadora"
@ -1604,10 +1592,6 @@ msgstr "No se pudo desconectar el proveedor de memoria"
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "No se pudo desconectar OAuth" msgstr "No se pudo desconectar OAuth"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "No se pudo desconectar este proveedor de voz"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "" msgstr ""
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "No se pudo reproducir un clip de prueba"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "Desconectar" msgstr "Desconectar"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "Desconectando…" msgstr "Desconectando…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "Escuchar una muestra"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "Si escuchaste eso, la voz está lista."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
@ -3792,10 +3762,6 @@ msgstr "Clave de API de organización"
msgid "Other model…" msgid "Other model…"
msgstr "Otro modelo…" msgstr "Otro modelo…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "Contraseña actualizada"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Las contraseñas no coinciden" msgstr "Las contraseñas no coinciden"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "Pega una clave de reemplazo"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "Pega la dirección compatible con OpenAI de tu servidor. Rakazo agrega /v1 si es necesario." msgstr "Pega la dirección compatible con OpenAI de tu servidor. Rakazo agrega /v1 si es necesario."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "Pega tu clave de API"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "Fijar"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "Reproduciendo…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "Proveedor" msgstr "Proveedor"
@ -4232,10 +4190,6 @@ msgstr "Reabrir"
msgid "Replace API key" msgid "Replace API key"
msgstr "Reemplazar clave de API" msgstr "Reemplazar clave de API"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "Reemplazar clave"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "Responder" msgstr "Responder"
@ -4871,6 +4825,11 @@ msgstr "Pasos"
msgid "Stop" msgid "Stop"
msgstr "Detener" msgstr "Detener"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "Dejar de hablar" msgstr "Dejar de hablar"

View File

@ -635,7 +635,6 @@ msgstr ""
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API कुंजी" msgstr "API कुंजी"
@ -1346,7 +1345,6 @@ msgstr "पासवर्ड की पुष्टि करें"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "कनेक्ट करें" msgstr "कनेक्ट करें"
@ -1399,11 +1397,6 @@ msgstr "कनेक्टेड"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "कनेक्टेड · {0}" msgstr "कनेक्टेड · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "{0} कनेक्ट किया गया।"
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "कनेक्टेड। इसके टूल आपके अग
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "कनेक्ट हो रहा है…" msgstr "कनेक्ट हो रहा है…"
@ -1535,10 +1527,6 @@ msgstr "{0} कनेक्ट नहीं हो सका"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "यह प्रोवाइडर कनेक्ट नहीं हो सका" msgstr "यह प्रोवाइडर कनेक्ट नहीं हो सका"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "यह आवाज़ प्रोवाइडर कनेक्ट नहीं हो सका"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "" msgstr ""
@ -1604,10 +1592,6 @@ msgstr "मेमोरी प्रोवाइडर डिस्कनेक
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "OAuth डिस्कनेक्ट नहीं हो सका" msgstr "OAuth डिस्कनेक्ट नहीं हो सका"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "यह आवाज़ प्रोवाइडर डिस्कनेक्ट नहीं हो सका"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "" msgstr ""
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "टेस्ट क्लिप नहीं चलाई जा सकी"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "डिस्कनेक्ट करें" msgstr "डिस्कनेक्ट करें"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "डिस्कनेक्ट हो रहा है…" msgstr "डिस्कनेक्ट हो रहा है…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "एक नमूना सुनें"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "अगर आपने वह सुना, तो आवाज़ तैयार है।"
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
@ -3792,10 +3762,6 @@ msgstr "संगठन API कुंजी"
msgid "Other model…" msgid "Other model…"
msgstr "अन्य मॉडल…" msgstr "अन्य मॉडल…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "पासवर्ड अपडेट हो गया"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "पासवर्ड मेल नहीं खाते" msgstr "पासवर्ड मेल नहीं खाते"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "बदलने के लिए नई कुंजी पेस्ट करें"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "अपने सर्वर से OpenAI-संगत पता पेस्ट करें। ज़रूरत होने पर Rakazo /v1 जोड़ देता है।" msgstr "अपने सर्वर से OpenAI-संगत पता पेस्ट करें। ज़रूरत होने पर Rakazo /v1 जोड़ देता है।"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "अपनी API कुंजी पेस्ट करें"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "पिन करें"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "प्ले हो रहा है…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "प्रोवाइडर" msgstr "प्रोवाइडर"
@ -4232,10 +4190,6 @@ msgstr "पुनः खोलें"
msgid "Replace API key" msgid "Replace API key"
msgstr "API कुंजी बदलें" msgstr "API कुंजी बदलें"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "कुंजी बदलें"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "उत्तर दें" msgstr "उत्तर दें"
@ -4871,6 +4825,11 @@ msgstr "चरण"
msgid "Stop" msgid "Stop"
msgstr "रोकें" msgstr "रोकें"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "बोलना रोकें" msgstr "बोलना रोकें"

View File

@ -635,7 +635,6 @@ msgstr "API가 다시 작동하지만 업데이트가 완료되지 않았습니
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API 키" msgstr "API 키"
@ -1346,7 +1345,6 @@ msgstr "비밀번호 확인"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "연결" msgstr "연결"
@ -1399,11 +1397,6 @@ msgstr "연결됨"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "연결됨 · {0}" msgstr "연결됨 · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "{0}에 연결했습니다."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "연결되었습니다. 다음 메시지부터 이 도구를 사용할
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "연결 중…" msgstr "연결 중…"
@ -1535,10 +1527,6 @@ msgstr "{0}에 연결하지 못했습니다."
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "AI 서비스에 연결하지 못했습니다." msgstr "AI 서비스에 연결하지 못했습니다."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "음성 서비스에 연결하지 못했습니다."
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "" msgstr ""
@ -1604,10 +1592,6 @@ msgstr "기억 서비스 연결을 해제하지 못했습니다."
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "OAuth 연결을 해제하지 못했습니다." msgstr "OAuth 연결을 해제하지 못했습니다."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "음성 서비스 연결을 해제하지 못했습니다."
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "" msgstr ""
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "샘플 음성을 재생하지 못했습니다."
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "연결 해제" msgstr "연결 해제"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "연결 해제 중…" msgstr "연결 해제 중…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "샘플 듣기"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "샘플이 들렸다면 음성 설정이 완료되었습니다."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
@ -3792,10 +3762,6 @@ msgstr "조직 API 키"
msgid "Other model…" msgid "Other model…"
msgstr "다른 모델 직접 입력…" msgstr "다른 모델 직접 입력…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "비밀번호가 업데이트되었습니다"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "비밀번호가 일치하지 않습니다" msgstr "비밀번호가 일치하지 않습니다"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "새 API 키를 붙여넣으세요"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "서버의 OpenAI 호환 주소를 붙여넣으세요. 필요하면 Rakazo가 /v1을 추가합니다." msgstr "서버의 OpenAI 호환 주소를 붙여넣으세요. 필요하면 Rakazo가 /v1을 추가합니다."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "API 키를 붙여넣으세요"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "고정"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "재생 중…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "서비스" msgstr "서비스"
@ -4232,10 +4190,6 @@ msgstr "다시 열기"
msgid "Replace API key" msgid "Replace API key"
msgstr "API 키 교체" msgstr "API 키 교체"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "키 교체"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "답장" msgstr "답장"
@ -4871,6 +4825,11 @@ msgstr "실행 단계"
msgid "Stop" msgid "Stop"
msgstr "중지" msgstr "중지"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "읽기 중지" msgstr "읽기 중지"

View File

@ -635,7 +635,6 @@ msgstr ""
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "Chave de API" msgstr "Chave de API"
@ -1346,7 +1345,6 @@ msgstr "Confirmar senha"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "Conectar" msgstr "Conectar"
@ -1399,11 +1397,6 @@ msgstr "Conectado"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "Conectado · {0}" msgstr "Conectado · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "Conectado {0}."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "Conectado. Suas ferramentas estão disponíveis na sua próxima mensagem
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "Conectando…" msgstr "Conectando…"
@ -1535,10 +1527,6 @@ msgstr "Não foi possível conectar o {0}"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "Não foi possível conectar este provedor" msgstr "Não foi possível conectar este provedor"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "Não foi possível conectar este provedor de voz"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "" msgstr ""
@ -1604,10 +1592,6 @@ msgstr "Não foi possível desconectar o provedor de memória"
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "Não foi possível desconectar o OAuth" msgstr "Não foi possível desconectar o OAuth"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "Não foi possível desconectar este provedor de voz"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "" msgstr ""
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "Não foi possível reproduzir um clipe de teste"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "Desconectar" msgstr "Desconectar"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "Desconectando…" msgstr "Desconectando…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "Ouvir uma amostra"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "Se você ouviu isso, a voz está pronta."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
@ -3792,10 +3762,6 @@ msgstr "Chave da API da organização"
msgid "Other model…" msgid "Other model…"
msgstr "Outro modelo..." msgstr "Outro modelo..."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "Senha atualizada"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "As senhas não coincidem" msgstr "As senhas não coincidem"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "Cole uma chave de substituição"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "Cole o endereço compatível com OpenAI do seu servidor. Rakazo adiciona /v1 se necessário." msgstr "Cole o endereço compatível com OpenAI do seu servidor. Rakazo adiciona /v1 se necessário."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "Cole sua chave API"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "Fixar"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "Reproduzindo"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "Provedor" msgstr "Provedor"
@ -4232,10 +4190,6 @@ msgstr "Reabrir"
msgid "Replace API key" msgid "Replace API key"
msgstr "Substituir chave de API" msgstr "Substituir chave de API"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "Substituir chave"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "Responder" msgstr "Responder"
@ -4871,6 +4825,11 @@ msgstr "Passos"
msgid "Stop" msgid "Stop"
msgstr "Parar" msgstr "Parar"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "Pare de Falar" msgstr "Pare de Falar"

View File

@ -635,7 +635,6 @@ msgstr "API вернулся, но обновление не завершило
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API-ключ" msgstr "API-ключ"
@ -1346,7 +1345,6 @@ msgstr "Подтвердите пароль"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "Подключить" msgstr "Подключить"
@ -1399,11 +1397,6 @@ msgstr "Подключено"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "Подключено · {0}" msgstr "Подключено · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "Подключено: {0}."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "Подключено. Его инструменты доступны и
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "Подключение…" msgstr "Подключение…"
@ -1535,10 +1527,6 @@ msgstr "Не удалось подключить {0}"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "Не удалось подключить этого провайдера" msgstr "Не удалось подключить этого провайдера"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "Не удалось подключить этого голосового провайдера."
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "Не удалось подключиться к экрану компьютера" msgstr "Не удалось подключиться к экрану компьютера"
@ -1604,10 +1592,6 @@ msgstr "Не удалось отключить поставщика памяти
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "Не удалось отключить OAuth" msgstr "Не удалось отключить OAuth"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "Не удалось отключить этого голосового провайдера."
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "Не удалось отклонить" msgstr "Не удалось отклонить"
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку." msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "Не удалось воспроизвести тестовый клип."
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr "Прямой MCP"
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "Отключить" msgstr "Отключить"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "Отключение…" msgstr "Отключение…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "Прослушать образец"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "Если вы это услышали, голос готов."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "игнорировать" msgstr "игнорировать"
@ -3792,10 +3762,6 @@ msgstr "Ключ API организации"
msgid "Other model…" msgid "Other model…"
msgstr "Другая модель…" msgstr "Другая модель…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "Пароль обновлён"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Пароли не совпадают" msgstr "Пароли не совпадают"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "Вставьте запасной ключ"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "Вставьте OpenAI-совместимый адрес со своего сервера. При необходимости Rakazo добавляет /v1." msgstr "Вставьте OpenAI-совместимый адрес со своего сервера. При необходимости Rakazo добавляет /v1."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "Вставьте свой ключ API"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "Закрепить"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "Воспроизведение…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "Поставщик" msgstr "Поставщик"
@ -4232,10 +4190,6 @@ msgstr "Открыть заново"
msgid "Replace API key" msgid "Replace API key"
msgstr "Заменить ключ API" msgstr "Заменить ключ API"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "Заменить ключ"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "Ответить" msgstr "Ответить"
@ -4871,6 +4825,11 @@ msgstr "Шаги"
msgid "Stop" msgid "Stop"
msgstr "Остановить" msgstr "Остановить"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "Остановить озвучивание" msgstr "Остановить озвучивание"

View File

@ -635,7 +635,6 @@ msgstr ""
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API anahtarı" msgstr "API anahtarı"
@ -1346,7 +1345,6 @@ msgstr "Parolayı doğrulayın"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "Bağlan" msgstr "Bağlan"
@ -1399,11 +1397,6 @@ msgstr "Bağlandı"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "Bağlı · {0}" msgstr "Bağlı · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "{0} bağlandı."
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "Bağlandı. Araçları bir sonraki mesajından itibaren kullanılabilir.
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "Bağlanıyor…" msgstr "Bağlanıyor…"
@ -1535,10 +1527,6 @@ msgstr "{0} bağlanamadı"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "Bu sağlayıcı bağlanamadı" msgstr "Bu sağlayıcı bağlanamadı"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "Bu ses sağlayıcısı bağlanamadı"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "" msgstr ""
@ -1604,10 +1592,6 @@ msgstr "Hafıza sağlayıcısının bağlantısı kesilemedi"
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "OAuth bağlantısı kesilemedi" msgstr "OAuth bağlantısı kesilemedi"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "Bu ses sağlayıcısının bağlantısı kesilemedi"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "" msgstr ""
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "Deneme sesi çalınamadı"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "Bağlantıyı kes" msgstr "Bağlantıyı kes"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "Bağlantı kesiliyor…" msgstr "Bağlantı kesiliyor…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "Örnek dinle"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "Bunu duyduysan ses hazır."
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
@ -3792,10 +3762,6 @@ msgstr "Kuruluş API anahtarı"
msgid "Other model…" msgid "Other model…"
msgstr "Başka model…" msgstr "Başka model…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "Parola güncellendi"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Parolalar eşleşmiyor" msgstr "Parolalar eşleşmiyor"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "Yeni bir anahtar yapıştır"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "Sunucundaki OpenAI uyumlu adresi yapıştır. Gerekirse Rakazo /v1 ekler." msgstr "Sunucundaki OpenAI uyumlu adresi yapıştır. Gerekirse Rakazo /v1 ekler."
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "API anahtarını yapıştır"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "Sabitle"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "Çalınıyor…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "Sağlayıcı" msgstr "Sağlayıcı"
@ -4232,10 +4190,6 @@ msgstr "Yeniden aç"
msgid "Replace API key" msgid "Replace API key"
msgstr "API anahtarını değiştir" msgstr "API anahtarını değiştir"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "Anahtarı değiştir"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "Yanıtla" msgstr "Yanıtla"
@ -4871,6 +4825,11 @@ msgstr "Adımlar"
msgid "Stop" msgid "Stop"
msgstr "Durdur" msgstr "Durdur"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "Seslendirmeyi durdur" msgstr "Seslendirmeyi durdur"

View File

@ -635,7 +635,6 @@ msgstr "API 已恢复,但更新未完成。请查看宿主日志。"
#: src/lib/localized-provider-hint.ts #: src/lib/localized-provider-hint.ts
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "API key" msgid "API key"
msgstr "API 密钥" msgstr "API 密钥"
@ -1346,7 +1345,6 @@ msgstr "确认密码"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connect" msgid "Connect"
msgstr "连接" msgstr "连接"
@ -1399,11 +1397,6 @@ msgstr "已连接"
msgid "Connected · {0}" msgid "Connected · {0}"
msgstr "已连接 · {0}" msgstr "已连接 · {0}"
#. placeholder {0}: selectedAdvanced.name
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connected {0}."
msgstr "已连接 {0}。"
#. placeholder {0}: selected.label #. placeholder {0}: selected.label
#. placeholder {0}: selectedLabelRef.current ?? "this model" #. placeholder {0}: selectedLabelRef.current ?? "this model"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
@ -1424,7 +1417,6 @@ msgstr "已连接,下一条消息起即可使用其工具。"
#: src/pages/memory-providers/SerenitySettingsForm.tsx #: src/pages/memory-providers/SerenitySettingsForm.tsx
#: src/pages/memory-providers/SupermemorySettingsForm.tsx #: src/pages/memory-providers/SupermemorySettingsForm.tsx
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Connecting…" msgid "Connecting…"
msgstr "正在连接…" msgstr "正在连接…"
@ -1535,10 +1527,6 @@ msgstr "无法连接 {0}"
msgid "Could not connect this provider" msgid "Could not connect this provider"
msgstr "无法连接此提供方" msgstr "无法连接此提供方"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not connect this voice provider"
msgstr "无法连接此语音提供方"
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Could not connect to the computer screen" msgid "Could not connect to the computer screen"
msgstr "" msgstr ""
@ -1604,10 +1592,6 @@ msgstr "无法断开记忆提供方"
msgid "Could not disconnect OAuth" msgid "Could not disconnect OAuth"
msgstr "无法断开 OAuth" msgstr "无法断开 OAuth"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not disconnect this voice provider"
msgstr "无法断开此语音提供方"
#: src/pages/shell/message-cards.tsx #: src/pages/shell/message-cards.tsx
msgid "Could not dismiss" msgid "Could not dismiss"
msgstr "" msgstr ""
@ -1679,10 +1663,6 @@ msgstr ""
msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Could not play a test clip"
msgstr "无法播放测试片段"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Could not reach the server" msgid "Could not reach the server"
@ -2120,12 +2100,10 @@ msgstr ""
#: src/pages/McpServersOverlay.tsx #: src/pages/McpServersOverlay.tsx
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnect" msgid "Disconnect"
msgstr "断开" msgstr "断开"
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Disconnecting…" msgid "Disconnecting…"
msgstr "正在断开…" msgstr "正在断开…"
@ -2703,10 +2681,6 @@ msgstr ""
msgid "Health" msgid "Health"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Hear a sample"
msgstr "试听"
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Heart" msgid "Heart"
msgstr "" msgstr ""
@ -2785,10 +2759,6 @@ msgstr ""
msgid "Identity" msgid "Identity"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "If you heard that, voice is ready."
msgstr "如果听到了,说明语音已就绪。"
#: src/pages/ExternalConversationSettings.tsx #: src/pages/ExternalConversationSettings.tsx
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
@ -3792,10 +3762,6 @@ msgstr "组织 API 密钥"
msgid "Other model…" msgid "Other model…"
msgstr "其他模型…" msgstr "其他模型…"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Other voice providers"
msgstr ""
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr ""
@ -3832,18 +3798,10 @@ msgstr "密码已更新"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "两次密码不一致" msgstr "两次密码不一致"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste a replacement key"
msgstr "粘贴替换密钥"
#: src/pages/ModelSettingsOverlay.tsx #: src/pages/ModelSettingsOverlay.tsx
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed." msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
msgstr "粘贴服务器的 OpenAI 兼容地址。需要时 Rakazo 会补上 /v1。" msgstr "粘贴服务器的 OpenAI 兼容地址。需要时 Rakazo 会补上 /v1。"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Paste your API key"
msgstr "粘贴你的 API 密钥"
#: src/pages/HeuteBrief.tsx #: src/pages/HeuteBrief.tsx
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
msgid "Paused" msgid "Paused"
@ -3889,14 +3847,15 @@ msgstr "置顶"
msgid "Pink" msgid "Pink"
msgstr "" msgstr ""
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Play {0}"
msgstr ""
#: src/components/OoAtelier.tsx #: src/components/OoAtelier.tsx
msgid "Playful" msgid "Playful"
msgstr "" msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Playing…"
msgstr "播放中…"
#: src/lib/voice-persona-copy.ts #: src/lib/voice-persona-copy.ts
msgid "Point me at the problem." msgid "Point me at the problem."
msgstr "" msgstr ""
@ -3973,7 +3932,6 @@ msgid "Projects"
msgstr "" msgstr ""
#: src/pages/MemorySettingsOverlay.tsx #: src/pages/MemorySettingsOverlay.tsx
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Provider" msgid "Provider"
msgstr "提供方" msgstr "提供方"
@ -4232,10 +4190,6 @@ msgstr "重新打开"
msgid "Replace API key" msgid "Replace API key"
msgstr "替换 API 密钥" msgstr "替换 API 密钥"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Replace key"
msgstr "替换密钥"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Reply" msgid "Reply"
msgstr "回复" msgstr "回复"
@ -4871,6 +4825,11 @@ msgstr "步骤"
msgid "Stop" msgid "Stop"
msgstr "停止" msgstr "停止"
#. placeholder {0}: voice.label
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Stop {0}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Stop speaking" msgid "Stop speaking"
msgstr "停止朗读" msgstr "停止朗读"

View File

@ -23,6 +23,7 @@ import {
setUiAppearance, setUiAppearance,
} from "../lib/ui-appearance"; } from "../lib/ui-appearance";
import { UI_LOCALE_LABELS, UI_LOCALES, type UiLocale } from "../lib/ui-locale"; import { UI_LOCALE_LABELS, UI_LOCALES, type UiLocale } from "../lib/ui-locale";
import { alignVoiceWithLocale } from "../lib/voice-locale";
export type SettingsGeneralProps = { export type SettingsGeneralProps = {
email?: string | null; email?: string | null;
@ -50,6 +51,7 @@ export function GeneralSettingsPanels({
const requestId = ++localeRequestRef.current; const requestId = ++localeRequestRef.current;
setLocale(next); setLocale(next);
void setUiLocale(next).then((activated) => { void setUiLocale(next).then((activated) => {
void alignVoiceWithLocale(activated).catch(() => undefined);
if (requestId !== localeRequestRef.current) return; if (requestId !== localeRequestRef.current) return;
setLocale(activated); setLocale(activated);
}); });

View File

@ -1,29 +1,30 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import type { VoiceCatalogEntry, VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts"; import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
import { preferVoicesForLocale } from "@rakazo/core"; import { voiceIdForLocale, voicesForLocale } from "@rakazo/core";
import { import {
Button, Button,
cn,
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Field,
FieldLabel,
Input,
NativeSelect,
NativeSelectOption,
Switch, Switch,
} from "@rakazo/ui-web"; } from "@rakazo/ui-web";
import { ChevronDownIcon, XIcon } from "lucide-react"; import { PlayIcon, SquareIcon, XIcon } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ooSound } from "../lib/oo-sound"; import { ooSound } from "../lib/oo-sound";
import { readOoSoundEnabled } from "../lib/oo-sound-pref"; import { readOoSoundEnabled } from "../lib/oo-sound-pref";
import { rpc } from "../lib/rpc"; import { rpc } from "../lib/rpc";
import { speaker } from "../lib/tts";
import { readUiLocale } from "../lib/ui-locale"; import { readUiLocale } from "../lib/ui-locale";
import {
alignVoiceWithLocale,
KOKORO_LOCAL_TOKEN,
LOCAL_VOICE_PROVIDER,
} from "../lib/voice-locale";
/** Must match packages/adapters KokoroVoiceProvider placeholder. */ const LOCAL_PROVIDER_ID = LOCAL_VOICE_PROVIDER;
const KOKORO_LOCAL_TOKEN = "local-kokoro"; const PREVIEW_PREFIX = "voice-preview:";
const LOCAL_PROVIDER_ID = "kokoro";
export function VoiceSettingsOverlay({ export function VoiceSettingsOverlay({
onClose, onClose,
@ -39,50 +40,51 @@ export function VoiceSettingsOverlay({
activeBotId?: string; activeBotId?: string;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const apiKeyId = useId();
const voiceSelectId = useId();
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]);
const [credentials, setCredentials] = useState<VoiceCredential[]>([]); const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
const [status, setStatus] = useState<VoiceStatus | null>(null); const [status, setStatus] = useState<VoiceStatus | null>(null);
const [voices, setVoices] = useState<VoiceInfo[]>([]); const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [voiceId, setVoiceId] = useState(""); const [voiceId, setVoiceId] = useState("");
const [autoSpeak, setAutoSpeak] = useState(false); const [autoSpeak, setAutoSpeak] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pending, setPending] = useState< const [pending, setPending] = useState<"connect" | "voice" | "speak" | null>(null);
"connect" | "disconnect" | "voice" | "test" | "speak" | null
>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled()); const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled());
const [advancedOpen, setAdvancedOpen] = useState(false); const [previewId, setPreviewId] = useState<string | null>(null);
const [advancedProvider, setAdvancedProvider] = useState("");
const [advancedApiKey, setAdvancedApiKey] = useState("");
const advancedChoice = useRef("");
const busy = pending !== null; const busy = pending !== null;
const uiLocale = readUiLocale(); const uiLocale = readUiLocale();
const localConnected = credentials.some((entry) => entry.provider === LOCAL_PROVIDER_ID); 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(() => { useEffect(() => {
return () => onBusyChange?.(false); return () => onBusyChange?.(false);
}, [onBusyChange]); }, [onBusyChange]);
function markPending(next: "connect" | "disconnect" | "voice" | "test" | "speak" | null) { 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); setPending(next);
onBusyChange?.(next !== null); onBusyChange?.(next !== null);
} }
async function refresh() { async function refresh() {
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([ await alignVoiceWithLocale(uiLocale).catch(() => undefined);
rpc.voice.catalog(), const [nextCredentials, nextStatus] = await Promise.all([
rpc.voice.credentials(), rpc.voice.credentials(),
rpc.voice.status(), rpc.voice.status(),
]); ]);
setCatalog(nextCatalog);
setCredentials(nextCredentials); setCredentials(nextCredentials);
setStatus(nextStatus); setStatus(nextStatus);
const localCred = nextCredentials.find((entry) => entry.provider === LOCAL_PROVIDER_ID); const localCred = nextCredentials.find((entry) => entry.provider === LOCAL_PROVIDER_ID);
@ -91,7 +93,7 @@ export function VoiceSettingsOverlay({
if (localCred || nextStatus.ready) { if (localCred || nextStatus.ready) {
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
setVoices(listed); setVoices(listed);
if (!activeVoice && listed[0]) setVoiceId(listed[0].id); if (!activeVoice) setVoiceId(voiceIdForLocale(listed, uiLocale, "") ?? "");
} else { } else {
setVoices([]); setVoices([]);
} }
@ -100,16 +102,6 @@ export function VoiceSettingsOverlay({
const bot = bots.find((entry) => entry.id === activeBotId); const bot = bots.find((entry) => entry.id === activeBotId);
setAutoSpeak(bot?.autoSpeak ?? false); 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(() => { useEffect(() => {
@ -121,7 +113,7 @@ export function VoiceSettingsOverlay({
}, [activeBotId]); }, [activeBotId]);
const voiceOptions = useMemo(() => { const voiceOptions = useMemo(() => {
const listed = preferVoicesForLocale(voices, uiLocale); const listed = voicesForLocale(voices, uiLocale);
if (listed.length) return listed; if (listed.length) return listed;
return voiceId ? [{ id: voiceId, label: voiceId }] : []; return voiceId ? [{ id: voiceId, label: voiceId }] : [];
}, [voices, voiceId, uiLocale]); }, [voices, voiceId, uiLocale]);
@ -176,7 +168,7 @@ export function VoiceSettingsOverlay({
return; return;
} }
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
const nextVoice = voiceId || listed[0]?.id; const nextVoice = voiceIdForLocale(listed, uiLocale, voiceId);
if (nextVoice) { if (nextVoice) {
await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID }); await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID });
setVoiceId(nextVoice); setVoiceId(nextVoice);
@ -195,58 +187,16 @@ export function VoiceSettingsOverlay({
} }
} }
async function connectAdvanced() { function togglePreview(voice: VoiceInfo) {
if (!selectedAdvanced || advancedApiKey.trim().length < 8) return; if (previewId === voice.id) {
markPending("connect"); speaker.stop();
setError(null); return;
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); setError(null);
try { void speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`, {
await rpc.voice.disconnect({ provider: advancedCredential.provider }); voiceId: voice.id,
setAdvancedApiKey(""); messageId: `${PREVIEW_PREFIX}${voice.id}`,
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 = ( const body = (
@ -296,37 +246,55 @@ export function VoiceSettingsOverlay({
</div> </div>
</div> </div>
{autoSpeak && speakingReady ? ( {speakingReady && voiceOptions.length > 0 ? (
<> <div
<Field className="mt-6"> data-testid="voice-carousel"
<FieldLabel htmlFor={voiceSelectId}> 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"
<Trans>Voice</Trans> >
</FieldLabel> {voiceOptions.map((voice) => {
<NativeSelect const active = voice.id === voiceId;
id={voiceSelectId} const playing = previewId === voice.id;
className="w-full" return (
value={voiceId} <div
disabled={busy} key={voice.id}
onChange={(event) => void chooseVoice(event.target.value)} data-testid="voice-card"
> className={cn(
{voiceOptions.map((voice) => ( "relative w-40 shrink-0 snap-start rounded-xl border px-4 py-3 transition-colors",
<NativeSelectOption key={voice.id} value={voice.id}> active
{voice.label} ? "border-primary bg-muted ring-1 ring-primary"
{voice.description ? ` · ${voice.description}` : ""} : "border-border bg-card hover:bg-accent",
</NativeSelectOption> )}
))} >
</NativeSelect> <div className="flex items-center justify-between gap-2">
</Field> <button
<Button type="button"
type="button" aria-pressed={active}
variant="secondary" disabled={busy}
className="mt-4 rounded-full" onClick={() => void chooseVoice(voice.id)}
disabled={busy || !status?.ready} 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"
onClick={() => void testVoice()} >
> {voice.label}
{pending === "test" ? <Trans>Playing…</Trans> : <Trans>Hear a sample</Trans>} </button>
</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} ) : null}
<div className="mt-8 rounded-xl border border-border px-4 py-4"> <div className="mt-8 rounded-xl border border-border px-4 py-4">
@ -359,97 +327,6 @@ export function VoiceSettingsOverlay({
</label> </label>
</div> </div>
</div> </div>
{advancedProviders.length > 0 ? (
<div className="mt-8">
<button
type="button"
disabled={busy}
onClick={() => setAdvancedOpen((open) => !open)}
className="flex w-full items-center justify-between rounded-xl border border-border px-4 py-3 text-start text-ui-body text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<span>
<Trans>Other voice providers</Trans>
</span>
<ChevronDownIcon
className={`size-4 shrink-0 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
/>
</button>
{advancedOpen && selectedAdvanced ? (
<div className="mt-3 rounded-xl border border-border px-4 py-4">
<Field>
<FieldLabel htmlFor={apiKeyId}>
<Trans>Provider</Trans>
</FieldLabel>
<NativeSelect
id={apiKeyId}
className="w-full"
value={advancedProvider}
disabled={busy}
onChange={(event) => {
advancedChoice.current = event.target.value;
setAdvancedProvider(event.target.value);
setAdvancedApiKey("");
setError(null);
setNotice(null);
}}
>
{advancedProviders.map((entry) => (
<NativeSelectOption key={entry.id} value={entry.id}>
{entry.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${apiKeyId}-key`}>
<Trans>API key</Trans>
</FieldLabel>
<Input
id={`${apiKeyId}-key`}
type="password"
autoComplete="new-password"
value={advancedApiKey}
onChange={(event) => setAdvancedApiKey(event.target.value)}
placeholder={
advancedCredential ? t`Paste a replacement key` : t`Paste your API key`
}
/>
</Field>
<Button
type="button"
className="mt-3"
disabled={busy || advancedApiKey.trim().length < 8}
onClick={() => void connectAdvanced()}
>
{pending === "connect" ? (
<Trans>Connecting…</Trans>
) : advancedCredential ? (
<Trans>Replace key</Trans>
) : (
<Trans>Connect</Trans>
)}
</Button>
{advancedCredential ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={busy}
onClick={() => void disconnectAdvanced()}
>
{pending === "disconnect" ? (
<Trans>Disconnecting…</Trans>
) : (
<Trans>Disconnect</Trans>
)}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
</div> </div>
</> </>
); );

View File

@ -55,7 +55,7 @@ evOo processes account details, user-provided bot content, connection informatio
Before submitting content or starting AI work, the evOo mobile app names the configured recipients, explains the data and purpose, and asks permission. Messages, relevant history, instructions, memories, attachments, screenshots, and connected-app content may be sent to model services for responses and tasks, including scheduled tasks. Recorded audio may be sent for transcription and text for speech generation. Conversation summaries, memories, queries, and bot and Space identifiers may be sent to a configured memory service. Before submitting content or starting AI work, the evOo mobile app names the configured recipients, explains the data and purpose, and asks permission. Messages, relevant history, instructions, memories, attachments, screenshots, and connected-app content may be sent to model services for responses and tasks, including scheduled tasks. Recorded audio may be sent for transcription and text for speech generation. Conversation summaries, memories, queries, and bot and Space identifiers may be sent to a configured memory service.
Supported connections include OpenAI, Anthropic, Google, OpenRouter, Vercel AI Gateway, Cursor, custom model servers, ElevenLabs, Cartesia, Fish Audio, and Supermemory. The mobile disclosure identifies the configured services. Gateways forward requests to model providers according to their routing settings and provider agreements. The mobile permission flow does not modify provider routing or promise zero retention. Supported connections include OpenAI, Anthropic, Google, OpenRouter, Vercel AI Gateway, Cursor, custom model servers, and Supermemory. The mobile disclosure identifies the configured services. Gateways forward requests to model providers according to their routing settings and provider agreements. The mobile permission flow does not modify provider routing or promise zero retention.
Permission records include the recipient, Space, disclosure version, and time. Choose Not now to decline, or withdraw permission in Account → AI data sharing for new mobile actions. Stop existing runs and disable routines separately. Withdrawal cannot recall data already sent. Web, desktop, and self-hosted server workflows do not use this mobile permission flow. Permission records include the recipient, Space, disclosure version, and time. Choose Not now to decline, or withdraw permission in Account → AI data sharing for new mobile actions. Stop existing runs and disable routines separately. Withdrawal cannot recall data already sent. Web, desktop, and self-hosted server workflows do not use this mobile permission flow.

View File

@ -1,6 +1,6 @@
# Kokoro (lokale Stimme) # Kokoro (lokale Stimme)
Kokoro ist der kostenfreie Standard für Voice First: **Kokoro-82M** (Apache 2.0) läuft lokal über [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) — keine ElevenLabs/OpenAI/Cartesia-Keys nötig. Kokoro ist die einzige Stimme in EvOo: **Kokoro-82M** (Apache 2.0) läuft lokal über [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), ohne Cloud-Dienst und ohne API-Key.
## Schnellstart ## Schnellstart
@ -16,11 +16,14 @@ export KOKORO_BASE_URL=http://127.0.0.1:8880
## In EvOo verbinden ## In EvOo verbinden
1. **Voice** in den Einstellungen öffnen. 1. **Stimme** in den Einstellungen öffnen.
2. **Kokoro** wählen (erster Eintrag). 2. **Kann sprechen** einschalten. EvOo prüft `GET /v1/audio/voices` und speichert einen Platzhalter-Token (`local-kokoro`), kein API-Key.
3. **Connect local Kokoro** — prüft `GET /v1/audio/voices`, speichert einen Platzhalter-Token (`local-kokoro`), kein API-Key. 3. Im Karussell eine Stimme antippen; der Play-Knopf spielt sie vorher an.
4. Stimme wählen (z. B. `af_heart`) und **Hear a sample** testen. 4. Voice First / CallView nutzt dann `POST /v1/audio/speech` (OpenAI-kompatibel, `stream: false`).
5. Voice First / CallView nutzt dann `POST /v1/audio/speech` (OpenAI-kompatibel, `stream: false`).
## Stimmen und Sprache
Kokoro kodiert die Sprache im ersten Buchstaben der Stimm-ID (`a`/`b` Englisch, `d` Deutsch, `e` Spanisch, `f` Französisch, …). EvOo zeigt nur die Stimmen der eingestellten App-Sprache und wechselt die gespeicherte Stimme mit, wenn die Sprache wechselt. Bietet der Server keine Stimme in dieser Sprache an (das Standard-Image von Kokoro-FastAPI hat keine deutschen `df_`/`dm_`-Stimmen), zeigt EvOo alle Stimmen.
## Fehler ## Fehler

View File

@ -14,9 +14,6 @@ const PRIVACY_URLS: Record<string, string> = {
anthropic: "https://www.anthropic.com/legal/privacy", anthropic: "https://www.anthropic.com/legal/privacy",
google: "https://policies.google.com/privacy", google: "https://policies.google.com/privacy",
"vercel-ai-gateway": "https://vercel.com/legal/privacy-policy", "vercel-ai-gateway": "https://vercel.com/legal/privacy-policy",
elevenlabs: "https://elevenlabs.io/privacy-policy",
cartesia: "https://cartesia.ai/legal/privacy",
"fish-audio": "https://fish.audio/privacy/",
supermemory: "https://supermemory.ai/privacy", supermemory: "https://supermemory.ai/privacy",
}; };

View File

@ -1,108 +0,0 @@
import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
import {
readVoiceAudio,
readVoiceJson,
requireOk,
voiceDeadline,
voiceHttpError,
voiceUnreachable,
} from "./voice-http.js";
const API = "https://api.cartesia.ai";
const VERSION = "2024-06-10";
const MODEL = "sonic-3";
export class CartesiaVoiceProvider implements VoiceProvider {
describe(): AdapterDescriptor<VoiceCapabilities> {
return {
id: "cartesia",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { catalog: true, synthesize: true, transcribe: false },
};
}
async verify(apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult> {
try {
const res = await fetch(`${API}/voices`, {
headers: cartesiaHeaders(apiKey),
signal: voiceDeadline(context.signal, 20_000),
});
if (res.ok) return { ok: true };
return {
ok: false,
message: voiceHttpError(
res.status,
"Cartesia",
"checking that key",
await readVoiceJson(res),
),
};
} catch {
return {
ok: false,
message: voiceUnreachable("Cartesia"),
};
}
}
async listVoices(apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
const res = await fetch(`${API}/voices`, {
headers: cartesiaHeaders(apiKey),
signal: voiceDeadline(context.signal, 20_000),
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) throw new Error(voiceHttpError(res.status, "Cartesia", "listing voices", body));
const voices = voicesFrom(body);
return voices
.map((voice) => ({
id: String(voice.id ?? voice.voice_id ?? ""),
label: String(voice.name ?? "Voice"),
description: typeof voice.description === "string" ? voice.description : undefined,
}))
.filter((voice) => voice.id);
}
async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip> {
const signal = voiceDeadline(request.signal ?? context.signal, 60_000);
const res = await fetch(`${API}/tts/bytes`, {
method: "POST",
headers: {
...cartesiaHeaders(request.apiKey),
"content-type": "application/json",
},
body: JSON.stringify({
model_id: MODEL,
transcript: request.text,
voice: { mode: "id", id: request.voiceId },
output_format: { container: "mp3", encoding: "mp3", sample_rate: 44100 },
}),
signal,
});
await requireOk(res, "Cartesia", "speaking");
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" };
}
}
function cartesiaHeaders(apiKey: string): Record<string, string> {
return { "X-API-Key": apiKey, "Cartesia-Version": VERSION };
}
function voicesFrom(body: unknown): Array<Record<string, unknown>> {
if (Array.isArray(body)) return body as Array<Record<string, unknown>>;
if (body && typeof body === "object") {
const record = body as Record<string, unknown>;
if (Array.isArray(record.data)) return record.data as Array<Record<string, unknown>>;
if (Array.isArray(record.voices)) return record.voices as Array<Record<string, unknown>>;
}
return [];
}

View File

@ -1,128 +0,0 @@
import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceTranscribeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
import {
readVoiceAudio,
readVoiceJson,
requireOk,
speechUploadName,
voiceDeadline,
voiceHttpError,
voiceUnreachable,
} from "./voice-http.js";
const API = "https://api.elevenlabs.io/v1";
const MODEL = "eleven_flash_v2_5";
const FORMAT = "mp3_44100_64";
const TRANSCRIBE_MODEL = "scribe_v1";
export class ElevenLabsVoiceProvider implements VoiceProvider {
describe(): AdapterDescriptor<VoiceCapabilities> {
return {
id: "elevenlabs",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { catalog: true, synthesize: true, transcribe: true },
};
}
async verify(apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult> {
try {
const res = await fetch(`${API}/voices`, {
headers: { "xi-api-key": apiKey },
signal: voiceDeadline(context.signal, 20_000),
});
if (res.ok) return { ok: true };
return {
ok: false,
message: voiceHttpError(
res.status,
"ElevenLabs",
"checking that key",
await readVoiceJson(res),
),
};
} catch {
return {
ok: false,
message: voiceUnreachable("ElevenLabs"),
};
}
}
async listVoices(apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
const res = await fetch(`${API}/voices`, {
headers: { "xi-api-key": apiKey },
signal: voiceDeadline(context.signal, 20_000),
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) throw new Error(voiceHttpError(res.status, "ElevenLabs", "listing voices", body));
const voices = (body as { voices?: Array<Record<string, unknown>> } | null)?.voices ?? [];
return voices
.map((voice) => ({
id: String(voice.voice_id ?? ""),
label: String(voice.name ?? "Voice"),
description:
[asLabel(voice.labels, "accent"), asLabel(voice.labels, "description")]
.filter(Boolean)
.join(" · ") || undefined,
}))
.filter((voice) => voice.id);
}
async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip> {
const signal = voiceDeadline(request.signal ?? context.signal, 60_000);
const res = await fetch(
`${API}/text-to-speech/${encodeURIComponent(request.voiceId)}?output_format=${FORMAT}`,
{
method: "POST",
headers: {
"xi-api-key": request.apiKey,
"content-type": "application/json",
accept: "audio/mpeg",
},
body: JSON.stringify({ text: request.text, model_id: MODEL }),
signal,
},
);
await requireOk(res, "ElevenLabs", "speaking");
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" };
}
async transcribe(
request: VoiceTranscribeRequest,
context: AdapterContext,
): Promise<{ text: string }> {
const form = new FormData();
form.set("model_id", TRANSCRIBE_MODEL);
form.set(
"file",
new Blob([new Uint8Array(request.audio)], { type: request.mimeType || "audio/webm" }),
speechUploadName(request.mimeType),
);
const res = await fetch(`${API}/speech-to-text`, {
method: "POST",
headers: { "xi-api-key": request.apiKey },
body: form,
signal: voiceDeadline(request.signal ?? context.signal, 60_000),
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) throw new Error(voiceHttpError(res.status, "ElevenLabs", "transcribing", body));
const text = String((body as { text?: unknown } | null)?.text ?? "").trim();
return { text };
}
}
function asLabel(labels: unknown, key: string): string {
if (!labels || typeof labels !== "object") return "";
const value = (labels as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}

View File

@ -1,210 +0,0 @@
import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceTranscribeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
import {
readVoiceAudio,
readVoiceJson,
requireOk,
speechUploadName,
voiceDeadline,
voiceHttpError,
voiceUnreachable,
} from "./voice-http.js";
const API = "https://api.fish.audio";
const MODEL_PAGE_SIZE = 100;
/** Top-scored public voices for the picker — not a full catalog crawl. */
const PUBLIC_MODEL_PAGES = 5;
/** User-owned libraries are smaller; still hard-capped. */
const OWN_MODEL_PAGES = 20;
const LIST_VOICES_DEADLINE_MS = 20_000;
const TTS_MODEL = "s2.1-pro";
export class FishAudioVoiceProvider implements VoiceProvider {
/** Advertise Fish Audio's model catalog, speech synthesis, and transcription support. */
describe(): AdapterDescriptor<VoiceCapabilities> {
return {
id: "fish-audio",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { catalog: true, synthesize: true, transcribe: true },
};
}
/** Verify the user's Fish Audio API key against the model catalog endpoint. */
async verify(apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult> {
try {
const res = await fetch(`${API}/model?page_size=1&page_number=1`, {
headers: fishAudioHeaders(apiKey),
signal: voiceDeadline(context.signal, 20_000),
});
if (res.ok) return { ok: true };
return {
ok: false,
message: voiceHttpError(
res.status,
"Fish Audio",
"checking that key",
await readVoiceJson(res),
),
};
} catch {
return {
ok: false,
message: voiceUnreachable("Fish Audio"),
};
}
}
/** Return user-owned then bounded public Fish Audio voices as Rakazo choices. */
async listVoices(apiKey: string, context: AdapterContext): Promise<VoiceInfo[]> {
const signal = voiceDeadline(context.signal, LIST_VOICES_DEADLINE_MS);
const listContext = { ...context, signal };
const [publicModels, ownModels] = await Promise.all([
fetchModels(apiKey, listContext, false),
fetchModels(apiKey, listContext, true),
]);
const seen = new Set<string>();
return [...ownModels, ...publicModels].map(modelToVoice).filter((voice): voice is VoiceInfo => {
if (!voice || seen.has(voice.id)) return false;
seen.add(voice.id);
return true;
});
}
/** Synthesize one Rakazo utterance as bounded MP3 audio. */
async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip> {
const signal = voiceDeadline(request.signal ?? context.signal, 60_000);
const res = await fetch(`${API}/v1/tts`, {
method: "POST",
headers: {
...fishAudioHeaders(request.apiKey),
"content-type": "application/json",
accept: "audio/mpeg",
model: TTS_MODEL,
},
body: JSON.stringify({
text: request.text,
reference_id: request.voiceId,
format: "mp3",
mp3_bitrate: 64,
latency: "balanced",
normalize: true,
}),
signal,
});
await requireOk(res, "Fish Audio", "speaking");
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" };
}
/** Transcribe a browser recording through Fish Audio's multipart ASR endpoint. */
async transcribe(
request: VoiceTranscribeRequest,
context: AdapterContext,
): Promise<{ text: string }> {
const form = new FormData();
form.set(
"audio",
new Blob([new Uint8Array(request.audio)], { type: request.mimeType || "audio/webm" }),
speechUploadName(request.mimeType),
);
form.set("ignore_timestamps", "true");
const res = await fetch(`${API}/v1/asr`, {
method: "POST",
headers: fishAudioHeaders(request.apiKey),
body: form,
signal: voiceDeadline(request.signal ?? context.signal, 60_000),
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) throw new Error(voiceHttpError(res.status, "Fish Audio", "transcribing", body));
return { text: String((body as { text?: unknown } | null)?.text ?? "").trim() };
}
}
/** Fetch public or user-owned Fish Audio voice models within a page budget. */
async function fetchModels(
apiKey: string,
context: AdapterContext,
own: boolean,
): Promise<Array<Record<string, unknown>>> {
const maxPages = own ? OWN_MODEL_PAGES : PUBLIC_MODEL_PAGES;
const models: Array<Record<string, unknown>> = [];
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
const params = new URLSearchParams({
page_size: String(MODEL_PAGE_SIZE),
page_number: String(pageNumber),
sort_by: "score",
});
if (own) params.set("self", "true");
const res = await fetch(`${API}/model?${params}`, {
headers: fishAudioHeaders(apiKey),
signal: context.signal,
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) throw new Error(voiceHttpError(res.status, "Fish Audio", "listing voices", body));
const items = modelsFrom(body);
models.push(...items);
if (items.length === 0 || !modelPageHasMore(body, pageNumber, items.length)) break;
}
return models;
}
/** Decide whether another Fish Audio model page should be requested. */
function modelPageHasMore(body: unknown, pageNumber: number, itemCount: number): boolean {
if (!body || typeof body !== "object") return false;
const meta = body as { has_more?: unknown; total?: unknown };
if (typeof meta.has_more === "boolean") return meta.has_more;
if (typeof meta.total === "number" && Number.isFinite(meta.total)) {
return pageNumber * MODEL_PAGE_SIZE < meta.total;
}
return itemCount === MODEL_PAGE_SIZE;
}
/** Build the authorization header shared by Fish Audio requests. */
function fishAudioHeaders(apiKey: string): Record<string, string> {
return { authorization: `Bearer ${apiKey}` };
}
/** Extract object-shaped model entries from a Fish Audio list response. */
function modelsFrom(body: unknown): Array<Record<string, unknown>> {
if (!body || typeof body !== "object") return [];
const items = (body as { items?: unknown }).items;
return Array.isArray(items)
? items.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === "object"),
)
: [];
}
/** Convert a Fish Audio model into the provider-neutral voice shape. */
function modelToVoice(model: Record<string, unknown>): VoiceInfo | null {
if (model.dmca_taken_down === true || model.state === "failed") return null;
const id = asText(model._id) || asText(model.id);
if (!id) return null;
const label = asText(model.title) || asText(model.name) || "Voice";
const description =
[asText(model.description), languageLabel(model.languages)].filter(Boolean).join(" · ") ||
undefined;
return { id, label, description };
}
/** Read a trimmed string field from an untyped provider response. */
function asText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Format the model language list for the voice picker description. */
function languageLabel(value: unknown): string {
if (!Array.isArray(value)) return "";
return value
.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.join(", ");
}

View File

@ -15,7 +15,6 @@ export * from "./browser-tools.js";
export { BUILTIN_MEMORY_PROVIDER_ID, BuiltinMemoryProvider } from "./builtin-memory-provider.js"; export { BUILTIN_MEMORY_PROVIDER_ID, BuiltinMemoryProvider } from "./builtin-memory-provider.js";
export * from "./builtin-skills.js"; export * from "./builtin-skills.js";
export * from "./builtin-tools.js"; export * from "./builtin-tools.js";
export * from "./cartesia-voice.js";
export * from "./chat-sdk-surface.js"; export * from "./chat-sdk-surface.js";
export * from "./child-bots.js"; export * from "./child-bots.js";
export * from "./cloud-agent-emulator.js"; export * from "./cloud-agent-emulator.js";
@ -51,7 +50,6 @@ export * from "./destination-emulator.js";
export * from "./docker-sandbox.js"; export * from "./docker-sandbox.js";
export * from "./e2b-emulator.js"; export * from "./e2b-emulator.js";
export * from "./e2b-sandbox.js"; export * from "./e2b-sandbox.js";
export * from "./elevenlabs-voice.js";
export * from "./email-emulator.js"; export * from "./email-emulator.js";
export * from "./embedding-emulator.js"; export * from "./embedding-emulator.js";
export * from "./executor.js"; export * from "./executor.js";
@ -65,7 +63,6 @@ export * from "./expo-push.js";
export * from "./fake-browser.js"; export * from "./fake-browser.js";
export * from "./fake-sandbox.js"; export * from "./fake-sandbox.js";
export * from "./fake-web.js"; export * from "./fake-web.js";
export * from "./fish-audio-voice.js";
export * from "./github-webhook-emulator.js"; export * from "./github-webhook-emulator.js";
export * from "./graphql-connectors.js"; export * from "./graphql-connectors.js";
export * from "./group-handoff.js"; export * from "./group-handoff.js";
@ -108,7 +105,6 @@ export * from "./none-sandbox.js";
export * from "./openai-compatible-embeddings.js"; export * from "./openai-compatible-embeddings.js";
export * from "./openai-compatible-transcription.js"; export * from "./openai-compatible-transcription.js";
export * from "./openai-compatible-url.js"; export * from "./openai-compatible-url.js";
export * from "./openai-voice.js";
export * from "./page-browser-session.js"; export * from "./page-browser-session.js";
export * from "./pi-credentials.js"; export * from "./pi-credentials.js";
export * from "./pi-model-config.js"; export * from "./pi-model-config.js";

View File

@ -26,15 +26,15 @@ const MODEL = "kokoro";
/** Curated presets when the server is down or returns bare voice ids. */ /** Curated presets when the server is down or returns bare voice ids. */
export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [ export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [
...GERMAN_VOICE_PRESETS, ...GERMAN_VOICE_PRESETS,
{ id: "af_heart", label: "Heart", description: "Warm · multilingual" }, { id: "af_heart", label: "Heart", description: "warm" },
{ id: "af_bella", label: "Bella", description: "Expressive · multilingual" }, { id: "af_bella", label: "Bella", description: "expressive" },
{ id: "af_sky", label: "Sky", description: "Bright · multilingual" }, { id: "af_sky", label: "Sky", description: "bright" },
{ id: "af_nova", label: "Nova", description: "Energetic · multilingual" }, { id: "af_nova", label: "Nova", description: "energetic" },
{ id: "am_adam", label: "Adam", description: "Clear · multilingual" }, { id: "am_adam", label: "Adam", description: "clear" },
{ id: "am_michael", label: "Michael", description: "Steady · multilingual" }, { id: "am_michael", label: "Michael", description: "steady" },
{ id: "bf_emma", label: "Emma", description: "British · multilingual" }, { id: "bf_emma", label: "Emma", description: "British" },
{ id: "bf_isabella", label: "Isabella", description: "British · multilingual" }, { id: "bf_isabella", label: "Isabella", description: "British" },
{ id: "bm_george", label: "George", description: "British · multilingual" }, { id: "bm_george", label: "George", description: "British" },
]; ];
const FALLBACK_BY_ID = new Map(KOKORO_FALLBACK_VOICES.map((voice) => [voice.id, voice])); const FALLBACK_BY_ID = new Map(KOKORO_FALLBACK_VOICES.map((voice) => [voice.id, voice]));
@ -127,7 +127,7 @@ function voicesFrom(body: unknown): VoiceInfo[] {
return raw return raw
.map((id) => { .map((id) => {
const known = FALLBACK_BY_ID.get(id); const known = FALLBACK_BY_ID.get(id);
return known ?? { id, label: formatVoiceLabel(id), description: "Multilingual" }; return known ?? { id, label: formatVoiceLabel(id) };
}) })
.filter((voice) => voice.id); .filter((voice) => voice.id);
} }

View File

@ -1,119 +0,0 @@
import type {
AdapterContext,
AdapterDescriptor,
SpeechClip,
VoiceCapabilities,
VoiceInfo,
VoiceProvider,
VoiceSynthesizeRequest,
VoiceTranscribeRequest,
VoiceVerifyResult,
} from "@rakazo/adapter-kit";
import {
readVoiceAudio,
readVoiceJson,
requireOk,
speechUploadName,
voiceDeadline,
voiceHttpError,
voiceUnreachable,
} from "./voice-http.js";
const API = "https://api.openai.com/v1";
const TTS_MODEL = "gpt-4o-mini-tts";
const STT_MODEL = "gpt-4o-mini-transcribe";
const OPENAI_VOICES: VoiceInfo[] = [
{ id: "alloy", label: "Alloy", description: "Neutral" },
{ id: "ash", label: "Ash", description: "Clear" },
{ id: "ballad", label: "Ballad", description: "Warm" },
{ id: "coral", label: "Coral", description: "Bright" },
{ id: "echo", label: "Echo", description: "Even" },
{ id: "fable", label: "Fable", description: "Narrative" },
{ id: "onyx", label: "Onyx", description: "Deep" },
{ id: "nova", label: "Nova", description: "Energetic" },
{ id: "sage", label: "Sage", description: "Calm" },
{ id: "shimmer", label: "Shimmer", description: "Soft" },
{ id: "verse", label: "Verse", description: "Expressive" },
];
export class OpenAIVoiceProvider implements VoiceProvider {
describe(): AdapterDescriptor<VoiceCapabilities> {
return {
id: "openai",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { catalog: true, synthesize: true, transcribe: true },
};
}
async verify(apiKey: string, context: AdapterContext): Promise<VoiceVerifyResult> {
try {
const res = await fetch(`${API}/models`, {
headers: { authorization: `Bearer ${apiKey}` },
signal: voiceDeadline(context.signal, 20_000),
});
if (res.ok) return { ok: true };
return {
ok: false,
message: voiceHttpError(
res.status,
"OpenAI",
"checking that key",
await readVoiceJson(res),
),
};
} catch {
return {
ok: false,
message: voiceUnreachable("OpenAI"),
};
}
}
async listVoices(_apiKey: string, _context: AdapterContext): Promise<VoiceInfo[]> {
return OPENAI_VOICES;
}
async synthesize(request: VoiceSynthesizeRequest, context: AdapterContext): Promise<SpeechClip> {
const signal = voiceDeadline(request.signal ?? context.signal, 60_000);
const res = await fetch(`${API}/audio/speech`, {
method: "POST",
headers: {
authorization: `Bearer ${request.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: TTS_MODEL,
voice: request.voiceId,
input: request.text,
response_format: "mp3",
}),
signal,
});
await requireOk(res, "OpenAI", "speaking");
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" };
}
async transcribe(
request: VoiceTranscribeRequest,
context: AdapterContext,
): Promise<{ text: string }> {
const form = new FormData();
form.set("model", STT_MODEL);
form.set(
"file",
new Blob([new Uint8Array(request.audio)], { type: request.mimeType || "audio/webm" }),
speechUploadName(request.mimeType),
);
const res = await fetch(`${API}/audio/transcriptions`, {
method: "POST",
headers: { authorization: `Bearer ${request.apiKey}` },
body: form,
signal: voiceDeadline(request.signal ?? context.signal, 60_000),
});
const body = await readVoiceJson(res, { requireValid: res.ok });
if (!res.ok) throw new Error(voiceHttpError(res.status, "OpenAI", "transcribing", body));
return { text: String((body as { text?: unknown } | null)?.text ?? "").trim() };
}
}

View File

@ -1,9 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { CartesiaVoiceProvider } from "./cartesia-voice.js";
import { ElevenLabsVoiceProvider } from "./elevenlabs-voice.js";
import { FishAudioVoiceProvider } from "./fish-audio-voice.js";
import { KOKORO_LOCAL_TOKEN, KokoroVoiceProvider } from "./kokoro-voice.js"; import { KOKORO_LOCAL_TOKEN, KokoroVoiceProvider } from "./kokoro-voice.js";
import { OpenAIVoiceProvider } from "./openai-voice.js";
import { import {
SCRIPTED_MPEG, SCRIPTED_MPEG,
SCRIPTED_TRANSCRIPT, SCRIPTED_TRANSCRIPT,
@ -36,30 +32,15 @@ afterEach(() => {
}); });
describe("createVoiceProvider", () => { describe("createVoiceProvider", () => {
it("exposes the hosted catalog behind one factory", () => { it("offers only the local voice server", () => {
process.env.AGENT_RUNTIME = "pi"; process.env.AGENT_RUNTIME = "pi";
expect(VOICE_CATALOG.map((entry) => entry.id)).toEqual([ expect(VOICE_CATALOG.map((entry) => entry.id)).toEqual(["kokoro"]);
"kokoro", expect(listVoiceCatalog().map((entry) => entry.id)).toEqual(["kokoro"]);
"elevenlabs",
"openai",
"cartesia",
"fish-audio",
]);
expect(listVoiceCatalog().map((entry) => entry.id)).toEqual([
"kokoro",
"elevenlabs",
"openai",
"cartesia",
"fish-audio",
]);
expect(createVoiceProvider("kokoro").describe().id).toBe("kokoro"); expect(createVoiceProvider("kokoro").describe().id).toBe("kokoro");
expect(createVoiceProvider("kokoro").describe().capabilities.transcribe).toBe(false); expect(createVoiceProvider("kokoro").describe().capabilities.transcribe).toBe(false);
expect(createVoiceProvider("elevenlabs").describe().id).toBe("elevenlabs");
expect(createVoiceProvider("openai").describe().capabilities.transcribe).toBe(true);
expect(createVoiceProvider("cartesia").describe().capabilities.transcribe).toBe(false);
expect(createVoiceProvider("fish-audio").describe().capabilities.transcribe).toBe(true);
expect(isVoiceProviderId("kokoro")).toBe(true); expect(isVoiceProviderId("kokoro")).toBe(true);
expect(isVoiceProviderId("elevenlabs")).toBe(true); expect(isVoiceProviderId("elevenlabs")).toBe(false);
expect(() => createVoiceProvider("elevenlabs")).toThrow(/unknown voice provider/i);
expect(isVoiceProviderId("scripted")).toBe(false); expect(isVoiceProviderId("scripted")).toBe(false);
expect(isVoiceProviderId("piper")).toBe(false); expect(isVoiceProviderId("piper")).toBe(false);
expect(() => createVoiceProvider("piper")).toThrow(/unknown voice provider/i); expect(() => createVoiceProvider("piper")).toThrow(/unknown voice provider/i);
@ -74,83 +55,6 @@ describe("createVoiceProvider", () => {
}); });
}); });
describe("ElevenLabsVoiceProvider", () => {
it("verifies against /voices so restricted speech keys still pass", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ voices: [{ voice_id: "abc", name: "Rachel" }] }),
});
vi.stubGlobal("fetch", fetchMock);
const provider = new ElevenLabsVoiceProvider();
await expect(provider.verify("sk_test_key", ctx)).resolves.toEqual({ ok: true });
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/voices");
});
it("synthesizes one utterance as mp3 bytes", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const provider = new ElevenLabsVoiceProvider();
const clip = await provider.synthesize(
{ text: "Hello there.", voiceId: "abc", apiKey: "sk_test_key" },
ctx,
);
expect(clip.mimeType).toBe("audio/mpeg");
expect([...clip.bytes]).toEqual([1, 2, 3]);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/text-to-speech/abc");
});
it("transcribes through Scribe", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: "hello there" }))),
);
const provider = new ElevenLabsVoiceProvider();
await expect(
provider.transcribe!(
{ audio: new Uint8Array([1]), mimeType: "audio/webm", apiKey: "sk" },
ctx,
),
).resolves.toEqual({ text: "hello there" });
});
});
describe("OpenAIVoiceProvider", () => {
it("returns a static voice catalog without a network round trip", async () => {
const provider = new OpenAIVoiceProvider();
const voices = await provider.listVoices("sk-test", ctx);
expect(voices.some((voice) => voice.id === "alloy")).toBe(true);
});
it("posts speech to /audio/speech", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([9]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const clip = await new OpenAIVoiceProvider().synthesize(
{ text: "Hi", voiceId: "alloy", apiKey: "sk-test" },
ctx,
);
expect(clip.mimeType).toBe("audio/mpeg");
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/audio/speech");
});
it("names Firefox ogg recordings from the mime type", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: "hello" })));
vi.stubGlobal("fetch", fetchMock);
await new OpenAIVoiceProvider().transcribe!(
{ audio: new Uint8Array([1]), mimeType: "audio/ogg;codecs=opus", apiKey: "sk-test" },
ctx,
);
const form = fetchMock.mock.calls[0]?.[1]?.body as FormData;
const file = form.get("file") as File;
expect(file.name).toBe("speech.ogg");
});
});
describe("KokoroVoiceProvider", () => { describe("KokoroVoiceProvider", () => {
it("verifies against the local /v1/audio/voices endpoint", async () => { it("verifies against the local /v1/audio/voices endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
@ -219,222 +123,8 @@ describe("KokoroVoiceProvider", () => {
}); });
}); });
describe("CartesiaVoiceProvider", () => { describe("voice response limits", () => {
it("maps the voices list from either array or { data } payloads", async () => { it.each([["Kokoro", () => new KokoroVoiceProvider(), "af_heart"] as const])(
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: "sonic", name: "Katie" }] })),
),
);
const provider = new CartesiaVoiceProvider();
const voices = await provider.listVoices("sk-test", ctx);
expect(voices).toEqual([{ id: "sonic", label: "Katie", description: undefined }]);
});
it("posts bytes to /tts/bytes", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([4, 5]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const clip = await new CartesiaVoiceProvider().synthesize(
{ text: "Hi", voiceId: "sonic", apiKey: "sk-test" },
ctx,
);
expect([...clip.bytes]).toEqual([4, 5]);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/tts/bytes");
});
});
describe("FishAudioVoiceProvider", () => {
it("lists own then public voice models without duplicates", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [{ _id: "public-id", title: "Public Voice", languages: ["en", "fr"] }],
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [
{ _id: "private-id", title: "Private Voice", description: "My clone" },
{ _id: "public-id", title: "Duplicate" },
{ _id: "failed-id", title: "Failed", state: "failed" },
],
}),
),
);
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices).toEqual([
{ id: "private-id", label: "Private Voice", description: "My clone" },
{ id: "public-id", label: "Duplicate" },
]);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("page_size=100");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("self=true");
});
it("pages through Fish Audio model listings until has_more is false", async () => {
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const page = Number(url.searchParams.get("page_number") ?? "1");
const own = url.searchParams.get("self") === "true";
const prefix = own ? "own" : "pub";
return new Response(
JSON.stringify({
total: 2,
has_more: page < 2,
items: [{ _id: `${prefix}-${page}`, title: `${own ? "Own" : "Public"} ${page}` }],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices.map((voice) => voice.id)).toEqual(["own-1", "own-2", "pub-1", "pub-2"]);
expect(voices.some((voice) => voice.label === "Public 2")).toBe(true);
expect(voices.some((voice) => voice.label === "Own 2")).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(
fetchMock.mock.calls
.map((call) => String(call[0]))
.filter((url) => url.includes("page_number=2")),
).toHaveLength(2);
});
it("keeps paging when total implies more pages without has_more", async () => {
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const page = Number(url.searchParams.get("page_number") ?? "1");
const own = url.searchParams.get("self") === "true";
if (own) {
return new Response(
JSON.stringify({ total: 1, items: [{ _id: "own-only", title: "Own" }] }),
);
}
return new Response(
JSON.stringify({
total: 101,
items:
page === 1
? Array.from({ length: 100 }, (_, index) => ({
_id: `pub-${index + 1}`,
title: `Public ${index + 1}`,
}))
: [{ _id: "pub-101", title: "Public 101" }],
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices.some((voice) => voice.id === "pub-101")).toBe(true);
expect(voices.some((voice) => voice.label === "Public 101")).toBe(true);
expect(
fetchMock.mock.calls.some(
(call) =>
String(call[0]).includes("page_number=2") && !String(call[0]).includes("self=true"),
),
).toBe(true);
});
it("stops catalog crawls at page caps when has_more stays true", async () => {
const publicPages = 5;
const ownPages = 20;
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const page = Number(url.searchParams.get("page_number") ?? "1");
const own = url.searchParams.get("self") === "true";
const prefix = own ? "own" : "pub";
const start = (page - 1) * 100;
return new Response(
JSON.stringify({
total: 1_000_000,
has_more: true,
items: Array.from({ length: 100 }, (_, index) => ({
_id: `${prefix}-${start + index + 1}`,
title: `${own ? "Own" : "Public"} ${start + index + 1}`,
})),
}),
);
});
vi.stubGlobal("fetch", fetchMock);
const voices = await new FishAudioVoiceProvider().listVoices("sk-test", ctx);
expect(voices).toHaveLength(ownPages * 100 + publicPages * 100);
expect(voices[0]?.id).toBe("own-1");
expect(voices.at(ownPages * 100)?.id).toBe("pub-1");
expect(
fetchMock.mock.calls.filter((call) => String(call[0]).includes("self=true")),
).toHaveLength(ownPages);
expect(
fetchMock.mock.calls.filter((call) => !String(call[0]).includes("self=true")),
).toHaveLength(publicPages);
});
it("synthesizes with the Fish Audio TTS contract", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => new Uint8Array([7, 8]).buffer,
});
vi.stubGlobal("fetch", fetchMock);
const clip = await new FishAudioVoiceProvider().synthesize(
{ text: "Hi", voiceId: "voice-id", apiKey: "sk-test" },
ctx,
);
expect(clip.mimeType).toBe("audio/mpeg");
expect([...clip.bytes]).toEqual([7, 8]);
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://api.fish.audio/v1/tts");
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((init.headers as Record<string, string>).model).toBe("s2.1-pro");
expect(JSON.parse(String(init.body))).toMatchObject({
text: "Hi",
reference_id: "voice-id",
format: "mp3",
});
});
it("transcribes through Fish Audio ASR", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ text: "hello" })));
vi.stubGlobal("fetch", fetchMock);
await expect(
new FishAudioVoiceProvider().transcribe!(
{ audio: new Uint8Array([1]), mimeType: "audio/ogg;codecs=opus", apiKey: "sk-test" },
ctx,
),
).resolves.toEqual({ text: "hello" });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://api.fish.audio/v1/asr");
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
const form = init.body as FormData;
expect((form.get("audio") as File).name).toBe("speech.ogg");
expect(form.get("ignore_timestamps")).toBe("true");
});
});
describe("hosted voice response limits", () => {
it.each([
["Kokoro", () => new KokoroVoiceProvider(), "af_heart"],
["ElevenLabs", () => new ElevenLabsVoiceProvider(), "voice"],
["OpenAI", () => new OpenAIVoiceProvider(), "alloy"],
["Cartesia", () => new CartesiaVoiceProvider(), "sonic"],
["Fish Audio", () => new FishAudioVoiceProvider(), "voice"],
])(
"rejects an oversized %s speech response before buffering it", "rejects an oversized %s speech response before buffering it",
async (_name, create, voiceId) => { async (_name, create, voiceId) => {
const cancel = vi.fn().mockResolvedValue(undefined); const cancel = vi.fn().mockResolvedValue(undefined);

View File

@ -1,9 +1,5 @@
import type { VoiceProvider } from "@rakazo/adapter-kit"; import type { VoiceProvider } from "@rakazo/adapter-kit";
import { CartesiaVoiceProvider } from "./cartesia-voice.js";
import { ElevenLabsVoiceProvider } from "./elevenlabs-voice.js";
import { FishAudioVoiceProvider } from "./fish-audio-voice.js";
import { KokoroVoiceProvider } from "./kokoro-voice.js"; import { KokoroVoiceProvider } from "./kokoro-voice.js";
import { OpenAIVoiceProvider } from "./openai-voice.js";
import { SCRIPTED_VOICE_CATALOG_ENTRY, ScriptedVoiceProvider } from "./scripted-voice.js"; import { SCRIPTED_VOICE_CATALOG_ENTRY, ScriptedVoiceProvider } from "./scripted-voice.js";
export const VOICE_CATALOG = [ export const VOICE_CATALOG = [
@ -13,30 +9,6 @@ export const VOICE_CATALOG = [
description: "Free local speech on your server.", description: "Free local speech on your server.",
transcribe: false, transcribe: false,
}, },
{
id: "elevenlabs",
name: "ElevenLabs",
description: "Highest quality and cloning. Flash v2.5 for conversational calls.",
transcribe: true,
},
{
id: "openai",
name: "OpenAI",
description: "Simple TTS plus Whisper-class transcription. Reuse an OpenAI key.",
transcribe: true,
},
{
id: "cartesia",
name: "Cartesia",
description: "Lowest-latency Sonic voices for interruptible calls.",
transcribe: false,
},
{
id: "fish-audio",
name: "Fish Audio",
description: "Voice models, cloning, and expressive speech with optional transcription.",
transcribe: true,
},
] as const; ] as const;
export { SCRIPTED_VOICE_CATALOG_ENTRY }; export { SCRIPTED_VOICE_CATALOG_ENTRY };
@ -69,23 +41,13 @@ export function createVoiceProvider(kind: string): VoiceProvider {
switch (kind) { switch (kind) {
case "kokoro": case "kokoro":
return new KokoroVoiceProvider(); return new KokoroVoiceProvider();
case "elevenlabs":
return new ElevenLabsVoiceProvider();
case "openai":
return new OpenAIVoiceProvider();
case "cartesia":
return new CartesiaVoiceProvider();
case "fish-audio":
return new FishAudioVoiceProvider();
case "scripted": case "scripted":
if (!scriptedVoiceEnabled()) break; if (!scriptedVoiceEnabled()) break;
return new ScriptedVoiceProvider(); return new ScriptedVoiceProvider();
default: default:
break; break;
} }
throw new Error( throw new Error(`Unknown voice provider "${kind}". Use kokoro.`);
`Unknown voice provider "${kind}". Use kokoro | elevenlabs | openai | cartesia | fish-audio.`,
);
} }
export class NoVoiceConfigured extends Error { export class NoVoiceConfigured extends Error {
@ -93,9 +55,7 @@ export class NoVoiceConfigured extends Error {
constructor(reason: "key" | "voice") { constructor(reason: "key" | "voice") {
super( super(
reason === "key" reason === "key" ? "Turn on speaking in Voice settings." : "Pick a voice in Voice settings.",
? "Add a voice provider key in Voice settings to turn on speaking."
: "Pick a voice in Voice settings.",
); );
this.reason = reason; this.reason = reason;
this.name = "NoVoiceConfigured"; this.name = "NoVoiceConfigured";

View File

@ -1,10 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import { MAX_VOICE_JSON_BYTES, readVoiceJson, voiceDeadline } from "./voice-http.js";
MAX_VOICE_JSON_BYTES,
readVoiceJson,
speechUploadName,
voiceDeadline,
} from "./voice-http.js";
describe("voiceDeadline", () => { describe("voiceDeadline", () => {
it("aborts when the client signal aborts", () => { it("aborts when the client signal aborts", () => {
@ -32,16 +27,6 @@ describe("voiceDeadline", () => {
}); });
}); });
describe("speechUploadName", () => {
it("keeps webm recordings as webm even when they name an opus codec", () => {
expect(speechUploadName("audio/webm;codecs=opus")).toBe("speech.webm");
});
it("maps Firefox ogg capture to an ogg filename", () => {
expect(speechUploadName("audio/ogg; codecs=opus")).toBe("speech.ogg");
});
});
describe("readVoiceJson", () => { describe("readVoiceJson", () => {
it("rejects an oversized provider response without waiting for cancellation", async () => { it("rejects an oversized provider response without waiting for cancellation", async () => {
const cancel = vi.fn(() => new Promise<void>(() => undefined)); const cancel = vi.fn(() => new Promise<void>(() => undefined));

View File

@ -41,16 +41,6 @@ function cancelResponseBody(res: Response): void {
} }
} }
export function speechUploadName(mimeType?: string): string {
const mime = (mimeType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
if (mime.includes("webm")) return "speech.webm";
if (mime.includes("ogg") || mime.includes("opus")) return "speech.ogg";
if (mime.includes("wav")) return "speech.wav";
if (mime.includes("mpeg") || mime.includes("mp3")) return "speech.mp3";
if (mime.includes("mp4") || mime.includes("m4a") || mime.includes("aac")) return "speech.m4a";
return "speech.webm";
}
export async function readVoiceJson( export async function readVoiceJson(
res: Response, res: Response,
options: { requireValid?: boolean } = {}, options: { requireValid?: boolean } = {},
@ -94,10 +84,6 @@ function detail(body: unknown): string {
return ""; return "";
} }
export function voiceUnreachable(provider: string): string {
return `Couldn't reach ${provider} to check that key. Check your connection.`;
}
export function voiceHttpError( export function voiceHttpError(
status: number, status: number,
provider: string, provider: string,

View File

@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseVoiceChatCommand } from "./voice-chat-command.js"; import { parseVoiceChatCommand } from "./voice-chat-command.js";
import { defaultVoiceForLocale, matchVoiceByName } from "./voice-labels.js"; import {
defaultVoiceForLocale,
matchVoiceByName,
voiceIdForLocale,
voicesForLocale,
} from "./voice-labels.js";
describe("parseVoiceChatCommand", () => { describe("parseVoiceChatCommand", () => {
it("parses slash voice commands", () => { it("parses slash voice commands", () => {
@ -59,3 +64,32 @@ describe("defaultVoiceForLocale", () => {
expect(defaultVoiceForLocale(voices, "de")?.id).toBe("df_eva"); expect(defaultVoiceForLocale(voices, "de")?.id).toBe("df_eva");
}); });
}); });
describe("voicesForLocale", () => {
const voices = [
{ id: "af_heart", label: "Heart" },
{ id: "bm_george", label: "George" },
{ id: "df_eva", label: "Eva" },
{ id: "dm_bernd", label: "Bernd" },
{ id: "ff_siwis", label: "Siwis" },
];
it("keeps only voices that speak the UI language", () => {
expect(voicesForLocale(voices, "de").map((voice) => voice.id)).toEqual(["df_eva", "dm_bernd"]);
expect(voicesForLocale(voices, "en").map((voice) => voice.id)).toEqual([
"af_heart",
"bm_george",
]);
});
it("keeps every voice when none speaks the language", () => {
expect(voicesForLocale(voices, "pt-BR")).toEqual(voices);
expect(voicesForLocale(voices, "ko")).toEqual(voices);
});
it("switches to a voice of the new language", () => {
expect(voiceIdForLocale(voices, "de", "af_heart")).toBe("df_eva");
expect(voiceIdForLocale(voices, "de", "dm_bernd")).toBe("dm_bernd");
expect(voiceIdForLocale(voices, "en", "df_eva")).toBe("af_heart");
});
});

View File

@ -2,12 +2,33 @@ import type { VoiceInfo } from "@rakazo/contracts";
/** Friendly German presets when Kokoro exposes df_/dm_ voices. */ /** Friendly German presets when Kokoro exposes df_/dm_ voices. */
export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [ export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [
{ id: "df_eva", label: "Eva", description: "Deutsch · warm" }, { id: "df_eva", label: "Eva", description: "warm" },
{ id: "dm_bernd", label: "Bernd", description: "Deutsch · klar" }, { id: "dm_bernd", label: "Bernd", description: "klar" },
{ id: "df_victoria", label: "Victoria", description: "Deutsch · freundlich" }, { id: "df_victoria", label: "Victoria", description: "freundlich" },
{ id: "dm_martin", label: "Martin", description: "Deutsch · ruhig" }, { id: "dm_martin", label: "Martin", description: "ruhig" },
]; ];
/**
* Kokoro encodes a voice's language in the first letter of its id (`df_eva` is German,
* `af_heart` American English, `bf_emma` British English).
*/
const KOKORO_LANGUAGE_PREFIXES: Record<string, string> = {
de: "d",
en: "ab",
es: "e",
fr: "f",
hi: "h",
it: "i",
ja: "j",
pt: "p",
zh: "z",
};
function voiceLanguagePrefixes(locale: string): string | undefined {
const language = locale.toLowerCase().split("-")[0] ?? "";
return KOKORO_LANGUAGE_PREFIXES[language];
}
const PRESET_BY_ID = new Map(GERMAN_VOICE_PRESETS.map((voice) => [voice.id, voice])); const PRESET_BY_ID = new Map(GERMAN_VOICE_PRESETS.map((voice) => [voice.id, voice]));
/** Strip engine prefix (df_eva → Eva) for display when no preset exists. */ /** Strip engine prefix (df_eva → Eva) for display when no preset exists. */
@ -23,21 +44,34 @@ export function voiceDescription(id: string, fallback?: string): string | undefi
return PRESET_BY_ID.get(id)?.description ?? fallback; return PRESET_BY_ID.get(id)?.description ?? fallback;
} }
/** Prefer German voices when UI locale is de. */ /**
export function preferVoicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[] { * Only the voices that speak the UI language. Ids without a Kokoro language prefix are kept,
if (locale !== "de") return voices; * and every voice is kept when none speaks the language, so the picker never ends up empty.
const german = voices.filter((voice) => /^d[fm]_/.test(voice.id)); */
if (german.length === 0) return voices; export function voicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[] {
const rest = voices.filter((voice) => !/^d[fm]_/.test(voice.id)); const prefixes = voiceLanguagePrefixes(locale);
return [...german, ...rest]; if (!prefixes) return voices;
const matching = voices.filter((voice) => {
const prefix = /^([a-z])[fm]_/.exec(voice.id)?.[1];
return !prefix || prefixes.includes(prefix);
});
return matching.length > 0 ? matching : voices;
} }
export function defaultVoiceForLocale(voices: VoiceInfo[], locale: string): VoiceInfo | undefined { export function defaultVoiceForLocale(voices: VoiceInfo[], locale: string): VoiceInfo | undefined {
const ordered = preferVoicesForLocale(voices, locale); const listed = voicesForLocale(voices, locale);
if (locale === "de") { return listed.find((voice) => voice.id === "df_eva") ?? listed[0];
return ordered.find((voice) => voice.id === "df_eva") ?? ordered[0]; }
}
return ordered[0]; /** The voice to keep using for this UI language: the current one when it fits, else the default. */
export function voiceIdForLocale(
voices: VoiceInfo[],
locale: string,
currentId: string,
): string | undefined {
const listed = voicesForLocale(voices, locale);
if (listed.some((voice) => voice.id === currentId)) return currentId;
return defaultVoiceForLocale(voices, locale)?.id;
} }
/** Match a user-provided name (Eva, bernd, df_eva) against available voices. */ /** Match a user-provided name (Eva, bernd, df_eva) against available voices. */

View File

@ -217,8 +217,8 @@ describeWithDatabase("API authorization and resource isolation", () => {
["voice/catalog"], ["voice/catalog"],
["voice/status"], ["voice/status"],
["voice/credentials"], ["voice/credentials"],
["voice/connect", { provider: "elevenlabs", apiKey: "not-a-real-key" }], ["voice/connect", { provider: "kokoro", apiKey: "not-a-real-key" }],
["voice/disconnect", { provider: "elevenlabs" }], ["voice/disconnect", { provider: "kokoro" }],
["voice/setVoice", { voiceId: "missing-voice" }], ["voice/setVoice", { voiceId: "missing-voice" }],
["voice/voices", {}], ["voice/voices", {}],
["voice/prepare", { text: "Nope" }], ["voice/prepare", { text: "Nope" }],

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { loadRootEnv } from "@rakazo/core/node/load-root-env"; import { loadRootEnv } from "@rakazo/core/node/load-root-env";
import { PostgreSqlContainer } from "@testcontainers/postgresql"; import { PostgreSqlContainer } from "@testcontainers/postgresql";
import type { createApp } from "../../../../apps/api/src/app.ts"; import type { createApp } from "../../../../apps/api/src/app.ts";
import { startFakeKokoro } from "../fake-kokoro.js";
import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js"; import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js";
import { runProcess } from "./process.js"; import { runProcess } from "./process.js";
@ -48,6 +49,7 @@ async function main() {
const reportDir = path.resolve("test-report", mode); const reportDir = path.resolve("test-report", mode);
await mkdir(reportDir, { recursive: true }); await mkdir(reportDir, { recursive: true });
const container = await new PostgreSqlContainer("postgres:16-alpine").start(); const container = await new PostgreSqlContainer("postgres:16-alpine").start();
const kokoro = await startFakeKokoro();
try { try {
const databaseUrl = container.getConnectionUri(); const databaseUrl = container.getConnectionUri();
const apiPort = Number(process.env.API_PORT ?? 3110); const apiPort = Number(process.env.API_PORT ?? 3110);
@ -79,6 +81,7 @@ async function main() {
process.env.SIGNUPS_ENABLED = "true"; process.env.SIGNUPS_ENABLED = "true";
process.env.SIGNUP_ALLOWLIST = ""; process.env.SIGNUP_ALLOWLIST = "";
process.env.CI = "1"; process.env.CI = "1";
process.env.KOKORO_BASE_URL = kokoro.baseUrl;
execSync("pnpm --filter @rakazo/db generate", { stdio: "inherit", env: process.env }); execSync("pnpm --filter @rakazo/db generate", { stdio: "inherit", env: process.env });
execSync("pnpm --filter @rakazo/db exec prisma migrate deploy", { execSync("pnpm --filter @rakazo/db exec prisma migrate deploy", {
@ -339,6 +342,7 @@ async function main() {
} }
} }
} finally { } finally {
await kokoro.close();
await container.stop().catch(() => undefined); await container.stop().catch(() => undefined);
} }
} }

View File

@ -0,0 +1,33 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
/**
* Stands in for the local Kokoro speech server so onboarding's voice step never depends on
* whatever happens to listen on the default port of the machine running the suite.
*/
export async function startFakeKokoro(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
const server = createServer((req, res) => {
if (req.method === "GET" && req.url === "/v1/audio/voices") {
res.writeHead(200, { "content-type": "application/json" });
// An empty list makes the adapter fall back to its curated presets.
res.end(JSON.stringify({ voices: [] }));
return;
}
if (req.method === "POST" && req.url === "/v1/audio/speech") {
req.resume();
req.on("end", () => {
// An MPEG frame header is enough for callers that only check the content type.
res.writeHead(200, { "content-type": "audio/mpeg" });
res.end(Buffer.from([0xff, 0xfb, 0x90, 0x64, 0, 0, 0, 0]));
});
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve) => server.close(() => resolve())),
};
}