.
This commit is contained in:
@@ -14,6 +14,7 @@ import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Picker, Sp
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { OPENAI_STATIC_VOICES } from "../lib/voice.ts";
|
||||
|
||||
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
|
||||
|
||||
@@ -349,6 +350,8 @@ function VoicePreviewsCard() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]);
|
||||
const [ttsProvider, setTtsProvider] = useState<string | null>(null);
|
||||
const [ttsAvailable, setTtsAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminGetSettings()
|
||||
@@ -357,7 +360,13 @@ function VoicePreviewsCard() {
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
api.ttsInfo().then((r) => setServerVoices(r.voices)).catch(() => {});
|
||||
api.ttsInfo()
|
||||
.then((r) => {
|
||||
setServerVoices(r.voices);
|
||||
setTtsProvider(r.provider);
|
||||
setTtsAvailable(r.available);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function save(next: VoicePreview[]) {
|
||||
@@ -454,8 +463,8 @@ function VoicePreviewsCard() {
|
||||
</Field>
|
||||
</span>
|
||||
<span className="w-40">
|
||||
<Field label={t("vfeat.voice")} hint={t("vfeat.voiceHint")}>
|
||||
{serverVoices.length > 0 ? (
|
||||
<Field label={t("vfeat.voice")} hint={ttsAvailable ? (ttsProvider ? `${ttsProvider} · ${t("vfeat.voiceHint")}` : t("vfeat.voiceHint")) : t("vfeat.noProvider")}>
|
||||
{serverVoices.length > 0 || ttsProvider !== "elevenlabs" ? (
|
||||
<Picker
|
||||
ariaLabel={t("vfeat.voice")}
|
||||
value={line.voice}
|
||||
@@ -465,7 +474,12 @@ function VoicePreviewsCard() {
|
||||
if (next[i]!.text.trim() !== "") void save(next);
|
||||
}}
|
||||
align="left"
|
||||
options={[{ value: "", label: t("vfeat.voicePh") }, ...serverVoices.map((v) => ({ value: v.id, label: v.name }))]}
|
||||
options={[
|
||||
{ value: "", label: t("vfeat.voicePh") },
|
||||
...(serverVoices.length > 0
|
||||
? serverVoices.map((v) => ({ value: v.id, label: v.name }))
|
||||
: OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))),
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api, type UserProvider, type VoicePreview } from "../lib/api.ts";
|
||||
import { pushToast } from "../lib/toasts.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||
import { detectLang, getVoicePrefs, listTtsVoices, pickVoice, playText, setVoicePrefs, stopPlayback, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
|
||||
import { detectLang, getVoicePrefs, listTtsVoices, OPENAI_STATIC_VOICES, pickVoice, playText, setVoicePrefs, stopPlayback, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
|
||||
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
||||
import type { Conversation, FilePreview, User } from "../lib/types.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
@@ -978,8 +978,11 @@ function VoiceSection() {
|
||||
/>
|
||||
</Field>
|
||||
{(prefs.engine !== "browser") && (
|
||||
ttsServer?.available ? (
|
||||
ttsServer.voices.length > 0 ? (
|
||||
<>
|
||||
{!ttsServer?.available && (
|
||||
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm text-amber-700 dark:text-amber-400">{t("voice.noServer")}</p>
|
||||
)}
|
||||
{ttsServer && ttsServer.voices.length > 0 ? (
|
||||
<Field label={t("voice.serverVoice")}>
|
||||
<Picker
|
||||
ariaLabel={t("voice.serverVoice")}
|
||||
@@ -990,7 +993,7 @@ function VoiceSection() {
|
||||
options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v.id, label: v.name }))]}
|
||||
/>
|
||||
</Field>
|
||||
) : ttsServer.provider === "elevenlabs" ? (
|
||||
) : ttsServer?.provider === "elevenlabs" ? (
|
||||
<Field label={t("voice.serverVoice")} hint={t("voice.serverVoiceHint")}>
|
||||
<Input
|
||||
value={prefs.serverVoice ?? ""}
|
||||
@@ -999,12 +1002,21 @@ function VoiceSection() {
|
||||
maxLength={64}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
) : ttsServer?.provider === "piper" ? (
|
||||
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-sm opacity-70 dark:bg-zinc-800">{t("voice.piperNote")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm text-amber-700 dark:text-amber-400">{t("voice.noServer")}</p>
|
||||
)
|
||||
) : (
|
||||
<Field label={t("voice.serverVoice")} hint={t("vfeat.noProvider")}>
|
||||
<Picker
|
||||
ariaLabel={t("voice.serverVoice")}
|
||||
icon={Volume2}
|
||||
value={prefs.serverVoice ?? ""}
|
||||
onChange={(v) => update({ serverVoice: v || null })}
|
||||
align="left"
|
||||
options={[{ value: "", label: t("voice.defaultVoice") }, ...OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field label={t("voice.voice")}>
|
||||
<Picker
|
||||
|
||||
@@ -608,6 +608,8 @@ const en = {
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
@@ -1316,6 +1318,8 @@ const es: Dict = {
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
@@ -2019,6 +2023,8 @@ const fr: Dict = {
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
@@ -2710,6 +2716,8 @@ const ru: Dict = {
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
@@ -3406,6 +3414,8 @@ const it: Dict = {
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
@@ -4093,6 +4103,8 @@ const pt: Dict = {
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
|
||||
@@ -90,6 +90,9 @@ export interface SpeakOpts {
|
||||
|
||||
export type VoiceTimbre = "masculine" | "feminine" | "any";
|
||||
|
||||
/** Stock OpenAI voices (static catalog — selectable even before any key). */
|
||||
export const OPENAI_STATIC_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||
|
||||
// Name fragments hinting a masculine / feminine voice (Chrome, Edge,
|
||||
// Safari and Android voices carry such markers; absence falls back to
|
||||
// any same-language voice — never an error).
|
||||
|
||||
Reference in New Issue
Block a user