* 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 * test(e2e): measure the workspace panel width after its transition settles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 --------- Co-authored-by: Claude <noreply@anthropic.com>
240 lines
8.1 KiB
TypeScript
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 },
|
|
});
|
|
}
|