From c567e4599ee6bf5908b4e6624866e0e0ecee0e28 Mon Sep 17 00:00:00 2001 From: Caldalis Date: Mon, 21 Sep 2026 16:48:57 +0800 Subject: [PATCH 1/4] fix(tts): resume a suspended AudioContext in the buffered player --- packages/core/src/tts/tts-players.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/src/tts/tts-players.ts b/packages/core/src/tts/tts-players.ts index d6753ede0..04de889c6 100644 --- a/packages/core/src/tts/tts-players.ts +++ b/packages/core/src/tts/tts-players.ts @@ -786,6 +786,16 @@ class BufferedAudioTTSPlayer implements ITTSPlayer { this.gainNode.connect(this.audioCtx.destination); this.scheduledEnd = 0; + // A webview hands back a suspended AudioContext under its autoplay policy, + // and scheduling into one is silent: the clock never advances, so nothing + // plays and playback never reports finishing either. EdgeTTSPlayer already + // guards for this. resume() can reject if a newer run closed this context + // while we awaited, and the runId guard below discards that run anyway. + if (this.audioCtx.state === "suspended") { + await this.audioCtx.resume().catch(() => {}); + } + if (myRun !== this.runId) return; + this.checkEndTimer = setInterval(() => { if (!this._playing || !this.audioCtx) return; const current = resolveCurrentChunk(this.chunkBoundaries, this.audioCtx.currentTime); From f64dad5994af1aa332c650184fec686c0f5b93cf Mon Sep 17 00:00:00 2001 From: Caldalis Date: Mon, 21 Sep 2026 16:48:57 +0800 Subject: [PATCH 2/4] fix(tts): populate built-in voice profiles on a fresh install --- packages/app-expo/src/stores/tts-store.ts | 6 +++++- packages/core/src/stores/tts-store.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app-expo/src/stores/tts-store.ts b/packages/app-expo/src/stores/tts-store.ts index 4a3d11129..5edfe41c0 100644 --- a/packages/app-expo/src/stores/tts-store.ts +++ b/packages/app-expo/src/stores/tts-store.ts @@ -380,7 +380,11 @@ export const useTTSStore = create()( playState: "stopped", currentText: "", currentSegmentText: "", - config: DEFAULT_TTS_CONFIG, + // Normalized, not raw: DEFAULT_TTS_CONFIG carries an empty `profiles` + // array, and normalizeTTSConfig is what fills in the built-in voice + // profiles. The persist layer only runs it when a config file already + // exists, so a fresh install would otherwise show an empty provider list. + config: normalizeTTSConfig(DEFAULT_TTS_CONFIG), onEnd: null, currentBookTitle: "", currentChapterTitle: "", diff --git a/packages/core/src/stores/tts-store.ts b/packages/core/src/stores/tts-store.ts index 20a442132..ebb423c78 100644 --- a/packages/core/src/stores/tts-store.ts +++ b/packages/core/src/stores/tts-store.ts @@ -344,7 +344,11 @@ export const useTTSStore = create()( (set, get) => ({ playState: "stopped", currentText: "", - config: DEFAULT_TTS_CONFIG, + // Normalized, not raw: DEFAULT_TTS_CONFIG carries an empty `profiles` + // array, and normalizeTTSConfig is what fills in the built-in voice + // profiles. The persist layer only runs it when a config file already + // exists, so a fresh install would otherwise show an empty provider list. + config: normalizeTTSConfig(DEFAULT_TTS_CONFIG), onEnd: null, currentChunkIndex: 0, totalChunks: 0, From b65cffe4d78d4dd6627b6a153046d3962788d45d Mon Sep 17 00:00:00 2001 From: Caldalis Date: Mon, 21 Sep 2026 16:48:57 +0800 Subject: [PATCH 3/4] fix(tts): surface buffered synthesis errors and honour stop() --- packages/core/src/stores/tts-store.ts | 8 ++++++++ packages/core/src/tts/cloud-tts.ts | 8 +++++++- packages/core/src/tts/tts-players.ts | 20 ++++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/core/src/stores/tts-store.ts b/packages/core/src/stores/tts-store.ts index ebb423c78..648245fc3 100644 --- a/packages/core/src/stores/tts-store.ts +++ b/packages/core/src/stores/tts-store.ts @@ -197,6 +197,7 @@ function detachAndStopPlayer(player: ITTSPlayer | null): void { if (!player) return; player.onStateChange = undefined; player.onChunkChange = undefined; + player.onError = undefined; player.onEnd = undefined; try { player.stop(); @@ -261,6 +262,13 @@ function startPlayback( }); }; + player.onError = (error) => { + if (gen !== _sessionGeneration) return; + console.error("[TTSStore][player] error", error); + _activeTTS = null; + set({ playState: "stopped" }); + }; + player.onEnd = () => { if (gen !== _sessionGeneration) return; _activeTTS = null; diff --git a/packages/core/src/tts/cloud-tts.ts b/packages/core/src/tts/cloud-tts.ts index 9bd638c77..539b6136d 100644 --- a/packages/core/src/tts/cloud-tts.ts +++ b/packages/core/src/tts/cloud-tts.ts @@ -111,7 +111,11 @@ export async function fetchXiaomiTTSWav(text: string, config: TTSConfig): Promis return base64ToBytes(audioData); } -export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Promise { +export async function fetchOpenAITTSAudio( + text: string, + config: TTSConfig, + signal?: AbortSignal, +): Promise { if (!config.openaiTtsApiKey) throw new Error("OpenAI-compatible TTS API key is required"); const platform = getPlatformService(); @@ -127,6 +131,7 @@ export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Prom voice: config.openaiTtsVoice, }, }), + signal, }); if (!response.ok) { @@ -150,6 +155,7 @@ export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Prom voice: config.openaiTtsVoice, response_format: config.openaiTtsFormat, }), + signal, }); if (!response.ok) { diff --git a/packages/core/src/tts/tts-players.ts b/packages/core/src/tts/tts-players.ts index 04de889c6..c25375646 100644 --- a/packages/core/src/tts/tts-players.ts +++ b/packages/core/src/tts/tts-players.ts @@ -761,9 +761,16 @@ class BufferedAudioTTSPlayer implements ITTSPlayer { onStateChange?: (state: "playing" | "paused" | "stopped") => void; onChunkChange?: (index: number, total: number) => void; + onError?: (error: Error) => void; onEnd?: () => void; - constructor(private fetchAudio: (text: string, config: TTSConfig) => Promise) {} + constructor( + private fetchAudio: ( + text: string, + config: TTSConfig, + signal?: AbortSignal, + ) => Promise, + ) {} get paused() { return this._paused; @@ -814,7 +821,7 @@ class BufferedAudioTTSPlayer implements ITTSPlayer { if (!this._playing || myRun !== this.runId) return; this.abortController = new AbortController(); try { - const bytes = await this.fetchAudio(chunks[i], config); + const bytes = await this.fetchAudio(chunks[i], config, this.abortController?.signal); if (!this._playing || myRun !== this.runId || !this.audioCtx || !this.gainNode) return; const audioBuffer = await this.audioCtx.decodeAudioData(bytesToArrayBuffer(bytes)); if (!this._playing || myRun !== this.runId || !this.audioCtx || !this.gainNode) return; @@ -830,6 +837,13 @@ class BufferedAudioTTSPlayer implements ITTSPlayer { } catch (err) { if (!this._playing || myRun !== this.runId || isTTSAbortError(err)) return; console.error("[Buffered TTS] chunk error:", err); + // Without this, a failing endpoint (an unreachable local server, a bad + // model id) produced no audio and then reported a normal end of + // playback, so the failure was invisible outside the console. + const error = err instanceof Error ? err : new Error(String(err)); + this.stop(); + this.onError?.(error); + return; } } @@ -934,6 +948,7 @@ export class OpenAICompatibleTTSPlayer implements ITTSPlayer { onStateChange?: (state: "playing" | "paused" | "stopped") => void; onChunkChange?: (index: number, total: number) => void; + onError?: (error: Error) => void; onEnd?: () => void; get paused() { @@ -947,6 +962,7 @@ export class OpenAICompatibleTTSPlayer implements ITTSPlayer { : this.bufferedPlayer; this.activePlayer.onStateChange = (state) => this.onStateChange?.(state); this.activePlayer.onChunkChange = (index, total) => this.onChunkChange?.(index, total); + this.activePlayer.onError = (error) => this.onError?.(error); this.activePlayer.onEnd = () => this.onEnd?.(); return this.activePlayer.speak(text, config); } From ad8e20c3b7aa344d8b3c39850eaec34abec43d17 Mon Sep 17 00:00:00 2001 From: Caldalis Date: Mon, 21 Sep 2026 16:48:57 +0800 Subject: [PATCH 4/4] feat(tts): add VoxCPM provider --- .../reader/tts/VoicePickerModal.tsx | 4 +- .../src/lib/platform/rn-tts-factories.ts | 1 + .../platform/track-player-cloud-tts-player.ts | 15 ++- .../app-expo/src/lib/platform/tts-preview.ts | 4 +- .../screens/settings/TTSSettingsScreen.tsx | 115 ++++++++++++++++++ packages/app-expo/src/stores/tts-store.ts | 22 ++++ .../app/src/components/reader/FooterBar.tsx | 11 +- .../app/src/components/reader/TTSControls.tsx | 4 +- .../app/src/components/reader/TTSPage.tsx | 11 +- .../src/components/settings/TTSSettings.tsx | 104 +++++++++++++++- packages/app/src/lib/tts/tts-preview.ts | 7 +- packages/core/src/i18n/locales/en/tts.json | 11 +- packages/core/src/i18n/locales/es/tts.json | 11 +- packages/core/src/i18n/locales/fr/tts.json | 11 +- packages/core/src/i18n/locales/ja/tts.json | 11 +- packages/core/src/i18n/locales/ko/tts.json | 11 +- packages/core/src/i18n/locales/zh-TW/tts.json | 11 +- packages/core/src/i18n/locales/zh/tts.json | 11 +- packages/core/src/stores/tts-store.test.ts | 82 +++++++++++++ packages/core/src/stores/tts-store.ts | 23 ++++ packages/core/src/tts/cloud-tts.test.ts | 88 +++++++++++++- packages/core/src/tts/cloud-tts.ts | 47 +++++++ packages/core/src/tts/display.ts | 4 + packages/core/src/tts/index.ts | 9 ++ packages/core/src/tts/respeak.test.ts | 29 +++++ packages/core/src/tts/respeak.ts | 9 ++ packages/core/src/tts/tts-players.ts | 21 +++- packages/core/src/tts/types.test.ts | 60 +++++++++ packages/core/src/tts/types.ts | 85 ++++++++++++- 29 files changed, 808 insertions(+), 24 deletions(-) diff --git a/packages/app-expo/src/components/reader/tts/VoicePickerModal.tsx b/packages/app-expo/src/components/reader/tts/VoicePickerModal.tsx index 01c586616..97876fcba 100644 --- a/packages/app-expo/src/components/reader/tts/VoicePickerModal.tsx +++ b/packages/app-expo/src/components/reader/tts/VoicePickerModal.tsx @@ -91,7 +91,9 @@ export function VoicePickerModal({ {/* Engine selector */} - {(["edge", "dashscope", "xiaomi", "openai-compatible", "system"] as const).map((eng) => { + {( + ["edge", "dashscope", "xiaomi", "openai-compatible", "voxcpm", "system"] as const + ).map((eng) => { const isActive = config.engine === eng; const label = eng === "edge" diff --git a/packages/app-expo/src/lib/platform/rn-tts-factories.ts b/packages/app-expo/src/lib/platform/rn-tts-factories.ts index bd8c43655..4556452bd 100644 --- a/packages/app-expo/src/lib/platform/rn-tts-factories.ts +++ b/packages/app-expo/src/lib/platform/rn-tts-factories.ts @@ -28,4 +28,5 @@ export const rnTTSPlayerFactories: TTSPlayerFactories = { createDashScopeTTS: () => new TrackPlayerDashScopeTTSPlayer(), createXiaomiTTS: () => new TrackPlayerCloudTTSPlayer(), createOpenAICompatibleTTS: () => new TrackPlayerCloudTTSPlayer(), + createVoxCPMTTS: () => new TrackPlayerCloudTTSPlayer(), }; diff --git a/packages/app-expo/src/lib/platform/track-player-cloud-tts-player.ts b/packages/app-expo/src/lib/platform/track-player-cloud-tts-player.ts index 5a7ab70ba..4c43bb50d 100644 --- a/packages/app-expo/src/lib/platform/track-player-cloud-tts-player.ts +++ b/packages/app-expo/src/lib/platform/track-player-cloud-tts-player.ts @@ -2,6 +2,7 @@ import { type ITTSPlayer, type TTSConfig, fetchOpenAITTSAudio, + fetchVoxCPMAudio, fetchXiaomiTTSWav, isTTSAbortError, splitIntoChunks, @@ -25,12 +26,21 @@ const DEFAULT_ARTWORK = (() => { function extensionForConfig(config: TTSConfig): string { if (config.engine === "xiaomi") return "wav"; + if (config.engine === "voxcpm") { + return config.voxcpmFormat === "pcm16" ? "wav" : config.voxcpmFormat || "wav"; + } if (config.openaiTtsEndpoint === "chat-completions") { return config.openaiTtsFormat === "pcm16" ? "wav" : config.openaiTtsFormat; } return config.openaiTtsFormat || "mp3"; } +function fetchChunkAudio(text: string, config: TTSConfig): Promise { + if (config.engine === "xiaomi") return fetchXiaomiTTSWav(text, config); + if (config.engine === "voxcpm") return fetchVoxCPMAudio(text, config); + return fetchOpenAITTSAudio(text, config); +} + export class TrackPlayerCloudTTSPlayer implements ITTSPlayer { onStateChange?: (state: "playing" | "paused" | "stopped") => void; onChunkChange?: (index: number, total: number) => void; @@ -264,10 +274,7 @@ export class TrackPlayerCloudTTSPlayer implements ITTSPlayer { private async _fetchChunkFile(index: number, gen: number): Promise { if (this._stopped || gen !== this._speakGen || !this._config) throw new Error("aborted"); const config = this._config; - const bytes = - config.engine === "xiaomi" - ? await fetchXiaomiTTSWav(this._chunks[index], config) - : await fetchOpenAITTSAudio(this._chunks[index], config); + const bytes = await fetchChunkAudio(this._chunks[index], config); if (this._stopped || gen !== this._speakGen) throw new Error("aborted"); const ext = extensionForConfig(config); diff --git a/packages/app-expo/src/lib/platform/tts-preview.ts b/packages/app-expo/src/lib/platform/tts-preview.ts index dd3a5e167..16f360b52 100644 --- a/packages/app-expo/src/lib/platform/tts-preview.ts +++ b/packages/app-expo/src/lib/platform/tts-preview.ts @@ -37,7 +37,9 @@ export async function previewTTSConfig( ? edgePreviewPlayer : config.engine === "dashscope" ? dashscopePreviewPlayer - : config.engine === "xiaomi" || config.engine === "openai-compatible" + : config.engine === "xiaomi" || + config.engine === "openai-compatible" || + config.engine === "voxcpm" ? cloudPreviewPlayer : systemPreviewPlayer; player.onStateChange = undefined; diff --git a/packages/app-expo/src/screens/settings/TTSSettingsScreen.tsx b/packages/app-expo/src/screens/settings/TTSSettingsScreen.tsx index 923ecbec3..417ba3b9f 100644 --- a/packages/app-expo/src/screens/settings/TTSSettingsScreen.tsx +++ b/packages/app-expo/src/screens/settings/TTSSettingsScreen.tsx @@ -12,6 +12,9 @@ import { previewTTSConfig, stopTTSPreview } from "@/lib/platform/tts-preview"; import { useTTSStore } from "@/stores"; import { DASHSCOPE_VOICES, + DEFAULT_VOXCPM_BASE_URL, + DEFAULT_VOXCPM_MODEL, + DEFAULT_VOXCPM_VOICE, DEFAULT_XIAOMI_STYLE_PROMPT, EDGE_TTS_VOICES, type TTSProfile, @@ -573,6 +576,113 @@ export default function TTSSettingsScreen() { )} + + {config.engine === "voxcpm" && ( + <> + + {t("tts.baseUrl", "Base URL")} + { + updateActiveProfile({ baseUrl: v }); + updateConfig({ voxcpmBaseUrl: v }); + }} + placeholder={DEFAULT_VOXCPM_BASE_URL} + placeholderTextColor={colors.mutedForeground} + autoCapitalize="none" + /> + {t("tts.voxcpmBaseUrlHint", "")} + + + {t("tts.model", "Model")} + { + updateActiveProfile({ model: v }); + updateConfig({ voxcpmModel: v }); + }} + placeholder={DEFAULT_VOXCPM_MODEL} + placeholderTextColor={colors.mutedForeground} + autoCapitalize="none" + /> + + + {t("tts.voice", "语音")} + { + updateActiveProfile({ voice: v }); + updateConfig({ voxcpmVoice: v }); + }} + placeholder={DEFAULT_VOXCPM_VOICE} + placeholderTextColor={colors.mutedForeground} + autoCapitalize="none" + /> + + + {t("tts.voiceDesign", "Voice design")} + { + updateActiveProfile({ stylePrompt: v }); + updateConfig({ voxcpmVoiceDesign: v }); + }} + multiline + placeholder={t("tts.voiceDesignPlaceholder", "")} + placeholderTextColor={colors.mutedForeground} + /> + {t("tts.voiceDesignHint", "")} + + + {t("tts.format", "Format")} + + {(["wav", "mp3"] as const).map((format) => { + const active = config.voxcpmFormat === format; + return ( + { + updateActiveProfile({ format }); + updateConfig({ voxcpmFormat: format }); + }} + activeOpacity={0.7} + > + + {format} + + + ); + })} + + + + + {t("tts.apiKeyOptional", "API Key (optional)")} + + { + updateActiveProfile({ apiKey: v }); + updateConfig({ voxcpmApiKey: v }); + }} + placeholder="" + placeholderTextColor={colors.mutedForeground} + autoCapitalize="none" + /> + + + )} {/* Rate & Pitch */} @@ -782,6 +892,11 @@ const makeStyles = (colors: ThemeColors) => fontWeight: fontWeight.medium, color: colors.foreground, }, + fieldHint: { + fontSize: fontSize.xs, + color: colors.mutedForeground, + lineHeight: 16, + }, input: { borderRadius: radius.lg, borderWidth: 1, diff --git a/packages/app-expo/src/stores/tts-store.ts b/packages/app-expo/src/stores/tts-store.ts index 5edfe41c0..fe45202e9 100644 --- a/packages/app-expo/src/stores/tts-store.ts +++ b/packages/app-expo/src/stores/tts-store.ts @@ -28,6 +28,7 @@ export interface TTSPlayerFactories { createDashScopeTTS: () => ITTSPlayer; createXiaomiTTS: () => ITTSPlayer; createOpenAICompatibleTTS: () => ITTSPlayer; + createVoxCPMTTS: () => ITTSPlayer; } const defaultFactories: TTSPlayerFactories = { @@ -44,6 +45,7 @@ const defaultFactories: TTSPlayerFactories = { createDashScopeTTS: () => new TrackPlayerDashScopeTTSPlayer(), createXiaomiTTS: () => new TrackPlayerCloudTTSPlayer(), createOpenAICompatibleTTS: () => new TrackPlayerCloudTTSPlayer(), + createVoxCPMTTS: () => new TrackPlayerCloudTTSPlayer(), }; let _factories: TTSPlayerFactories = defaultFactories; @@ -52,6 +54,7 @@ let _edgeTTS: ITTSPlayer | null = null; let _dashscopeTTS: ITTSPlayer | null = null; let _xiaomiTTS: ITTSPlayer | null = null; let _openAICompatibleTTS: ITTSPlayer | null = null; +let _voxcpmTTS: ITTSPlayer | null = null; let _activeTTS: ITTSPlayer | null = null; let _sessionSegments: string[] = []; @@ -86,6 +89,11 @@ function getOpenAICompatibleTTS(): ITTSPlayer { return _openAICompatibleTTS; } +function getVoxCPMTTS(): ITTSPlayer { + if (!_voxcpmTTS) _voxcpmTTS = _factories.createVoxCPMTTS(); + return _voxcpmTTS; +} + function clearSleepTimerHandle(): void { if (_sleepTimerHandle) { clearTimeout(_sleepTimerHandle); @@ -133,6 +141,7 @@ function detachAndStopAllPlayers(): void { detachAndStopPlayer(_dashscopeTTS); detachAndStopPlayer(_xiaomiTTS); detachAndStopPlayer(_openAICompatibleTTS); + detachAndStopPlayer(_voxcpmTTS); } function normalizeSegments(text: string | string[]): string[] { @@ -183,6 +192,15 @@ function syncProfileUpdatesFromLegacyFields( if (updates.openaiTtsStylePrompt !== undefined) { profileUpdates.stylePrompt = updates.openaiTtsStylePrompt; } + } else if (targetProvider === "voxcpm") { + if (updates.voxcpmBaseUrl !== undefined) profileUpdates.baseUrl = updates.voxcpmBaseUrl; + if (updates.voxcpmApiKey !== undefined) profileUpdates.apiKey = updates.voxcpmApiKey; + if (updates.voxcpmModel !== undefined) profileUpdates.model = updates.voxcpmModel; + if (updates.voxcpmVoice !== undefined) profileUpdates.voice = updates.voxcpmVoice; + if (updates.voxcpmFormat !== undefined) profileUpdates.format = updates.voxcpmFormat; + if (updates.voxcpmVoiceDesign !== undefined) { + profileUpdates.stylePrompt = updates.voxcpmVoiceDesign; + } } if (Object.keys(profileUpdates).length === 0) return updates; @@ -217,6 +235,9 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer { if (config.engine === "openai-compatible") { return getOpenAICompatibleTTS(); } + if (config.engine === "voxcpm") { + return getVoxCPMTTS(); + } return getSystemTTS(); } @@ -687,4 +708,5 @@ export function setTTSPlayerFactories(factories: Partial): v _dashscopeTTS = null; _xiaomiTTS = null; _openAICompatibleTTS = null; + _voxcpmTTS = null; } diff --git a/packages/app/src/components/reader/FooterBar.tsx b/packages/app/src/components/reader/FooterBar.tsx index d376f123f..e5665cb8c 100644 --- a/packages/app/src/components/reader/FooterBar.tsx +++ b/packages/app/src/components/reader/FooterBar.tsx @@ -154,7 +154,16 @@ export function FooterBar({
{t("tts.engine")}
- {(["edge", "system", "dashscope", "xiaomi", "openai-compatible"] as TTSEngine[]).map((eng) => ( + {( + [ + "edge", + "system", + "dashscope", + "xiaomi", + "openai-compatible", + "voxcpm", + ] as TTSEngine[] + ).map((eng) => (
- {(["edge", "dashscope", "xiaomi", "openai-compatible", "system"] as const).map((eng) => { + {( + [ + "edge", + "dashscope", + "xiaomi", + "openai-compatible", + "voxcpm", + "system", + ] as const + ).map((eng) => { const isActive = config.engine === eng; const label = eng === "edge" diff --git a/packages/app/src/components/settings/TTSSettings.tsx b/packages/app/src/components/settings/TTSSettings.tsx index f94dc72c7..b7cffeddb 100644 --- a/packages/app/src/components/settings/TTSSettings.tsx +++ b/packages/app/src/components/settings/TTSSettings.tsx @@ -24,6 +24,9 @@ import { import { previewTTSConfig, stopTTSPreview } from "@/lib/tts/tts-preview"; import { useTTSStore } from "@/stores/tts-store"; import { + DEFAULT_VOXCPM_BASE_URL, + DEFAULT_VOXCPM_MODEL, + DEFAULT_VOXCPM_VOICE, DEFAULT_XIAOMI_STYLE_PROMPT, getActiveTTSProfile, getLocaleDisplayLabel, @@ -32,7 +35,7 @@ import { type TTSProviderType, type TTSProfile, } from "@readany/core/tts"; -import { Cloud, Headphones, Mic, Play, Settings2, Square, Zap } from "lucide-react"; +import { Cloud, Headphones, Mic, Play, Settings2, Sparkles, Square, Zap } from "lucide-react"; import type { TFunction } from "i18next"; /** * TTSSettings — TTS configuration panel in the settings dialog. @@ -164,7 +167,9 @@ export function TTSSettings() { ? Mic : activeProfile.provider === "xiaomi" ? Cloud - : Settings2; + : activeProfile.provider === "voxcpm" + ? Sparkles + : Settings2; const ProviderIcon = providerIcon; const selectProfile = (profileId: string) => { @@ -575,6 +580,101 @@ export function TTSSettings() { )} )} + + {config.engine === "voxcpm" && ( + <> +
+ {t("tts.baseUrl", "Base URL")} + { + updateActiveProfile({ baseUrl: e.target.value }); + updateConfig({ voxcpmBaseUrl: e.target.value }); + }} + placeholder={DEFAULT_VOXCPM_BASE_URL} + /> +

+ {t("tts.voxcpmBaseUrlHint", "")} +

+
+
+
+ {t("tts.model", "Model")} + { + updateActiveProfile({ model: e.target.value }); + updateConfig({ voxcpmModel: e.target.value }); + }} + placeholder={DEFAULT_VOXCPM_MODEL} + /> +
+
+ {t("tts.voice")} + { + updateActiveProfile({ voice: e.target.value }); + updateConfig({ voxcpmVoice: e.target.value }); + }} + placeholder={DEFAULT_VOXCPM_VOICE} + /> +
+
+
+ + {t("tts.voiceDesign", "Voice design")} + +