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:
parent
e55207f5d2
commit
bc7353f1f2
@ -49,7 +49,7 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
|
||||
- TypeScript monorepo (pnpm + Turborepo)
|
||||
- React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile)
|
||||
- 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
|
||||
|
||||
|
||||
@ -20,8 +20,8 @@ function setup() {
|
||||
secret: { findMany: vi.fn(async () => []) },
|
||||
bot: { findMany: vi.fn(async () => []) },
|
||||
spaceVoicePreference: {
|
||||
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
|
||||
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
|
||||
findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })),
|
||||
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
|
||||
},
|
||||
spaceMemoryConfig: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
@ -104,12 +104,12 @@ describe("consent grants", () => {
|
||||
it("discloses only the selected voice provider before a voice action", async () => {
|
||||
const { deps, actor } = setup();
|
||||
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
|
||||
{ credential: { provider: "openai" } },
|
||||
{ credential: { provider: "elevenlabs" } },
|
||||
{ credential: { provider: "kokoro" } },
|
||||
{ credential: { provider: "scripted" } },
|
||||
] as never);
|
||||
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
||||
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();
|
||||
});
|
||||
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(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 () => {
|
||||
const { deps, actor } = setup();
|
||||
|
||||
@ -13,18 +13,18 @@ import {
|
||||
|
||||
describe("toVoiceStatus", () => {
|
||||
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,
|
||||
ready: false,
|
||||
transcribe: true,
|
||||
provider: "elevenlabs",
|
||||
transcribe: false,
|
||||
provider: "kokoro",
|
||||
voiceId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("is ready once a voice is chosen", () => {
|
||||
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
|
||||
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
|
||||
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true);
|
||||
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false);
|
||||
});
|
||||
|
||||
it("is off when nothing is connected", () => {
|
||||
|
||||
@ -96,7 +96,8 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
|
||||
const cred = provider
|
||||
? await findVoiceCredential(deps.prisma, actor, provider)
|
||||
: 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({
|
||||
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
|
||||
});
|
||||
|
||||
@ -46,6 +46,7 @@ import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
|
||||
import { registerPushToken } from "../lib/push";
|
||||
import type { AccountUiLocale } 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. */
|
||||
export default function Account() {
|
||||
@ -183,6 +184,7 @@ export default function Account() {
|
||||
setLocaleSaving(true);
|
||||
setLocaleError(null);
|
||||
void setUiLocale(code)
|
||||
.then((activated) => alignVoiceWithLocale(activated).catch(() => undefined))
|
||||
.catch(() => {
|
||||
setLocaleError(t("Could not change language"));
|
||||
})
|
||||
|
||||
@ -1,76 +1,51 @@
|
||||
import { voicesForLocale } from "@rakazo/core";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { NativeSymbol } from "../components/native-symbol";
|
||||
import { rpc } from "../lib/api";
|
||||
import { mobileTokens } from "../lib/appearance";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
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 = {
|
||||
id: string;
|
||||
provider: string;
|
||||
voiceId: string;
|
||||
};
|
||||
type VoiceStatus = {
|
||||
configured: boolean;
|
||||
ready: boolean;
|
||||
provider: string | null;
|
||||
voiceId: string;
|
||||
};
|
||||
type VoiceInfo = { id: string; label: string; description?: string };
|
||||
|
||||
export default function VoiceSettings() {
|
||||
const styles = useThemedStyles(createVoiceStyles);
|
||||
const { t } = useI18n();
|
||||
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]);
|
||||
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
|
||||
const [status, setStatus] = useState<VoiceStatus | null>(null);
|
||||
const { locale, t } = useI18n();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [provider, setProvider] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [voiceId, setVoiceId] = useState("");
|
||||
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 [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (nextProvider?: string) => {
|
||||
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([
|
||||
rpc<VoiceCatalogEntry[]>("voice/catalog"),
|
||||
rpc<VoiceCredential[]>("voice/credentials"),
|
||||
rpc<VoiceStatus>("voice/status"),
|
||||
]);
|
||||
const selected = nextProvider || nextStatus.provider || nextCatalog[0]?.id || "";
|
||||
setCatalog(nextCatalog);
|
||||
setCredentials(nextCredentials);
|
||||
setStatus(nextStatus);
|
||||
setProvider(selected);
|
||||
const cred = nextCredentials.find((entry) => entry.provider === selected);
|
||||
const load = useCallback(async () => {
|
||||
await alignVoiceWithLocale(locale).catch(() => undefined);
|
||||
const credentials = await rpc<VoiceCredential[]>("voice/credentials");
|
||||
const cred = credentials.find((entry) => entry.provider === LOCAL_VOICE_PROVIDER);
|
||||
setConnected(Boolean(cred));
|
||||
setVoiceId(cred?.voiceId ?? "");
|
||||
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 {
|
||||
setVoices([]);
|
||||
}
|
||||
}, []);
|
||||
}, [locale]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@ -83,24 +58,12 @@ export default function VoiceSettings() {
|
||||
}, [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() {
|
||||
if (!selected || !canConnect) return;
|
||||
setPending("connect");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc("voice/connect", {
|
||||
provider: selected.id,
|
||||
apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(),
|
||||
voiceId: voiceId || undefined,
|
||||
});
|
||||
setApiKey("");
|
||||
await load(selected.id);
|
||||
setNotice(t("Connected {name}.", { name: selected.name }));
|
||||
await rpc("voice/connect", { provider: LOCAL_VOICE_PROVIDER, apiKey: KOKORO_LOCAL_TOKEN });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not connect"));
|
||||
} finally {
|
||||
@ -109,14 +72,11 @@ export default function VoiceSettings() {
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (!credential) return;
|
||||
setPending("disconnect");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await rpc("voice/disconnect", { provider: credential.provider });
|
||||
setApiKey("");
|
||||
await load(credential.provider);
|
||||
await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not disconnect"));
|
||||
} finally {
|
||||
@ -127,173 +87,145 @@ export default function VoiceSettings() {
|
||||
async function chooseVoice(nextVoiceId: string) {
|
||||
setVoiceId(nextVoiceId);
|
||||
setPending("voice");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id });
|
||||
await load(selected?.id);
|
||||
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: LOCAL_VOICE_PROVIDER });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not save that voice"));
|
||||
await load().catch(() => undefined);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function testVoice() {
|
||||
setPending("test");
|
||||
async function playVoice(voice: VoiceInfo) {
|
||||
setPlaying(voice.id);
|
||||
setError(null);
|
||||
try {
|
||||
const ready = await speakText(t("Hi, this is how I'll sound when I read replies out loud."));
|
||||
if (!ready) {
|
||||
throw new Error(t("Connect a voice provider first."));
|
||||
}
|
||||
await speakText(t("Hi, this is how I'll sound when I read replies out loud."), {
|
||||
voiceId: voice.id,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("Could not play a sample"));
|
||||
} finally {
|
||||
setPending(null);
|
||||
setPlaying((current) => (current === voice.id ? null : current));
|
||||
}
|
||||
}
|
||||
|
||||
const busy = pending !== null;
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={["bottom"]} style={styles.screen}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{loading ? <ActivityIndicator color={native.secondaryLabel} /> : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{notice ? <Text style={styles.notice}>{notice}</Text> : null}
|
||||
{catalog.map((entry) => {
|
||||
const connected = credentials.some((cred) => cred.provider === entry.id);
|
||||
return (
|
||||
<Pressable
|
||||
key={entry.id}
|
||||
disabled={pending !== null}
|
||||
onPress={() => {
|
||||
setProvider(entry.id);
|
||||
setPending("voice");
|
||||
void load(entry.id)
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("Could not load voice settings"),
|
||||
),
|
||||
)
|
||||
.finally(() => setPending(null));
|
||||
}}
|
||||
style={[
|
||||
styles.card,
|
||||
provider === entry.id && styles.cardActive,
|
||||
pending !== null && styles.disabled,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.cardTitle}>{entry.name}</Text>
|
||||
<Text style={styles.cardMeta}>
|
||||
{connected
|
||||
? t("Connected")
|
||||
: entry.transcribe
|
||||
? t("Speak + transcribe")
|
||||
: t("Speak only")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{selected ? (
|
||||
<>
|
||||
{isKokoro ? (
|
||||
<Text style={styles.help}>
|
||||
{t("Local voice runs on your server. Tap Connect when it is ready.")}
|
||||
</Text>
|
||||
) : (
|
||||
<TextInput
|
||||
accessibilityLabel={t("API key")}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
importantForAutofill="no"
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
placeholder={credential ? t("Paste a replacement key") : t("Paste your API key")}
|
||||
placeholderTextColor={native.tertiaryLabel}
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
textContentType="none"
|
||||
/>
|
||||
)}
|
||||
<Pressable
|
||||
disabled={pending !== null || !canConnect}
|
||||
onPress={() => void connect()}
|
||||
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>
|
||||
{credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{credential ? (
|
||||
<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}
|
||||
</>
|
||||
{!loading && !connected ? (
|
||||
<Pressable
|
||||
disabled={busy}
|
||||
onPress={() => void connect()}
|
||||
style={[styles.button, busy && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{voices.length ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
accessibilityRole="radiogroup"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + CARD_GAP}
|
||||
decelerationRate="fast"
|
||||
style={styles.carousel}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{voices.map((voice) => {
|
||||
const active = voiceId === voice.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={voice.id}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: active, disabled: busy }}
|
||||
disabled={busy}
|
||||
onPress={() => void chooseVoice(voice.id)}
|
||||
style={[styles.card, active && styles.cardActive]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text numberOfLines={1} style={styles.cardTitle}>
|
||||
{voice.label}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("Play {name}", { name: voice.label })}
|
||||
disabled={playing !== null}
|
||||
hitSlop={8}
|
||||
onPress={() => void playVoice(voice)}
|
||||
style={styles.play}
|
||||
>
|
||||
{playing === voice.id ? (
|
||||
<ActivityIndicator size="small" color={native.secondaryLabel} />
|
||||
) : (
|
||||
<NativeSymbol ios="play.fill" android="play" size={14} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
{voice.description ? (
|
||||
<Text style={styles.cardMeta}>{voice.description}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{connected ? (
|
||||
<Pressable
|
||||
disabled={busy}
|
||||
onPress={() => void disconnect()}
|
||||
style={[styles.secondary, busy && styles.disabled]}
|
||||
>
|
||||
<Text style={styles.secondaryLabel}>
|
||||
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 160;
|
||||
const CARD_GAP = 12;
|
||||
|
||||
function createVoiceStyles() {
|
||||
const tokens = mobileTokens();
|
||||
return StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: native.page },
|
||||
content: { padding: 20, gap: 10 },
|
||||
error: { color: tokens.destructive, marginBottom: 8 },
|
||||
notice: { color: tokens.success, marginBottom: 8 },
|
||||
content: { paddingVertical: 20, gap: 10 },
|
||||
error: { color: tokens.destructive, marginBottom: 8, marginHorizontal: 20 },
|
||||
carousel: { flexGrow: 0 },
|
||||
carouselContent: { paddingHorizontal: 20, gap: CARD_GAP },
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
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,
|
||||
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: {
|
||||
marginTop: 8,
|
||||
marginHorizontal: 20,
|
||||
backgroundColor: tokens.primary,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
@ -301,17 +233,6 @@ function createVoiceStyles() {
|
||||
},
|
||||
disabled: { opacity: 0.4 },
|
||||
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" },
|
||||
secondaryLabel: { color: native.secondaryLabel, fontSize: 15 },
|
||||
});
|
||||
|
||||
@ -667,6 +667,7 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
Peach: "Персик",
|
||||
Penguin: "Пингвин",
|
||||
Pink: "Розовый",
|
||||
"Play {name}": "Воспроизвести {name}",
|
||||
Playful: "Игривость",
|
||||
"Reading glasses": "Очки для чтения",
|
||||
Robot: "Робот",
|
||||
|
||||
@ -651,6 +651,7 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
Peach: "桃色",
|
||||
Penguin: "企鹅",
|
||||
Pink: "粉色",
|
||||
"Play {name}": "播放 {name}",
|
||||
Playful: "俏皮",
|
||||
"Reading glasses": "老花镜",
|
||||
Robot: "机器人",
|
||||
|
||||
@ -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 { promptAiConsent } from "./ai-consent";
|
||||
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;
|
||||
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> {
|
||||
const requestContext = await captureApiRequestContext();
|
||||
const prepared = await rpc<{ ready: boolean; utterances: string[] }>(
|
||||
|
||||
@ -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";
|
||||
|
||||
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 userName = `Voice ${stamp}`;
|
||||
await signup(page, `voice-${stamp}@rakazo.test`, "password12", userName);
|
||||
await signup(page, `voice-${stamp}@rakazo.test`, "password12", `Voice ${stamp}`);
|
||||
await completeOnboarding(page);
|
||||
|
||||
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 }),
|
||||
).toHaveCount(1);
|
||||
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", {
|
||||
text: "Hello there.",
|
||||
});
|
||||
expect(preparedOff.ready).toBe(false);
|
||||
|
||||
await openUserSettings(page, "voice");
|
||||
await expect(page.getByTestId("voice-settings")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Other voice providers" }).click();
|
||||
await page.getByLabel("Provider", { exact: true }).selectOption("scripted");
|
||||
const apiKeyInput = page.getByPlaceholder(/Paste your API key/);
|
||||
await expect(apiKeyInput).toHaveAttribute("autocomplete", "new-password");
|
||||
await apiKeyInput.fill("fake-scripted-voice-key");
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(page.getByText("Connected Scripted.")).toBeVisible();
|
||||
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",
|
||||
{},
|
||||
const voice = await turnOnSpeaking(page);
|
||||
const cards = voice.getByTestId("voice-card");
|
||||
// English UI lists only the English voices of the local server.
|
||||
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(1);
|
||||
await expect(cards.filter({ hasText: "George" })).toHaveCount(1);
|
||||
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(0);
|
||||
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
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 expect(page.getByTestId("user-settings")).toHaveCount(0);
|
||||
|
||||
const composer = page.getByPlaceholder(/Message/);
|
||||
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");
|
||||
// A reply can render more than one text bubble; speak the latest one.
|
||||
const speakReply = page.getByRole("button", { name: "Speak this reply" }).last();
|
||||
await expect(speakReply).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(speakReply).toBeVisible({ timeout: 30_000 });
|
||||
const replySpoken = page.waitForResponse(
|
||||
(response) => response.url().includes("/api/voice/speak") && response.ok(),
|
||||
);
|
||||
await speakReply.click();
|
||||
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
|
||||
.getByTestId("composer-bar")
|
||||
.getByRole("button", { name: "Voice", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("call-view")).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 captureScreenshot(page, testInfo, "voice-stage");
|
||||
await page.getByRole("button", { name: "Hang up" }).click();
|
||||
await expect(page.getByTestId("call-view")).toHaveCount(0);
|
||||
|
||||
await openUserSettings(page, "voice");
|
||||
const settings = page.getByTestId("voice-settings");
|
||||
await settings.getByRole("button", { name: "Other voice providers" }).click();
|
||||
await settings.getByPlaceholder(/Paste a replacement key/).fill("leftover-voice-key");
|
||||
await settings.getByRole("button", { name: "Disconnect", exact: true }).click();
|
||||
await expect(settings.getByRole("button", { name: "Disconnect", exact: true })).toHaveCount(0);
|
||||
await expect(settings.getByRole("button", { name: "Connect", exact: true })).toBeVisible();
|
||||
await expect(settings.getByLabel("API key", { exact: true })).toHaveValue("");
|
||||
await expect(rpc<Array<{ provider: string }>>(page, "voice/credentials", {})).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("German lists only German voices and switches the saved voice to one", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `voice-de-${stamp}@rakazo.test`, "password12", `Stimme ${stamp}`);
|
||||
await completeOnboarding(page);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
@ -37,7 +37,7 @@ export function OnboardingVoiceStep({
|
||||
const listed = await rpc.voice.voices({ provider: "kokoro" });
|
||||
if (cancelled) return;
|
||||
const locale = readUiLocale();
|
||||
const ordered = preferVoicesForLocale(listed, locale).slice(0, 6);
|
||||
const ordered = voicesForLocale(listed, locale).slice(0, 6);
|
||||
setVoiceOptions(ordered);
|
||||
const defaultVoice = defaultVoiceForLocale(listed, locale);
|
||||
if (defaultVoice) onSelectedVoiceIdChange(defaultVoice.id);
|
||||
|
||||
@ -166,7 +166,7 @@ export class Speaker {
|
||||
{ signal, context: { spaceId } },
|
||||
);
|
||||
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 ?? [];
|
||||
}
|
||||
|
||||
17
apps/web/src/lib/voice-locale.ts
Normal file
17
apps/web/src/lib/voice-locale.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API-Schlüssel"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "Passwort bestätigen"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "Verbinden"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "Verbunden"
|
||||
msgid "Connected · {0}"
|
||||
msgstr "Verbunden · {0}"
|
||||
|
||||
#. placeholder {0}: selectedAdvanced.name
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connected {0}."
|
||||
msgstr "{0} verbunden."
|
||||
|
||||
#. placeholder {0}: selected.label
|
||||
#. placeholder {0}: selectedLabelRef.current ?? "this model"
|
||||
#: 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/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "Verbindung wird hergestellt…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "Verbindung mit {0} fehlgeschlagen"
|
||||
msgid "Could not connect this provider"
|
||||
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
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr "Verbindung zum Computerbildschirm fehlgeschlagen"
|
||||
@ -1604,10 +1592,6 @@ msgstr "Erinnerungsanbieter konnte nicht getrennt werden"
|
||||
msgid "Could not disconnect OAuth"
|
||||
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
|
||||
msgid "Could not dismiss"
|
||||
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."
|
||||
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/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr "Direktes MCP"
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "Trennen"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "Verbindung wird getrennt…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr "Kopfhörer"
|
||||
msgid "Health"
|
||||
msgstr "Gesundheit"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "Beispiel anhören"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr "Herz"
|
||||
@ -2785,10 +2759,6 @@ msgstr "Ich höre zu — was hat Priorität?"
|
||||
msgid "Identity"
|
||||
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
|
||||
msgid "Ignore"
|
||||
msgstr "Ignorieren"
|
||||
@ -3792,10 +3762,6 @@ msgstr "Organisations-API-Schlüssel"
|
||||
msgid "Other model…"
|
||||
msgstr "Anderes Modell…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr "Andere Stimmanbieter"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr "Seite"
|
||||
@ -3832,18 +3798,10 @@ msgstr "Passwort aktualisiert"
|
||||
msgid "Passwords do not match"
|
||||
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
|
||||
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."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "Füge deinen API-Schlüssel ein"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "Anheften"
|
||||
msgid "Pink"
|
||||
msgstr "Rosa"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr "{0} anhören"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr "Verspielt"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "Wiedergabe…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr "Zeig mir das Problem."
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr "Projekte"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Anbieter"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "Erneut öffnen"
|
||||
msgid "Replace API key"
|
||||
msgstr "API-Schlüssel ersetzen"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "Schlüssel ersetzen"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "Antworten"
|
||||
@ -4871,6 +4825,11 @@ msgstr "Schritte"
|
||||
msgid "Stop"
|
||||
msgstr "Stoppen"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr "{0} stoppen"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "Vorlesen stoppen"
|
||||
|
||||
@ -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/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API key"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "Confirm password"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "Connect"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "Connected"
|
||||
msgid "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}: selectedLabelRef.current ?? "this model"
|
||||
#: 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/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "Connecting…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "Could not connect {0}"
|
||||
msgid "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
|
||||
msgid "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"
|
||||
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
|
||||
msgid "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."
|
||||
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/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr "Direct MCP"
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "Disconnect"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "Disconnecting…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr "Headphones"
|
||||
msgid "Health"
|
||||
msgstr "Health"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "Hear a sample"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr "Heart"
|
||||
@ -2785,10 +2759,6 @@ msgstr "I'm listening — what's the priority?"
|
||||
msgid "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
|
||||
msgid "Ignore"
|
||||
msgstr "Ignore"
|
||||
@ -3792,10 +3762,6 @@ msgstr "Organization API key"
|
||||
msgid "Other model…"
|
||||
msgstr "Other model…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr "Other voice providers"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr "Page"
|
||||
@ -3832,18 +3798,10 @@ msgstr "Password updated"
|
||||
msgid "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
|
||||
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."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "Paste your API key"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "Pin"
|
||||
msgid "Pink"
|
||||
msgstr "Pink"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr "Play {0}"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr "Playful"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "Playing…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr "Point me at the problem."
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr "Projects"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Provider"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "Reopen"
|
||||
msgid "Replace API key"
|
||||
msgstr "Replace API key"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "Replace key"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "Reply"
|
||||
@ -4871,6 +4825,11 @@ msgstr "Steps"
|
||||
msgid "Stop"
|
||||
msgstr "Stop"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr "Stop {0}"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "Stop speaking"
|
||||
|
||||
@ -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/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "Clave de API"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "Confirmar contraseña"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "Conectar"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "Conectado"
|
||||
msgid "Connected · {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}: selectedLabelRef.current ?? "this model"
|
||||
#: 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/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "Conectando…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "No se pudo conectar {0}"
|
||||
msgid "Could not connect this provider"
|
||||
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
|
||||
msgid "Could not connect to the computer screen"
|
||||
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"
|
||||
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
|
||||
msgid "Could not dismiss"
|
||||
msgstr ""
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
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/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr ""
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "Desconectar"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "Desconectando…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "Escuchar una muestra"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
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
|
||||
msgid "Ignore"
|
||||
msgstr ""
|
||||
@ -3792,10 +3762,6 @@ msgstr "Clave de API de organización"
|
||||
msgid "Other model…"
|
||||
msgstr "Otro modelo…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "Contraseña actualizada"
|
||||
msgid "Passwords do not match"
|
||||
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
|
||||
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."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "Pega tu clave de API"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "Fijar"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "Reproduciendo…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Proveedor"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "Reabrir"
|
||||
msgid "Replace API key"
|
||||
msgstr "Reemplazar clave de API"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "Reemplazar clave"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "Responder"
|
||||
@ -4871,6 +4825,11 @@ msgstr "Pasos"
|
||||
msgid "Stop"
|
||||
msgstr "Detener"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "Dejar de hablar"
|
||||
|
||||
@ -635,7 +635,6 @@ msgstr ""
|
||||
#: src/lib/localized-provider-hint.ts
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API कुंजी"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "पासवर्ड की पुष्टि करें"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "कनेक्ट करें"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "कनेक्टेड"
|
||||
msgid "Connected · {0}"
|
||||
msgstr "कनेक्टेड · {0}"
|
||||
|
||||
#. placeholder {0}: selectedAdvanced.name
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connected {0}."
|
||||
msgstr "{0} कनेक्ट किया गया।"
|
||||
|
||||
#. placeholder {0}: selected.label
|
||||
#. placeholder {0}: selectedLabelRef.current ?? "this model"
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -1424,7 +1417,6 @@ msgstr "कनेक्टेड। इसके टूल आपके अग
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "कनेक्ट हो रहा है…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "{0} कनेक्ट नहीं हो सका"
|
||||
msgid "Could not connect this provider"
|
||||
msgstr "यह प्रोवाइडर कनेक्ट नहीं हो सका"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not connect this voice provider"
|
||||
msgstr "यह आवाज़ प्रोवाइडर कनेक्ट नहीं हो सका"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr ""
|
||||
@ -1604,10 +1592,6 @@ msgstr "मेमोरी प्रोवाइडर डिस्कनेक
|
||||
msgid "Could not disconnect OAuth"
|
||||
msgstr "OAuth डिस्कनेक्ट नहीं हो सका"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not disconnect this voice provider"
|
||||
msgstr "यह आवाज़ प्रोवाइडर डिस्कनेक्ट नहीं हो सका"
|
||||
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Could not dismiss"
|
||||
msgstr ""
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not play a test clip"
|
||||
msgstr "टेस्ट क्लिप नहीं चलाई जा सकी"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr ""
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "डिस्कनेक्ट करें"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "डिस्कनेक्ट हो रहा है…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "एक नमूना सुनें"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "If you heard that, voice is ready."
|
||||
msgstr "अगर आपने वह सुना, तो आवाज़ तैयार है।"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Ignore"
|
||||
msgstr ""
|
||||
@ -3792,10 +3762,6 @@ msgstr "संगठन API कुंजी"
|
||||
msgid "Other model…"
|
||||
msgstr "अन्य मॉडल…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "पासवर्ड अपडेट हो गया"
|
||||
msgid "Passwords do not match"
|
||||
msgstr "पासवर्ड मेल नहीं खाते"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste a replacement key"
|
||||
msgstr "बदलने के लिए नई कुंजी पेस्ट करें"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
|
||||
msgstr "अपने सर्वर से OpenAI-संगत पता पेस्ट करें। ज़रूरत होने पर Rakazo /v1 जोड़ देता है।"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "अपनी API कुंजी पेस्ट करें"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "पिन करें"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "प्ले हो रहा है…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "प्रोवाइडर"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "पुनः खोलें"
|
||||
msgid "Replace API key"
|
||||
msgstr "API कुंजी बदलें"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "कुंजी बदलें"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "उत्तर दें"
|
||||
@ -4871,6 +4825,11 @@ msgstr "चरण"
|
||||
msgid "Stop"
|
||||
msgstr "रोकें"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "बोलना रोकें"
|
||||
|
||||
@ -635,7 +635,6 @@ msgstr "API가 다시 작동하지만 업데이트가 완료되지 않았습니
|
||||
#: src/lib/localized-provider-hint.ts
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API 키"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "비밀번호 확인"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "연결"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "연결됨"
|
||||
msgid "Connected · {0}"
|
||||
msgstr "연결됨 · {0}"
|
||||
|
||||
#. placeholder {0}: selectedAdvanced.name
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connected {0}."
|
||||
msgstr "{0}에 연결했습니다."
|
||||
|
||||
#. placeholder {0}: selected.label
|
||||
#. placeholder {0}: selectedLabelRef.current ?? "this model"
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -1424,7 +1417,6 @@ msgstr "연결되었습니다. 다음 메시지부터 이 도구를 사용할
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "연결 중…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "{0}에 연결하지 못했습니다."
|
||||
msgid "Could not connect this provider"
|
||||
msgstr "AI 서비스에 연결하지 못했습니다."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not connect this voice provider"
|
||||
msgstr "음성 서비스에 연결하지 못했습니다."
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr ""
|
||||
@ -1604,10 +1592,6 @@ msgstr "기억 서비스 연결을 해제하지 못했습니다."
|
||||
msgid "Could not disconnect OAuth"
|
||||
msgstr "OAuth 연결을 해제하지 못했습니다."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not disconnect this voice provider"
|
||||
msgstr "음성 서비스 연결을 해제하지 못했습니다."
|
||||
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Could not dismiss"
|
||||
msgstr ""
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not play a test clip"
|
||||
msgstr "샘플 음성을 재생하지 못했습니다."
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr ""
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "연결 해제"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "연결 해제 중…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "샘플 듣기"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "If you heard that, voice is ready."
|
||||
msgstr "샘플이 들렸다면 음성 설정이 완료되었습니다."
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Ignore"
|
||||
msgstr ""
|
||||
@ -3792,10 +3762,6 @@ msgstr "조직 API 키"
|
||||
msgid "Other model…"
|
||||
msgstr "다른 모델 직접 입력…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "비밀번호가 업데이트되었습니다"
|
||||
msgid "Passwords do not match"
|
||||
msgstr "비밀번호가 일치하지 않습니다"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste a replacement key"
|
||||
msgstr "새 API 키를 붙여넣으세요"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
|
||||
msgstr "서버의 OpenAI 호환 주소를 붙여넣으세요. 필요하면 Rakazo가 /v1을 추가합니다."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "API 키를 붙여넣으세요"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "고정"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "재생 중…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "서비스"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "다시 열기"
|
||||
msgid "Replace API key"
|
||||
msgstr "API 키 교체"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "키 교체"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "답장"
|
||||
@ -4871,6 +4825,11 @@ msgstr "실행 단계"
|
||||
msgid "Stop"
|
||||
msgstr "중지"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "읽기 중지"
|
||||
|
||||
@ -635,7 +635,6 @@ msgstr ""
|
||||
#: src/lib/localized-provider-hint.ts
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "Chave de API"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "Confirmar senha"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "Conectar"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "Conectado"
|
||||
msgid "Connected · {0}"
|
||||
msgstr "Conectado · {0}"
|
||||
|
||||
#. placeholder {0}: selectedAdvanced.name
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connected {0}."
|
||||
msgstr "Conectado {0}."
|
||||
|
||||
#. placeholder {0}: selected.label
|
||||
#. placeholder {0}: selectedLabelRef.current ?? "this model"
|
||||
#: 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/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "Conectando…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "Não foi possível conectar o {0}"
|
||||
msgid "Could not connect this provider"
|
||||
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
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr ""
|
||||
@ -1604,10 +1592,6 @@ msgstr "Não foi possível desconectar o provedor de memória"
|
||||
msgid "Could not disconnect 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
|
||||
msgid "Could not dismiss"
|
||||
msgstr ""
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
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/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr ""
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "Desconectar"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "Desconectando…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "Ouvir uma amostra"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
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
|
||||
msgid "Ignore"
|
||||
msgstr ""
|
||||
@ -3792,10 +3762,6 @@ msgstr "Chave da API da organização"
|
||||
msgid "Other model…"
|
||||
msgstr "Outro modelo..."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "Senha atualizada"
|
||||
msgid "Passwords do not match"
|
||||
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
|
||||
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."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "Cole sua chave API"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "Fixar"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "Reproduzindo"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Provedor"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "Reabrir"
|
||||
msgid "Replace API key"
|
||||
msgstr "Substituir chave de API"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "Substituir chave"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "Responder"
|
||||
@ -4871,6 +4825,11 @@ msgstr "Passos"
|
||||
msgid "Stop"
|
||||
msgstr "Parar"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "Pare de Falar"
|
||||
|
||||
@ -635,7 +635,6 @@ msgstr "API вернулся, но обновление не завершило
|
||||
#: src/lib/localized-provider-hint.ts
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API-ключ"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "Подтвердите пароль"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "Подключить"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "Подключено"
|
||||
msgid "Connected · {0}"
|
||||
msgstr "Подключено · {0}"
|
||||
|
||||
#. placeholder {0}: selectedAdvanced.name
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connected {0}."
|
||||
msgstr "Подключено: {0}."
|
||||
|
||||
#. placeholder {0}: selected.label
|
||||
#. placeholder {0}: selectedLabelRef.current ?? "this model"
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -1424,7 +1417,6 @@ msgstr "Подключено. Его инструменты доступны и
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "Подключение…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "Не удалось подключить {0}"
|
||||
msgid "Could not connect this provider"
|
||||
msgstr "Не удалось подключить этого провайдера"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not connect this voice provider"
|
||||
msgstr "Не удалось подключить этого голосового провайдера."
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr "Не удалось подключиться к экрану компьютера"
|
||||
@ -1604,10 +1592,6 @@ msgstr "Не удалось отключить поставщика памяти
|
||||
msgid "Could not disconnect OAuth"
|
||||
msgstr "Не удалось отключить OAuth"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not disconnect this voice provider"
|
||||
msgstr "Не удалось отключить этого голосового провайдера."
|
||||
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Could not dismiss"
|
||||
msgstr "Не удалось отклонить"
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not play a test clip"
|
||||
msgstr "Не удалось воспроизвести тестовый клип."
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr "Прямой MCP"
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "Отключить"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "Отключение…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "Прослушать образец"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "If you heard that, voice is ready."
|
||||
msgstr "Если вы это услышали, голос готов."
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Ignore"
|
||||
msgstr "игнорировать"
|
||||
@ -3792,10 +3762,6 @@ msgstr "Ключ API организации"
|
||||
msgid "Other model…"
|
||||
msgstr "Другая модель…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "Пароль обновлён"
|
||||
msgid "Passwords do not match"
|
||||
msgstr "Пароли не совпадают"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste a replacement key"
|
||||
msgstr "Вставьте запасной ключ"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
|
||||
msgstr "Вставьте OpenAI-совместимый адрес со своего сервера. При необходимости Rakazo добавляет /v1."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "Вставьте свой ключ API"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "Закрепить"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "Воспроизведение…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Поставщик"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "Открыть заново"
|
||||
msgid "Replace API key"
|
||||
msgstr "Заменить ключ API"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "Заменить ключ"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "Ответить"
|
||||
@ -4871,6 +4825,11 @@ msgstr "Шаги"
|
||||
msgid "Stop"
|
||||
msgstr "Остановить"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "Остановить озвучивание"
|
||||
|
||||
@ -635,7 +635,6 @@ msgstr ""
|
||||
#: src/lib/localized-provider-hint.ts
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API anahtarı"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "Parolayı doğrulayın"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "Bağlan"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "Bağlandı"
|
||||
msgid "Connected · {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}: selectedLabelRef.current ?? "this model"
|
||||
#: 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/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "Bağlanıyor…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "{0} bağlanamadı"
|
||||
msgid "Could not connect this provider"
|
||||
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
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr ""
|
||||
@ -1604,10 +1592,6 @@ msgstr "Hafıza sağlayıcısının bağlantısı kesilemedi"
|
||||
msgid "Could not disconnect OAuth"
|
||||
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
|
||||
msgid "Could not dismiss"
|
||||
msgstr ""
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not play a test clip"
|
||||
msgstr "Deneme sesi çalınamadı"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr ""
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "Bağlantıyı kes"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "Bağlantı kesiliyor…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "Örnek dinle"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "If you heard that, voice is ready."
|
||||
msgstr "Bunu duyduysan ses hazır."
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Ignore"
|
||||
msgstr ""
|
||||
@ -3792,10 +3762,6 @@ msgstr "Kuruluş API anahtarı"
|
||||
msgid "Other model…"
|
||||
msgstr "Başka model…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "Parola güncellendi"
|
||||
msgid "Passwords do not match"
|
||||
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
|
||||
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."
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "API anahtarını yapıştır"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "Sabitle"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "Çalınıyor…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "Sağlayıcı"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "Yeniden aç"
|
||||
msgid "Replace API key"
|
||||
msgstr "API anahtarını değiştir"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "Anahtarı değiştir"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "Yanıtla"
|
||||
@ -4871,6 +4825,11 @@ msgstr "Adımlar"
|
||||
msgid "Stop"
|
||||
msgstr "Durdur"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "Seslendirmeyi durdur"
|
||||
|
||||
@ -635,7 +635,6 @@ msgstr "API 已恢复,但更新未完成。请查看宿主日志。"
|
||||
#: src/lib/localized-provider-hint.ts
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "API key"
|
||||
msgstr "API 密钥"
|
||||
|
||||
@ -1346,7 +1345,6 @@ msgstr "确认密码"
|
||||
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connect"
|
||||
msgstr "连接"
|
||||
|
||||
@ -1399,11 +1397,6 @@ msgstr "已连接"
|
||||
msgid "Connected · {0}"
|
||||
msgstr "已连接 · {0}"
|
||||
|
||||
#. placeholder {0}: selectedAdvanced.name
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connected {0}."
|
||||
msgstr "已连接 {0}。"
|
||||
|
||||
#. placeholder {0}: selected.label
|
||||
#. placeholder {0}: selectedLabelRef.current ?? "this model"
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
@ -1424,7 +1417,6 @@ msgstr "已连接,下一条消息起即可使用其工具。"
|
||||
#: src/pages/memory-providers/SerenitySettingsForm.tsx
|
||||
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Connecting…"
|
||||
msgstr "正在连接…"
|
||||
|
||||
@ -1535,10 +1527,6 @@ msgstr "无法连接 {0}"
|
||||
msgid "Could not connect this provider"
|
||||
msgstr "无法连接此提供方"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not connect this voice provider"
|
||||
msgstr "无法连接此语音提供方"
|
||||
|
||||
#: src/pages/Shell.tsx
|
||||
msgid "Could not connect to the computer screen"
|
||||
msgstr ""
|
||||
@ -1604,10 +1592,6 @@ msgstr "无法断开记忆提供方"
|
||||
msgid "Could not disconnect OAuth"
|
||||
msgstr "无法断开 OAuth"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not disconnect this voice provider"
|
||||
msgstr "无法断开此语音提供方"
|
||||
|
||||
#: src/pages/shell/message-cards.tsx
|
||||
msgid "Could not dismiss"
|
||||
msgstr ""
|
||||
@ -1679,10 +1663,6 @@ msgstr ""
|
||||
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Could not play a test clip"
|
||||
msgstr "无法播放测试片段"
|
||||
|
||||
#: src/pages/AccountSettingsOverlay.tsx
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Could not reach the server"
|
||||
@ -2120,12 +2100,10 @@ msgstr ""
|
||||
|
||||
#: src/pages/McpServersOverlay.tsx
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnect"
|
||||
msgstr "断开"
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Disconnecting…"
|
||||
msgstr "正在断开…"
|
||||
|
||||
@ -2703,10 +2681,6 @@ msgstr ""
|
||||
msgid "Health"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Hear a sample"
|
||||
msgstr "试听"
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Heart"
|
||||
msgstr ""
|
||||
@ -2785,10 +2759,6 @@ msgstr ""
|
||||
msgid "Identity"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "If you heard that, voice is ready."
|
||||
msgstr "如果听到了,说明语音已就绪。"
|
||||
|
||||
#: src/pages/ExternalConversationSettings.tsx
|
||||
msgid "Ignore"
|
||||
msgstr ""
|
||||
@ -3792,10 +3762,6 @@ msgstr "组织 API 密钥"
|
||||
msgid "Other model…"
|
||||
msgstr "其他模型…"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Other voice providers"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
msgid "Page"
|
||||
msgstr ""
|
||||
@ -3832,18 +3798,10 @@ msgstr "密码已更新"
|
||||
msgid "Passwords do not match"
|
||||
msgstr "两次密码不一致"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste a replacement key"
|
||||
msgstr "粘贴替换密钥"
|
||||
|
||||
#: src/pages/ModelSettingsOverlay.tsx
|
||||
msgid "Paste the OpenAI-compatible address from your server. Rakazo adds /v1 if needed."
|
||||
msgstr "粘贴服务器的 OpenAI 兼容地址。需要时 Rakazo 会补上 /v1。"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Paste your API key"
|
||||
msgstr "粘贴你的 API 密钥"
|
||||
|
||||
#: src/pages/HeuteBrief.tsx
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Paused"
|
||||
@ -3889,14 +3847,15 @@ msgstr "置顶"
|
||||
msgid "Pink"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Play {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/OoAtelier.tsx
|
||||
msgid "Playful"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Playing…"
|
||||
msgstr "播放中…"
|
||||
|
||||
#: src/lib/voice-persona-copy.ts
|
||||
msgid "Point me at the problem."
|
||||
msgstr ""
|
||||
@ -3973,7 +3932,6 @@ msgid "Projects"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/MemorySettingsOverlay.tsx
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Provider"
|
||||
msgstr "提供方"
|
||||
|
||||
@ -4232,10 +4190,6 @@ msgstr "重新打开"
|
||||
msgid "Replace API key"
|
||||
msgstr "替换 API 密钥"
|
||||
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Replace key"
|
||||
msgstr "替换密钥"
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Reply"
|
||||
msgstr "回复"
|
||||
@ -4871,6 +4825,11 @@ msgstr "步骤"
|
||||
msgid "Stop"
|
||||
msgstr "停止"
|
||||
|
||||
#. placeholder {0}: voice.label
|
||||
#: src/pages/VoiceSettingsOverlay.tsx
|
||||
msgid "Stop {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/transcript.tsx
|
||||
msgid "Stop speaking"
|
||||
msgstr "停止朗读"
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
setUiAppearance,
|
||||
} from "../lib/ui-appearance";
|
||||
import { UI_LOCALE_LABELS, UI_LOCALES, type UiLocale } from "../lib/ui-locale";
|
||||
import { alignVoiceWithLocale } from "../lib/voice-locale";
|
||||
|
||||
export type SettingsGeneralProps = {
|
||||
email?: string | null;
|
||||
@ -50,6 +51,7 @@ export function GeneralSettingsPanels({
|
||||
const requestId = ++localeRequestRef.current;
|
||||
setLocale(next);
|
||||
void setUiLocale(next).then((activated) => {
|
||||
void alignVoiceWithLocale(activated).catch(() => undefined);
|
||||
if (requestId !== localeRequestRef.current) return;
|
||||
setLocale(activated);
|
||||
});
|
||||
|
||||
@ -1,29 +1,30 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { VoiceCatalogEntry, VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
|
||||
import { preferVoicesForLocale } from "@rakazo/core";
|
||||
import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
|
||||
import { voiceIdForLocale, voicesForLocale } from "@rakazo/core";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Field,
|
||||
FieldLabel,
|
||||
Input,
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
Switch,
|
||||
} from "@rakazo/ui-web";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { PlayIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ooSound } from "../lib/oo-sound";
|
||||
import { readOoSoundEnabled } from "../lib/oo-sound-pref";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { speaker } from "../lib/tts";
|
||||
import { readUiLocale } from "../lib/ui-locale";
|
||||
import {
|
||||
alignVoiceWithLocale,
|
||||
KOKORO_LOCAL_TOKEN,
|
||||
LOCAL_VOICE_PROVIDER,
|
||||
} from "../lib/voice-locale";
|
||||
|
||||
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
|
||||
const KOKORO_LOCAL_TOKEN = "local-kokoro";
|
||||
const LOCAL_PROVIDER_ID = "kokoro";
|
||||
const LOCAL_PROVIDER_ID = LOCAL_VOICE_PROVIDER;
|
||||
const PREVIEW_PREFIX = "voice-preview:";
|
||||
|
||||
export function VoiceSettingsOverlay({
|
||||
onClose,
|
||||
@ -39,50 +40,51 @@ export function VoiceSettingsOverlay({
|
||||
activeBotId?: string;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const apiKeyId = useId();
|
||||
const voiceSelectId = useId();
|
||||
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]);
|
||||
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
|
||||
const [status, setStatus] = useState<VoiceStatus | null>(null);
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [voiceId, setVoiceId] = useState("");
|
||||
const [autoSpeak, setAutoSpeak] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState<
|
||||
"connect" | "disconnect" | "voice" | "test" | "speak" | null
|
||||
>(null);
|
||||
const [pending, setPending] = useState<"connect" | "voice" | "speak" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled());
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [advancedProvider, setAdvancedProvider] = useState("");
|
||||
const [advancedApiKey, setAdvancedApiKey] = useState("");
|
||||
const advancedChoice = useRef("");
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
|
||||
const busy = pending !== null;
|
||||
const uiLocale = readUiLocale();
|
||||
const localConnected = credentials.some((entry) => entry.provider === LOCAL_PROVIDER_ID);
|
||||
const advancedProviders = catalog.filter((entry) => entry.id !== LOCAL_PROVIDER_ID);
|
||||
const selectedAdvanced =
|
||||
advancedProviders.find((entry) => entry.id === advancedProvider) ?? advancedProviders[0];
|
||||
const advancedCredential = credentials.find((entry) => entry.provider === advancedProvider);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onBusyChange?.(false);
|
||||
}, [onBusyChange]);
|
||||
|
||||
function markPending(next: "connect" | "disconnect" | "voice" | "test" | "speak" | null) {
|
||||
useEffect(() => {
|
||||
const unsubscribe = speaker.subscribe((snapshot) => {
|
||||
const id = snapshot.messageId?.startsWith(PREVIEW_PREFIX)
|
||||
? snapshot.messageId.slice(PREVIEW_PREFIX.length)
|
||||
: null;
|
||||
setPreviewId(snapshot.status === "idle" ? null : id);
|
||||
if (id && snapshot.error) setError(snapshot.error);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (speaker.state.messageId?.startsWith(PREVIEW_PREFIX)) speaker.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function markPending(next: "connect" | "voice" | "speak" | null) {
|
||||
setPending(next);
|
||||
onBusyChange?.(next !== null);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([
|
||||
rpc.voice.catalog(),
|
||||
await alignVoiceWithLocale(uiLocale).catch(() => undefined);
|
||||
const [nextCredentials, nextStatus] = await Promise.all([
|
||||
rpc.voice.credentials(),
|
||||
rpc.voice.status(),
|
||||
]);
|
||||
setCatalog(nextCatalog);
|
||||
setCredentials(nextCredentials);
|
||||
setStatus(nextStatus);
|
||||
const localCred = nextCredentials.find((entry) => entry.provider === LOCAL_PROVIDER_ID);
|
||||
@ -91,7 +93,7 @@ export function VoiceSettingsOverlay({
|
||||
if (localCred || nextStatus.ready) {
|
||||
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
|
||||
setVoices(listed);
|
||||
if (!activeVoice && listed[0]) setVoiceId(listed[0].id);
|
||||
if (!activeVoice) setVoiceId(voiceIdForLocale(listed, uiLocale, "") ?? "");
|
||||
} else {
|
||||
setVoices([]);
|
||||
}
|
||||
@ -100,16 +102,6 @@ export function VoiceSettingsOverlay({
|
||||
const bot = bots.find((entry) => entry.id === activeBotId);
|
||||
setAutoSpeak(bot?.autoSpeak ?? false);
|
||||
}
|
||||
if (!advancedChoice.current) {
|
||||
const connected = nextCredentials.find((entry) => entry.provider !== LOCAL_PROVIDER_ID);
|
||||
const preferred =
|
||||
nextCatalog.find((entry) => entry.id === connected?.provider) ??
|
||||
nextCatalog.find((entry) => entry.id !== LOCAL_PROVIDER_ID);
|
||||
if (preferred) {
|
||||
advancedChoice.current = preferred.id;
|
||||
setAdvancedProvider(preferred.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -121,7 +113,7 @@ export function VoiceSettingsOverlay({
|
||||
}, [activeBotId]);
|
||||
|
||||
const voiceOptions = useMemo(() => {
|
||||
const listed = preferVoicesForLocale(voices, uiLocale);
|
||||
const listed = voicesForLocale(voices, uiLocale);
|
||||
if (listed.length) return listed;
|
||||
return voiceId ? [{ id: voiceId, label: voiceId }] : [];
|
||||
}, [voices, voiceId, uiLocale]);
|
||||
@ -176,7 +168,7 @@ export function VoiceSettingsOverlay({
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID });
|
||||
setVoiceId(nextVoice);
|
||||
@ -195,58 +187,16 @@ export function VoiceSettingsOverlay({
|
||||
}
|
||||
}
|
||||
|
||||
async function connectAdvanced() {
|
||||
if (!selectedAdvanced || advancedApiKey.trim().length < 8) return;
|
||||
markPending("connect");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await rpc.voice.connect({
|
||||
provider: selectedAdvanced.id,
|
||||
apiKey: advancedApiKey.trim(),
|
||||
});
|
||||
setAdvancedApiKey("");
|
||||
await refresh();
|
||||
setNotice(t`Connected ${selectedAdvanced.name}.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not connect this voice provider`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
function togglePreview(voice: VoiceInfo) {
|
||||
if (previewId === voice.id) {
|
||||
speaker.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectAdvanced() {
|
||||
if (!advancedCredential) return;
|
||||
markPending("disconnect");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.voice.disconnect({ provider: advancedCredential.provider });
|
||||
setAdvancedApiKey("");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not disconnect this voice provider`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function testVoice() {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
markPending("test");
|
||||
try {
|
||||
const { speaker } = await import("../lib/tts.js");
|
||||
await speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`);
|
||||
if (speaker.state.error) {
|
||||
setError(speaker.state.error);
|
||||
return;
|
||||
}
|
||||
setNotice(t`If you heard that, voice is ready.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not play a test clip`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
void speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`, {
|
||||
voiceId: voice.id,
|
||||
messageId: `${PREVIEW_PREFIX}${voice.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = (
|
||||
@ -296,37 +246,55 @@ export function VoiceSettingsOverlay({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoSpeak && speakingReady ? (
|
||||
<>
|
||||
<Field className="mt-6">
|
||||
<FieldLabel htmlFor={voiceSelectId}>
|
||||
<Trans>Voice</Trans>
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={voiceSelectId}
|
||||
className="w-full"
|
||||
value={voiceId}
|
||||
disabled={busy}
|
||||
onChange={(event) => void chooseVoice(event.target.value)}
|
||||
>
|
||||
{voiceOptions.map((voice) => (
|
||||
<NativeSelectOption key={voice.id} value={voice.id}>
|
||||
{voice.label}
|
||||
{voice.description ? ` · ${voice.description}` : ""}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="mt-4 rounded-full"
|
||||
disabled={busy || !status?.ready}
|
||||
onClick={() => void testVoice()}
|
||||
>
|
||||
{pending === "test" ? <Trans>Playing…</Trans> : <Trans>Hear a sample</Trans>}
|
||||
</Button>
|
||||
</>
|
||||
{speakingReady && voiceOptions.length > 0 ? (
|
||||
<div
|
||||
data-testid="voice-carousel"
|
||||
className="rk-scroll -mx-6 mt-6 flex snap-x snap-mandatory scroll-px-6 gap-3 overflow-x-auto px-6 pb-2 sm:-mx-8 sm:scroll-px-8 sm:px-8"
|
||||
>
|
||||
{voiceOptions.map((voice) => {
|
||||
const active = voice.id === voiceId;
|
||||
const playing = previewId === voice.id;
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
data-testid="voice-card"
|
||||
className={cn(
|
||||
"relative w-40 shrink-0 snap-start rounded-xl border px-4 py-3 transition-colors",
|
||||
active
|
||||
? "border-primary bg-muted ring-1 ring-primary"
|
||||
: "border-border bg-card hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
disabled={busy}
|
||||
onClick={() => void chooseVoice(voice.id)}
|
||||
className="min-w-0 truncate text-start text-ui-body font-medium text-foreground after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"
|
||||
>
|
||||
{voice.label}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="relative z-10 shrink-0 rounded-full"
|
||||
aria-label={playing ? t`Stop ${voice.label}` : t`Play ${voice.label}`}
|
||||
onClick={() => togglePreview(voice)}
|
||||
>
|
||||
{playing ? <SquareIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
{voice.description ? (
|
||||
<p className="mt-0.5 text-ui-body-sm text-muted-foreground">
|
||||
{voice.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 rounded-xl border border-border px-4 py-4">
|
||||
@ -359,97 +327,6 @@ export function VoiceSettingsOverlay({
|
||||
</label>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@ -16,11 +16,14 @@ export KOKORO_BASE_URL=http://127.0.0.1:8880
|
||||
|
||||
## In EvOo verbinden
|
||||
|
||||
1. **Voice** in den Einstellungen öffnen.
|
||||
2. **Kokoro** wählen (erster Eintrag).
|
||||
3. **Connect local Kokoro** — prüft `GET /v1/audio/voices`, speichert einen Platzhalter-Token (`local-kokoro`), kein API-Key.
|
||||
4. Stimme wählen (z. B. `af_heart`) und **Hear a sample** testen.
|
||||
5. Voice First / CallView nutzt dann `POST /v1/audio/speech` (OpenAI-kompatibel, `stream: false`).
|
||||
1. **Stimme** in den Einstellungen öffnen.
|
||||
2. **Kann sprechen** einschalten. EvOo prüft `GET /v1/audio/voices` und speichert einen Platzhalter-Token (`local-kokoro`), kein API-Key.
|
||||
3. Im Karussell eine Stimme antippen; der Play-Knopf spielt sie vorher an.
|
||||
4. 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
|
||||
|
||||
|
||||
@ -14,9 +14,6 @@ const PRIVACY_URLS: Record<string, string> = {
|
||||
anthropic: "https://www.anthropic.com/legal/privacy",
|
||||
google: "https://policies.google.com/privacy",
|
||||
"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",
|
||||
};
|
||||
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
@ -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 : "";
|
||||
}
|
||||
@ -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(", ");
|
||||
}
|
||||
@ -15,7 +15,6 @@ export * from "./browser-tools.js";
|
||||
export { BUILTIN_MEMORY_PROVIDER_ID, BuiltinMemoryProvider } from "./builtin-memory-provider.js";
|
||||
export * from "./builtin-skills.js";
|
||||
export * from "./builtin-tools.js";
|
||||
export * from "./cartesia-voice.js";
|
||||
export * from "./chat-sdk-surface.js";
|
||||
export * from "./child-bots.js";
|
||||
export * from "./cloud-agent-emulator.js";
|
||||
@ -51,7 +50,6 @@ export * from "./destination-emulator.js";
|
||||
export * from "./docker-sandbox.js";
|
||||
export * from "./e2b-emulator.js";
|
||||
export * from "./e2b-sandbox.js";
|
||||
export * from "./elevenlabs-voice.js";
|
||||
export * from "./email-emulator.js";
|
||||
export * from "./embedding-emulator.js";
|
||||
export * from "./executor.js";
|
||||
@ -65,7 +63,6 @@ export * from "./expo-push.js";
|
||||
export * from "./fake-browser.js";
|
||||
export * from "./fake-sandbox.js";
|
||||
export * from "./fake-web.js";
|
||||
export * from "./fish-audio-voice.js";
|
||||
export * from "./github-webhook-emulator.js";
|
||||
export * from "./graphql-connectors.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-transcription.js";
|
||||
export * from "./openai-compatible-url.js";
|
||||
export * from "./openai-voice.js";
|
||||
export * from "./page-browser-session.js";
|
||||
export * from "./pi-credentials.js";
|
||||
export * from "./pi-model-config.js";
|
||||
|
||||
@ -26,15 +26,15 @@ const MODEL = "kokoro";
|
||||
/** Curated presets when the server is down or returns bare voice ids. */
|
||||
export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [
|
||||
...GERMAN_VOICE_PRESETS,
|
||||
{ id: "af_heart", label: "Heart", description: "Warm · multilingual" },
|
||||
{ id: "af_bella", label: "Bella", description: "Expressive · multilingual" },
|
||||
{ id: "af_sky", label: "Sky", description: "Bright · multilingual" },
|
||||
{ id: "af_nova", label: "Nova", description: "Energetic · multilingual" },
|
||||
{ id: "am_adam", label: "Adam", description: "Clear · multilingual" },
|
||||
{ id: "am_michael", label: "Michael", description: "Steady · multilingual" },
|
||||
{ id: "bf_emma", label: "Emma", description: "British · multilingual" },
|
||||
{ id: "bf_isabella", label: "Isabella", description: "British · multilingual" },
|
||||
{ id: "bm_george", label: "George", description: "British · multilingual" },
|
||||
{ id: "af_heart", label: "Heart", description: "warm" },
|
||||
{ id: "af_bella", label: "Bella", description: "expressive" },
|
||||
{ id: "af_sky", label: "Sky", description: "bright" },
|
||||
{ id: "af_nova", label: "Nova", description: "energetic" },
|
||||
{ id: "am_adam", label: "Adam", description: "clear" },
|
||||
{ id: "am_michael", label: "Michael", description: "steady" },
|
||||
{ id: "bf_emma", label: "Emma", description: "British" },
|
||||
{ id: "bf_isabella", label: "Isabella", description: "British" },
|
||||
{ id: "bm_george", label: "George", description: "British" },
|
||||
];
|
||||
|
||||
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
|
||||
.map((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);
|
||||
}
|
||||
|
||||
@ -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() };
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,5 @@
|
||||
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 { OpenAIVoiceProvider } from "./openai-voice.js";
|
||||
import {
|
||||
SCRIPTED_MPEG,
|
||||
SCRIPTED_TRANSCRIPT,
|
||||
@ -36,30 +32,15 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("createVoiceProvider", () => {
|
||||
it("exposes the hosted catalog behind one factory", () => {
|
||||
it("offers only the local voice server", () => {
|
||||
process.env.AGENT_RUNTIME = "pi";
|
||||
expect(VOICE_CATALOG.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(VOICE_CATALOG.map((entry) => entry.id)).toEqual(["kokoro"]);
|
||||
expect(listVoiceCatalog().map((entry) => entry.id)).toEqual(["kokoro"]);
|
||||
expect(createVoiceProvider("kokoro").describe().id).toBe("kokoro");
|
||||
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("elevenlabs")).toBe(true);
|
||||
expect(isVoiceProviderId("elevenlabs")).toBe(false);
|
||||
expect(() => createVoiceProvider("elevenlabs")).toThrow(/unknown voice provider/i);
|
||||
expect(isVoiceProviderId("scripted")).toBe(false);
|
||||
expect(isVoiceProviderId("piper")).toBe(false);
|
||||
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", () => {
|
||||
it("verifies against the local /v1/audio/voices endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
@ -219,222 +123,8 @@ describe("KokoroVoiceProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CartesiaVoiceProvider", () => {
|
||||
it("maps the voices list from either array or { data } payloads", async () => {
|
||||
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"],
|
||||
])(
|
||||
describe("voice response limits", () => {
|
||||
it.each([["Kokoro", () => new KokoroVoiceProvider(), "af_heart"] as const])(
|
||||
"rejects an oversized %s speech response before buffering it",
|
||||
async (_name, create, voiceId) => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
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 { OpenAIVoiceProvider } from "./openai-voice.js";
|
||||
import { SCRIPTED_VOICE_CATALOG_ENTRY, ScriptedVoiceProvider } from "./scripted-voice.js";
|
||||
|
||||
export const VOICE_CATALOG = [
|
||||
@ -13,30 +9,6 @@ export const VOICE_CATALOG = [
|
||||
description: "Free local speech on your server.",
|
||||
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;
|
||||
|
||||
export { SCRIPTED_VOICE_CATALOG_ENTRY };
|
||||
@ -69,23 +41,13 @@ export function createVoiceProvider(kind: string): VoiceProvider {
|
||||
switch (kind) {
|
||||
case "kokoro":
|
||||
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":
|
||||
if (!scriptedVoiceEnabled()) break;
|
||||
return new ScriptedVoiceProvider();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
throw new Error(
|
||||
`Unknown voice provider "${kind}". Use kokoro | elevenlabs | openai | cartesia | fish-audio.`,
|
||||
);
|
||||
throw new Error(`Unknown voice provider "${kind}". Use kokoro.`);
|
||||
}
|
||||
|
||||
export class NoVoiceConfigured extends Error {
|
||||
@ -93,9 +55,7 @@ export class NoVoiceConfigured extends Error {
|
||||
|
||||
constructor(reason: "key" | "voice") {
|
||||
super(
|
||||
reason === "key"
|
||||
? "Add a voice provider key in Voice settings to turn on speaking."
|
||||
: "Pick a voice in Voice settings.",
|
||||
reason === "key" ? "Turn on speaking in Voice settings." : "Pick a voice in Voice settings.",
|
||||
);
|
||||
this.reason = reason;
|
||||
this.name = "NoVoiceConfigured";
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MAX_VOICE_JSON_BYTES,
|
||||
readVoiceJson,
|
||||
speechUploadName,
|
||||
voiceDeadline,
|
||||
} from "./voice-http.js";
|
||||
import { MAX_VOICE_JSON_BYTES, readVoiceJson, voiceDeadline } from "./voice-http.js";
|
||||
|
||||
describe("voiceDeadline", () => {
|
||||
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", () => {
|
||||
it("rejects an oversized provider response without waiting for cancellation", async () => {
|
||||
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
||||
|
||||
@ -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(
|
||||
res: Response,
|
||||
options: { requireValid?: boolean } = {},
|
||||
@ -94,10 +84,6 @@ function detail(body: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function voiceUnreachable(provider: string): string {
|
||||
return `Couldn't reach ${provider} to check that key. Check your connection.`;
|
||||
}
|
||||
|
||||
export function voiceHttpError(
|
||||
status: number,
|
||||
provider: string,
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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", () => {
|
||||
it("parses slash voice commands", () => {
|
||||
@ -59,3 +64,32 @@ describe("defaultVoiceForLocale", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,12 +2,33 @@ import type { VoiceInfo } from "@rakazo/contracts";
|
||||
|
||||
/** Friendly German presets when Kokoro exposes df_/dm_ voices. */
|
||||
export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [
|
||||
{ id: "df_eva", label: "Eva", description: "Deutsch · warm" },
|
||||
{ id: "dm_bernd", label: "Bernd", description: "Deutsch · klar" },
|
||||
{ id: "df_victoria", label: "Victoria", description: "Deutsch · freundlich" },
|
||||
{ id: "dm_martin", label: "Martin", description: "Deutsch · ruhig" },
|
||||
{ id: "df_eva", label: "Eva", description: "warm" },
|
||||
{ id: "dm_bernd", label: "Bernd", description: "klar" },
|
||||
{ id: "df_victoria", label: "Victoria", description: "freundlich" },
|
||||
{ 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]));
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Prefer German voices when UI locale is de. */
|
||||
export function preferVoicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[] {
|
||||
if (locale !== "de") return voices;
|
||||
const german = voices.filter((voice) => /^d[fm]_/.test(voice.id));
|
||||
if (german.length === 0) return voices;
|
||||
const rest = voices.filter((voice) => !/^d[fm]_/.test(voice.id));
|
||||
return [...german, ...rest];
|
||||
/**
|
||||
* Only the voices that speak the UI language. Ids without a Kokoro language prefix are kept,
|
||||
* and every voice is kept when none speaks the language, so the picker never ends up empty.
|
||||
*/
|
||||
export function voicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[] {
|
||||
const prefixes = voiceLanguagePrefixes(locale);
|
||||
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 {
|
||||
const ordered = preferVoicesForLocale(voices, locale);
|
||||
if (locale === "de") {
|
||||
return ordered.find((voice) => voice.id === "df_eva") ?? ordered[0];
|
||||
}
|
||||
return ordered[0];
|
||||
const listed = voicesForLocale(voices, locale);
|
||||
return listed.find((voice) => voice.id === "df_eva") ?? listed[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. */
|
||||
|
||||
@ -217,8 +217,8 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
["voice/catalog"],
|
||||
["voice/status"],
|
||||
["voice/credentials"],
|
||||
["voice/connect", { provider: "elevenlabs", apiKey: "not-a-real-key" }],
|
||||
["voice/disconnect", { provider: "elevenlabs" }],
|
||||
["voice/connect", { provider: "kokoro", apiKey: "not-a-real-key" }],
|
||||
["voice/disconnect", { provider: "kokoro" }],
|
||||
["voice/setVoice", { voiceId: "missing-voice" }],
|
||||
["voice/voices", {}],
|
||||
["voice/prepare", { text: "Nope" }],
|
||||
|
||||
@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
||||
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import type { createApp } from "../../../../apps/api/src/app.ts";
|
||||
import { startFakeKokoro } from "../fake-kokoro.js";
|
||||
import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js";
|
||||
import { runProcess } from "./process.js";
|
||||
|
||||
@ -48,6 +49,7 @@ async function main() {
|
||||
const reportDir = path.resolve("test-report", mode);
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
const container = await new PostgreSqlContainer("postgres:16-alpine").start();
|
||||
const kokoro = await startFakeKokoro();
|
||||
try {
|
||||
const databaseUrl = container.getConnectionUri();
|
||||
const apiPort = Number(process.env.API_PORT ?? 3110);
|
||||
@ -79,6 +81,7 @@ async function main() {
|
||||
process.env.SIGNUPS_ENABLED = "true";
|
||||
process.env.SIGNUP_ALLOWLIST = "";
|
||||
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 exec prisma migrate deploy", {
|
||||
@ -339,6 +342,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await kokoro.close();
|
||||
await container.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
33
packages/testkit/src/fake-kokoro.ts
Normal file
33
packages/testkit/src/fake-kokoro.ts
Normal 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())),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user