Claude bc7353f1f2
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
2026-10-05 14:44:03 +00:00

240 lines
8.1 KiB
TypeScript

import { voicesForLocale } from "@rakazo/core";
import { useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
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 {
alignVoiceWithLocale,
KOKORO_LOCAL_TOKEN,
LOCAL_VOICE_PROVIDER,
speakText,
} from "../lib/voice";
type VoiceCredential = {
id: string;
provider: string;
voiceId: string;
};
type VoiceInfo = { id: string; label: string; description?: string };
export default function VoiceSettings() {
const styles = useThemedStyles(createVoiceStyles);
const { locale, t } = useI18n();
const [connected, setConnected] = useState(false);
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [voiceId, setVoiceId] = useState("");
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | null>(null);
const [playing, setPlaying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
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) {
const listed = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
setVoices(voicesForLocale(listed, locale));
} else {
setVoices([]);
}
}, [locale]);
useFocusEffect(
useCallback(() => {
setLoading(true);
void load()
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : t("Could not load voice settings")),
)
.finally(() => setLoading(false));
}, [load]),
);
async function connect() {
setPending("connect");
setError(null);
try {
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 {
setPending(null);
}
}
async function disconnect() {
setPending("disconnect");
setError(null);
try {
await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER });
await load();
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not disconnect"));
} finally {
setPending(null);
}
}
async function chooseVoice(nextVoiceId: string) {
setVoiceId(nextVoiceId);
setPending("voice");
setError(null);
try {
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 playVoice(voice: VoiceInfo) {
setPlaying(voice.id);
setError(null);
try {
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 {
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}
{!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: { 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,
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: {
marginHorizontal: 20,
backgroundColor: tokens.primary,
borderRadius: 12,
paddingVertical: 12,
alignItems: "center",
},
disabled: { opacity: 0.4 },
buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" },
secondary: { marginTop: 16, alignItems: "center" },
secondaryLabel: { color: native.secondaryLabel, fontSize: 15 },
});
}