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 4a3d11129..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(); } @@ -380,7 +401,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: "", @@ -683,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")} + +