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) => ( {t("tts.engine")} - {(["system", "dashscope", "xiaomi", "openai-compatible"] as TTSEngine[]).map((eng) => ( + {( + ["system", "dashscope", "xiaomi", "openai-compatible", "voxcpm"] as TTSEngine[] + ).map((eng) => ( {t("tts.selectEngine")} - {(["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")} + + { + updateActiveProfile({ stylePrompt: e.target.value }); + updateConfig({ voxcpmVoiceDesign: e.target.value }); + }} + placeholder={t("tts.voiceDesignPlaceholder", "")} + /> + + {t("tts.voiceDesignHint", "")} + + + + {t("tts.format", "Format")} + { + const format = v as "mp3" | "wav"; + updateActiveProfile({ format }); + updateConfig({ voxcpmFormat: format }); + }} + > + + + + + wav + mp3 + + + + + + {t("tts.apiKeyOptional", "API Key (optional)")} + + { + updateActiveProfile({ apiKey: e.target.value }); + updateConfig({ voxcpmApiKey: e.target.value }); + }} + /> + + > + )} diff --git a/packages/app/src/lib/tts/tts-preview.ts b/packages/app/src/lib/tts/tts-preview.ts index 071fb7d52..e6a01d767 100644 --- a/packages/app/src/lib/tts/tts-preview.ts +++ b/packages/app/src/lib/tts/tts-preview.ts @@ -4,6 +4,7 @@ import { DashScopeTTSPlayer, EdgeTTSPlayer, OpenAICompatibleTTSPlayer, + VoxCPMTTSPlayer, XiaomiTTSPlayer, } from "@readany/core/tts"; @@ -12,6 +13,7 @@ const edgePreviewPlayer = new EdgeTTSPlayer(); const dashscopePreviewPlayer = new DashScopeTTSPlayer(); const xiaomiPreviewPlayer = new XiaomiTTSPlayer(); const openAICompatiblePreviewPlayer = new OpenAICompatibleTTSPlayer(); +const voxcpmPreviewPlayer = new VoxCPMTTSPlayer(); export interface TTSPreviewCallbacks { onStateChange?: (state: "playing" | "paused" | "stopped") => void; @@ -32,6 +34,7 @@ export function stopTTSPreview() { stopPlayer(dashscopePreviewPlayer); stopPlayer(xiaomiPreviewPlayer); stopPlayer(openAICompatiblePreviewPlayer); + stopPlayer(voxcpmPreviewPlayer); } export async function previewTTSConfig( @@ -49,7 +52,9 @@ export async function previewTTSConfig( ? xiaomiPreviewPlayer : config.engine === "openai-compatible" ? openAICompatiblePreviewPlayer - : systemPreviewPlayer; + : config.engine === "voxcpm" + ? voxcpmPreviewPlayer + : systemPreviewPlayer; player.onStateChange = undefined; player.onEnd = undefined; try { diff --git a/packages/core/src/i18n/locales/en/tts.json b/packages/core/src/i18n/locales/en/tts.json index ba063c7bf..0a30228e0 100644 --- a/packages/core/src/i18n/locales/en/tts.json +++ b/packages/core/src/i18n/locales/en/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "OpenAI Compatible", "description": "Custom OpenAI audio/speech or chat audio endpoint." + }, + "voxcpm": { + "label": "VoxCPM", + "description": "OpenBMB VoxCPM on your own OpenAI-compatible server." } }, "providerCategory": { @@ -139,7 +143,12 @@ "systemVoiceNote": "Choose the system voice in your device settings", "browserVoiceNote": "Choose the system voice in your device settings", "stop": "Stop", - "noTextFound": "No readable text found on this page" + "noTextFound": "No readable text found on this page", + "voiceDesign": "Voice design", + "voiceDesignPlaceholder": "A young woman, gentle and warm", + "voiceDesignHint": "Describe the voice you want in plain words. Leave empty to use the model's default voice.", + "apiKeyOptional": "API Key (optional)", + "voxcpmBaseUrlHint": "Point this at a VoxCPM server you run, for example vllm serve openbmb/VoxCPM2 --omni --port 8000." }, "fonts": { "title": "Fonts", diff --git a/packages/core/src/i18n/locales/es/tts.json b/packages/core/src/i18n/locales/es/tts.json index f4b260e6a..b113c4ea1 100644 --- a/packages/core/src/i18n/locales/es/tts.json +++ b/packages/core/src/i18n/locales/es/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "Compatible con OpenAI", "description": "Endpoint compatible con OpenAI audio/speech o audio de chat." + }, + "voxcpm": { + "label": "VoxCPM", + "description": "VoxCPM de OpenBMB, en tu propio servidor compatible con OpenAI." } }, "providerCategory": { @@ -138,7 +142,12 @@ "engineDescDashscope": "Alibaba Tongyi · Chino optimizado", "engineDescSystem": "Sistema integrado · Gratis", "player": "Reproductor TTS", - "listeningToBook": "Reproduciendo audiolibro" + "listeningToBook": "Reproduciendo audiolibro", + "voiceDesign": "Descripción de la voz", + "voiceDesignPlaceholder": "Una mujer joven, suave y cálida", + "voiceDesignHint": "Describe la voz que quieres con tus palabras. Déjalo vacío para usar la voz predeterminada del modelo.", + "apiKeyOptional": "Clave API (opcional)", + "voxcpmBaseUrlHint": "Apunta esto a tu propio servidor VoxCPM, por ejemplo vllm serve openbmb/VoxCPM2 --omni --port 8000." }, "fonts": { "title": "Fuentes", diff --git a/packages/core/src/i18n/locales/fr/tts.json b/packages/core/src/i18n/locales/fr/tts.json index bf6667fca..df8858633 100644 --- a/packages/core/src/i18n/locales/fr/tts.json +++ b/packages/core/src/i18n/locales/fr/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "Compatible OpenAI", "description": "Endpoint compatible OpenAI audio/speech ou audio de chat." + }, + "voxcpm": { + "label": "VoxCPM", + "description": "VoxCPM d'OpenBMB, via votre propre serveur compatible OpenAI." } }, "providerCategory": { @@ -138,7 +142,12 @@ "engineDescDashscope": "Alibaba Tongyi · Chinois optimisé", "engineDescSystem": "Système intégré · Gratuit", "player": "Lecteur TTS", - "listeningToBook": "Lecture audio en cours" + "listeningToBook": "Lecture audio en cours", + "voiceDesign": "Description de la voix", + "voiceDesignPlaceholder": "Une jeune femme, douce et chaleureuse", + "voiceDesignHint": "Décrivez la voix souhaitée en quelques mots. Laissez vide pour la voix par défaut du modèle.", + "apiKeyOptional": "Clé API (facultative)", + "voxcpmBaseUrlHint": "Indiquez l'adresse de votre serveur VoxCPM, par exemple vllm serve openbmb/VoxCPM2 --omni --port 8000." }, "fonts": { "title": "Polices", diff --git a/packages/core/src/i18n/locales/ja/tts.json b/packages/core/src/i18n/locales/ja/tts.json index d32a45ca6..0ee121947 100644 --- a/packages/core/src/i18n/locales/ja/tts.json +++ b/packages/core/src/i18n/locales/ja/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "OpenAI互換", "description": "OpenAI audio/speech またはチャット音声互換エンドポイント。" + }, + "voxcpm": { + "label": "VoxCPM", + "description": "OpenBMB の VoxCPM。自分で立てた OpenAI 互換サーバーに接続します。" } }, "providerCategory": { @@ -138,7 +142,12 @@ "engineDescDashscope": "アリババクラウド通義 · 中国語最適化", "engineDescSystem": "システム内蔵 · 無料", "player": "TTSプレーヤー", - "listeningToBook": "オーディオブック再生中" + "listeningToBook": "オーディオブック再生中", + "voiceDesign": "声のデザイン", + "voiceDesignPlaceholder": "若い女性の声、優しく穏やか", + "voiceDesignHint": "希望する声を言葉で説明してください。空欄の場合はモデルの既定の声を使います。", + "apiKeyOptional": "API キー(任意)", + "voxcpmBaseUrlHint": "自分で立てた VoxCPM サーバーのアドレスを入力します。例: vllm serve openbmb/VoxCPM2 --omni --port 8000" }, "fonts": { "title": "フォント", diff --git a/packages/core/src/i18n/locales/ko/tts.json b/packages/core/src/i18n/locales/ko/tts.json index 6879e47fd..596da9879 100644 --- a/packages/core/src/i18n/locales/ko/tts.json +++ b/packages/core/src/i18n/locales/ko/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "OpenAI 호환", "description": "OpenAI audio/speech 또는 채팅 오디오 호환 엔드포인트." + }, + "voxcpm": { + "label": "VoxCPM", + "description": "OpenBMB VoxCPM. 직접 운영하는 OpenAI 호환 서버에 연결합니다." } }, "providerCategory": { @@ -138,7 +142,12 @@ "engineDescDashscope": "알리바바클라우드 통의 · 중국어 최적화", "engineDescSystem": "시스템 내장 · 무료", "player": "TTS 플레이어", - "listeningToBook": "오디오북 재생 중" + "listeningToBook": "오디오북 재생 중", + "voiceDesign": "음색 설명", + "voiceDesignPlaceholder": "젊은 여성 목소리, 부드럽고 따뜻하게", + "voiceDesignHint": "원하는 음색을 문장으로 설명하세요. 비워 두면 모델 기본 음색을 사용합니다.", + "apiKeyOptional": "API 키 (선택)", + "voxcpmBaseUrlHint": "직접 운영하는 VoxCPM 서버 주소를 입력하세요. 예: vllm serve openbmb/VoxCPM2 --omni --port 8000" }, "fonts": { "title": "글꼴", diff --git a/packages/core/src/i18n/locales/zh-TW/tts.json b/packages/core/src/i18n/locales/zh-TW/tts.json index a7240b0d9..39db1e9e5 100644 --- a/packages/core/src/i18n/locales/zh-TW/tts.json +++ b/packages/core/src/i18n/locales/zh-TW/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "OpenAI 相容", "description": "自訂 OpenAI audio/speech 或聊天音訊介面" + }, + "voxcpm": { + "label": "VoxCPM", + "description": "OpenBMB VoxCPM,連接你自建的 OpenAI 相容服務。" } }, "providerCategory": { @@ -138,7 +142,12 @@ "nextChapter": "下一章", "systemVoiceNote": "系統音色請在裝置系統設定中選擇", "browserVoiceNote": "系統音色請在裝置系統設定中選擇", - "stop": "停止" + "stop": "停止", + "voiceDesign": "音色描述", + "voiceDesignPlaceholder": "年輕女聲,溫柔親切", + "voiceDesignHint": "用一句話描述想要的音色。留空則使用模型預設音色。", + "apiKeyOptional": "API 金鑰(選填)", + "voxcpmBaseUrlHint": "填寫你自行部署的 VoxCPM 服務位址,例如先執行 vllm serve openbmb/VoxCPM2 --omni --port 8000。" }, "fonts": { "title": "字型", diff --git a/packages/core/src/i18n/locales/zh/tts.json b/packages/core/src/i18n/locales/zh/tts.json index 7464993c6..cc926872a 100644 --- a/packages/core/src/i18n/locales/zh/tts.json +++ b/packages/core/src/i18n/locales/zh/tts.json @@ -32,6 +32,10 @@ "openai-compatible": { "label": "OpenAI 兼容", "description": "自定义 OpenAI audio/speech 或聊天音频接口" + }, + "voxcpm": { + "label": "VoxCPM", + "description": "OpenBMB VoxCPM,连接你自建的 OpenAI 兼容服务。" } }, "providerCategory": { @@ -139,7 +143,12 @@ "systemVoiceNote": "系统音色请在设备系统设置中选择", "browserVoiceNote": "系统音色请在设备系统设置中选择", "stop": "停止", - "noTextFound": "当前页面未找到可朗读的文本" + "noTextFound": "当前页面未找到可朗读的文本", + "voiceDesign": "音色描述", + "voiceDesignPlaceholder": "年轻女声,温柔亲切", + "voiceDesignHint": "用一句话描述想要的音色。留空则使用模型默认音色。", + "apiKeyOptional": "API 密钥(可选)", + "voxcpmBaseUrlHint": "填写你自己部署的 VoxCPM 服务地址,例如先运行 vllm serve openbmb/VoxCPM2 --omni --port 8000。" }, "fonts": { "title": "字体", diff --git a/packages/core/src/stores/tts-store.test.ts b/packages/core/src/stores/tts-store.test.ts index 15ffbc71b..dea82cc6e 100644 --- a/packages/core/src/stores/tts-store.test.ts +++ b/packages/core/src/stores/tts-store.test.ts @@ -292,3 +292,85 @@ describe("useTTSStore — re-speak on synth change (#370)", () => { expect(useTTSStore.getState().config.rate).toBe(1.3); }); }); + +describe("useTTSStore — VoxCPM engine dispatch", () => { + let voxcpmPlayer: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + systemPlayer = createMockPlayer(); + voxcpmPlayer = createMockPlayer(); + setTTSPlayerFactories({ + createSystemTTS: () => systemPlayer, + createVoxCPMTTS: () => voxcpmPlayer, + }); + resetStore(); + useTTSStore.getState().stop(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("routes voxcpm playback to the VoxCPM player, not the system fallback", () => { + useTTSStore.getState().updateConfig({ engine: "voxcpm" }); + useTTSStore.getState().play(["s0", "s1"]); + + expect(voxcpmPlayer.speak).toHaveBeenCalledTimes(1); + expect(systemPlayer.speak).not.toHaveBeenCalled(); + }); + + it("plays without an API key configured", () => { + useTTSStore.getState().updateConfig({ engine: "voxcpm", voxcpmApiKey: "" }); + useTTSStore.getState().play(["s0"]); + + const [, config] = voxcpmPlayer.speak.mock.calls[0]; + expect((config as TTSConfig).voxcpmApiKey).toBe(""); + expect((config as TTSConfig).engine).toBe("voxcpm"); + }); + + it("stops instead of reporting a normal end when synthesis fails", () => { + // A VoxCPM server that is not running is the likeliest first-run state, + // so a failure must not look like playback finishing successfully. + const onEnd = vi.fn(); + voxcpmPlayer.speak.mockImplementation(() => { + voxcpmPlayer.onError?.(new Error("VoxCPM TTS failed: 502")); + }); + + useTTSStore.setState({ onEnd }); + useTTSStore.getState().updateConfig({ engine: "voxcpm" }); + useTTSStore.getState().play(["s0", "s1"]); + + expect(useTTSStore.getState().playState).toBe("stopped"); + expect(onEnd).not.toHaveBeenCalled(); + }); + + it("re-speaks from the current sentence after the voice design changes", () => { + useTTSStore.getState().updateConfig({ engine: "voxcpm" }); + useTTSStore.getState().play(["s0", "s1"]); + expect(voxcpmPlayer.speak).toHaveBeenCalledTimes(1); + + useTTSStore.getState().updateConfig({ voxcpmVoiceDesign: "a calm older man" }); + vi.advanceTimersByTime(250); + + expect(voxcpmPlayer.speak).toHaveBeenCalledTimes(2); + const [, config] = voxcpmPlayer.speak.mock.calls[1]; + expect((config as TTSConfig).voxcpmVoiceDesign).toBe("a calm older man"); + }); +}); + +describe("useTTSStore — initial state before any persistence", () => { + it("exposes the built-in voice profiles on a fresh install", () => { + // persist.ts only runs normalizeTTSConfig when a config file already + // exists on disk, so an un-normalized initial state left the settings + // provider list empty until the user changed something. + const { profiles, activeProfileId } = useTTSStore.getState().config; + + expect(profiles.length).toBeGreaterThan(0); + expect(profiles.map((p) => p.provider)).toEqual( + expect.arrayContaining(["edge", "system", "dashscope", "xiaomi", "openai-compatible", "voxcpm"]), + ); + expect(profiles.some((p) => p.id === activeProfileId)).toBe(true); + }); +}); diff --git a/packages/core/src/stores/tts-store.ts b/packages/core/src/stores/tts-store.ts index 20a442132..52d5fe198 100644 --- a/packages/core/src/stores/tts-store.ts +++ b/packages/core/src/stores/tts-store.ts @@ -21,6 +21,7 @@ import { DashScopeTTSPlayer, EdgeTTSPlayer, OpenAICompatibleTTSPlayer, + VoxCPMTTSPlayer, XiaomiTTSPlayer, } from "../tts/tts-players"; import type { ITTSPlayer, TTSConfig, TTSProfile } from "../tts/types"; @@ -38,6 +39,7 @@ export interface TTSPlayerFactories { createDashScopeTTS: () => ITTSPlayer; createXiaomiTTS: () => ITTSPlayer; createOpenAICompatibleTTS: () => ITTSPlayer; + createVoxCPMTTS: () => ITTSPlayer; } /** Default Web-based factories */ @@ -47,6 +49,7 @@ const defaultFactories: TTSPlayerFactories = { createDashScopeTTS: () => new DashScopeTTSPlayer(), createXiaomiTTS: () => new XiaomiTTSPlayer(), createOpenAICompatibleTTS: () => new OpenAICompatibleTTSPlayer(), + createVoxCPMTTS: () => new VoxCPMTTSPlayer(), }; let _factories: TTSPlayerFactories = defaultFactories; @@ -70,6 +73,7 @@ export function setTTSPlayerFactories(factories: Partial): v _dashscopeTTS = null; _xiaomiTTS = null; _openAICompatibleTTS = null; + _voxcpmTTS = null; } /** Lazily-created singleton TTS player instances */ @@ -78,6 +82,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[] = []; let _sessionCurrentIndex = 0; @@ -115,6 +120,11 @@ function getOpenAICompatibleTTS(): ITTSPlayer { return _openAICompatibleTTS; } +function getVoxCPMTTS(): ITTSPlayer { + if (!_voxcpmTTS) _voxcpmTTS = _factories.createVoxCPMTTS(); + return _voxcpmTTS; +} + function clearSleepTimerHandle(): void { if (_sleepTimerHandle) { clearTimeout(_sleepTimerHandle); @@ -174,6 +184,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; @@ -197,6 +216,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(); @@ -212,6 +232,7 @@ function detachAndStopAllPlayers(): void { detachAndStopPlayer(_dashscopeTTS); detachAndStopPlayer(_xiaomiTTS); detachAndStopPlayer(_openAICompatibleTTS); + detachAndStopPlayer(_voxcpmTTS); } function getPlayerForConfig(config: TTSConfig): ITTSPlayer { @@ -227,6 +248,9 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer { if (config.engine === "openai-compatible") { return getOpenAICompatibleTTS(); } + if (config.engine === "voxcpm") { + return getVoxCPMTTS(); + } return getSystemTTS(); } @@ -261,6 +285,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; @@ -344,7 +375,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, diff --git a/packages/core/src/tts/cloud-tts.test.ts b/packages/core/src/tts/cloud-tts.test.ts index 20659880d..8482b643d 100644 --- a/packages/core/src/tts/cloud-tts.test.ts +++ b/packages/core/src/tts/cloud-tts.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildXiaomiTTSUrl, isTTSAbortError } from "./cloud-tts"; +import { setPlatformService } from "../services/platform"; +import { + buildVoxCPMTTSUrl, + buildXiaomiTTSUrl, + fetchVoxCPMAudio, + isTTSAbortError, +} from "./cloud-tts"; import { DEFAULT_TTS_CONFIG } from "./types"; describe("buildXiaomiTTSUrl", () => { @@ -33,3 +39,81 @@ describe("isTTSAbortError", () => { expect(isTTSAbortError(new Error("Xiaomi MiMo TTS failed: 400"))).toBe(false); }); }); + +describe("buildVoxCPMTTSUrl", () => { + it("defaults to a locally served VoxCPM", () => { + expect(buildVoxCPMTTSUrl(DEFAULT_TTS_CONFIG)).toBe("http://localhost:8000/v1/audio/speech"); + }); + + it("tolerates a trailing slash on the base URL", () => { + expect(buildVoxCPMTTSUrl({ voxcpmBaseUrl: "https://tts.example.com/v1/" })).toBe( + "https://tts.example.com/v1/audio/speech", + ); + }); +}); + +describe("fetchVoxCPMAudio", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function stubPlatform() { + const fetchMock = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + })); + setPlatformService({ fetch: fetchMock } as never); + return fetchMock; + } + + it("posts the OpenAI audio/speech shape and omits auth when no key is set", async () => { + const fetchMock = stubPlatform(); + + const bytes = await fetchVoxCPMAudio("Hello", DEFAULT_TTS_CONFIG); + + expect(Array.from(bytes)).toEqual([1, 2, 3]); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("http://localhost:8000/v1/audio/speech"); + expect((init.headers as Record).Authorization).toBeUndefined(); + expect(JSON.parse(init.body as string)).toEqual({ + model: "openbmb/VoxCPM2", + input: "Hello", + voice: "default", + response_format: "wav", + }); + }); + + it("sends a bearer token only when a key is configured", async () => { + const fetchMock = stubPlatform(); + + await fetchVoxCPMAudio("Hello", { ...DEFAULT_TTS_CONFIG, voxcpmApiKey: "k-1" }); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe("Bearer k-1"); + }); + + it("forwards an abort signal so stop() can cancel an in-flight request", async () => { + // The buffered player aborts its controller on stop(). Before this was + // threaded through, that abort reached nothing and a second play stacked + // a concurrent request on top of the first. + const fetchMock = stubPlatform(); + const controller = new AbortController(); + + await fetchVoxCPMAudio("Hello", DEFAULT_TTS_CONFIG, controller.signal); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.signal).toBe(controller.signal); + }); + + it("prefixes the Voice Design description onto the input text", async () => { + const fetchMock = stubPlatform(); + + await fetchVoxCPMAudio("你好", { + ...DEFAULT_TTS_CONFIG, + voxcpmVoiceDesign: "年轻女声,温柔亲切", + }); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string).input).toBe("(年轻女声,温柔亲切)你好"); + }); +}); diff --git a/packages/core/src/tts/cloud-tts.ts b/packages/core/src/tts/cloud-tts.ts index 9bd638c77..37d1a6353 100644 --- a/packages/core/src/tts/cloud-tts.ts +++ b/packages/core/src/tts/cloud-tts.ts @@ -1,5 +1,10 @@ import { getPlatformService } from "../services/platform"; import { + buildVoxCPMInput, + DEFAULT_VOXCPM_BASE_URL, + DEFAULT_VOXCPM_FORMAT, + DEFAULT_VOXCPM_MODEL, + DEFAULT_VOXCPM_VOICE, DEFAULT_XIAOMI_TTS_BASE_URL, normalizeXiaomiTTSVoice, type TTSConfig, @@ -111,7 +116,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 +136,7 @@ export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Prom voice: config.openaiTtsVoice, }, }), + signal, }); if (!response.ok) { @@ -150,6 +160,7 @@ export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Prom voice: config.openaiTtsVoice, response_format: config.openaiTtsFormat, }), + signal, }); if (!response.ok) { @@ -158,3 +169,45 @@ export async function fetchOpenAITTSAudio(text: string, config: TTSConfig): Prom return new Uint8Array(await response.arrayBuffer()); } + +export function buildVoxCPMTTSUrl(config: Pick): string { + return joinUrl(config.voxcpmBaseUrl || DEFAULT_VOXCPM_BASE_URL, "/audio/speech"); +} + +/** + * VoxCPM synthesis over the OpenAI-compatible `/v1/audio/speech` shape, as + * served by vLLM-Omni (`vllm serve openbmb/VoxCPM2 --omni`) or Nano-vLLM-VoxCPM. + * + * Unlike the other cloud providers, the API key is optional: a self-hosted + * server normally has no auth, so the Authorization header is only sent when a + * key is actually configured. + */ +export async function fetchVoxCPMAudio( + text: string, + config: TTSConfig, + signal?: AbortSignal, +): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (config.voxcpmApiKey) { + headers.Authorization = `Bearer ${config.voxcpmApiKey}`; + } + + const platform = getPlatformService(); + const response = await platform.fetch(buildVoxCPMTTSUrl(config), { + method: "POST", + headers, + body: JSON.stringify({ + model: config.voxcpmModel || DEFAULT_VOXCPM_MODEL, + input: buildVoxCPMInput(text, config.voxcpmVoiceDesign), + voice: config.voxcpmVoice || DEFAULT_VOXCPM_VOICE, + response_format: config.voxcpmFormat || DEFAULT_VOXCPM_FORMAT, + }), + signal, + }); + + if (!response.ok) { + throw await buildTTSHttpError("VoxCPM TTS", response); + } + + return new Uint8Array(await response.arrayBuffer()); +} diff --git a/packages/core/src/tts/display.ts b/packages/core/src/tts/display.ts index 4457a3550..33e370a05 100644 --- a/packages/core/src/tts/display.ts +++ b/packages/core/src/tts/display.ts @@ -81,5 +81,9 @@ export function getTTSVoiceLabel(config: TTSConfig): string { return config.openaiTtsVoice || config.openaiTtsModel || "OpenAI Compatible"; } + if (config.engine === "voxcpm") { + return config.voxcpmVoice || config.voxcpmModel || "VoxCPM"; + } + return config.systemVoiceLabel || config.voiceName || "System Voice"; } diff --git a/packages/core/src/tts/index.ts b/packages/core/src/tts/index.ts index 8b0749492..b03830121 100644 --- a/packages/core/src/tts/index.ts +++ b/packages/core/src/tts/index.ts @@ -13,8 +13,13 @@ export type { TTSProviderType, } from "./types"; export { + buildVoxCPMInput, createDefaultTTSProfiles, DEFAULT_TTS_CONFIG, + DEFAULT_VOXCPM_BASE_URL, + DEFAULT_VOXCPM_FORMAT, + DEFAULT_VOXCPM_MODEL, + DEFAULT_VOXCPM_VOICE, DEFAULT_XIAOMI_STYLE_PROMPT, DEFAULT_XIAOMI_TTS_BASE_URL, DEFAULT_XIAOMI_TTS_VOICE, @@ -47,17 +52,21 @@ export type { EdgeTTSVoice, EdgeTTSPayload } from "./edge-tts"; // Players export { BrowserTTSPlayer, + BufferedAudioTTSPlayer, DashScopeTTSPlayer, EdgeTTSPlayer, OpenAICompatibleTTSPlayer, + VoxCPMTTSPlayer, XiaomiTTSPlayer, } from "./tts-players"; export { base64ToBytes, buildOpenAIChatTTSMessages, + buildVoxCPMTTSUrl, buildXiaomiTTSUrl, buildXiaomiTTSMessages, fetchOpenAITTSAudio, + fetchVoxCPMAudio, fetchXiaomiTTSWav, isTTSAbortError, } from "./cloud-tts"; diff --git a/packages/core/src/tts/respeak.test.ts b/packages/core/src/tts/respeak.test.ts index 0eda09801..61c09c418 100644 --- a/packages/core/src/tts/respeak.test.ts +++ b/packages/core/src/tts/respeak.test.ts @@ -92,3 +92,32 @@ describe("isActivePlay", () => { expect(isActivePlay("stopped")).toBe(false); }); }); + +describe("shouldRespeakForSynthChange — VoxCPM", () => { + const base = { ...DEFAULT_TTS_CONFIG, engine: "voxcpm" as const }; + + it("re-speaks when the voice design changes", () => { + expect( + shouldRespeakForSynthChange(base, { ...base, voxcpmVoiceDesign: "a calm older man" }), + ).toBe(true); + }); + + it("re-speaks when the server or model changes", () => { + expect( + shouldRespeakForSynthChange(base, { ...base, voxcpmBaseUrl: "http://10.0.0.2:8000/v1" }), + ).toBe(true); + expect(shouldRespeakForSynthChange(base, { ...base, voxcpmModel: "openbmb/VoxCPM1.5" })).toBe( + true, + ); + }); + + it("does not re-speak when nothing synthesis-affecting changed", () => { + expect(shouldRespeakForSynthChange(base, { ...base })).toBe(false); + }); + + it("does not require an API key, unlike the cloud providers", () => { + expect( + shouldRespeakForSynthChange(base, { ...base, voxcpmVoice: "narrator", voxcpmApiKey: "" }), + ).toBe(true); + }); +}); diff --git a/packages/core/src/tts/respeak.ts b/packages/core/src/tts/respeak.ts index 2a041dd66..8088d8325 100644 --- a/packages/core/src/tts/respeak.ts +++ b/packages/core/src/tts/respeak.ts @@ -34,6 +34,15 @@ export function shouldRespeakForSynthChange(prev: TTSConfig, next: TTSConfig): b next.openaiTtsStylePrompt !== prev.openaiTtsStylePrompt) ); } + if (next.engine === "voxcpm") { + return ( + next.voxcpmBaseUrl !== prev.voxcpmBaseUrl || + next.voxcpmModel !== prev.voxcpmModel || + next.voxcpmVoice !== prev.voxcpmVoice || + next.voxcpmFormat !== prev.voxcpmFormat || + next.voxcpmVoiceDesign !== prev.voxcpmVoiceDesign + ); + } if (next.engine === "edge") { return ( next.edgeVoice !== prev.edgeVoice || next.rate !== prev.rate || next.pitch !== prev.pitch diff --git a/packages/core/src/tts/tts-players.ts b/packages/core/src/tts/tts-players.ts index d6753ede0..108d4968c 100644 --- a/packages/core/src/tts/tts-players.ts +++ b/packages/core/src/tts/tts-players.ts @@ -15,6 +15,7 @@ import { buildXiaomiTTSUrl, buildXiaomiTTSMessages, fetchOpenAITTSAudio, + fetchVoxCPMAudio, isTTSAbortError, } from "./cloud-tts"; import { fetchEdgeTTSAudio } from "./edge-tts"; @@ -744,7 +745,11 @@ export class XiaomiTTSPlayer extends PCMStreamingTTSPlayer { } } -class BufferedAudioTTSPlayer implements ITTSPlayer { +/** + * Fetch a whole audio file per chunk, decode it, and schedule the chunks + * gaplessly. Exported so engines that return complete files can subclass it. + */ +export class BufferedAudioTTSPlayer implements ITTSPlayer { private audioCtx: AudioContext | null = null; private gainNode: GainNode | null = null; private scheduledEnd = 0; @@ -761,9 +766,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; @@ -786,6 +798,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); @@ -804,7 +826,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; @@ -820,6 +842,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; } } @@ -924,6 +953,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() { @@ -937,6 +967,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); } @@ -955,6 +986,20 @@ export class OpenAICompatibleTTSPlayer implements ITTSPlayer { } } +// ── VoxCPM (OpenBMB — self-hosted OpenAI-compatible endpoint) ── + +/** + * VoxCPM returns a whole WAV/MP3 per chunk, so it reuses the buffered player. + * `/v1/audio/speech` has no streaming shape we can consume yet; when vLLM-Omni + * exposes SSE audio deltas this can gain a `PCMStreamingTTSPlayer` path the way + * `OpenAICompatibleTTSPlayer` does. + */ +export class VoxCPMTTSPlayer extends BufferedAudioTTSPlayer { + constructor() { + super(fetchVoxCPMAudio); + } +} + // ── Edge TTS (Microsoft Neural voices — free, high quality) ── export class EdgeTTSPlayer implements ITTSPlayer { diff --git a/packages/core/src/tts/types.test.ts b/packages/core/src/tts/types.test.ts index c10bc27cf..bbf526404 100644 --- a/packages/core/src/tts/types.test.ts +++ b/packages/core/src/tts/types.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_XIAOMI_TTS_VOICE, XIAOMI_TTS_VOICES, + buildVoxCPMInput, normalizeTTSConfig, + normalizeTTSEngine, } from "./types"; describe("normalizeTTSConfig", () => { @@ -96,3 +98,61 @@ describe("normalizeTTSConfig", () => { ]); }); }); + +describe("VoxCPM provider", () => { + it("keeps voxcpm as a known engine instead of falling back to edge", () => { + expect(normalizeTTSEngine("voxcpm")).toBe("voxcpm"); + expect(normalizeTTSEngine("not-a-real-engine")).toBe("edge"); + }); + + it("ships a default VoxCPM profile pointing at a local server", () => { + const config = normalizeTTSConfig({ engine: "voxcpm" }); + + expect(config.activeProfileId).toBe("voxcpm-default"); + expect(config.voxcpmBaseUrl).toBe("http://localhost:8000/v1"); + expect(config.voxcpmModel).toBe("openbmb/VoxCPM2"); + expect(config.voxcpmApiKey).toBe(""); + expect(config.voxcpmFormat).toBe("wav"); + }); + + it("restores persisted VoxCPM profile settings onto the flat config", () => { + const config = normalizeTTSConfig({ + engine: "voxcpm", + activeProfileId: "voxcpm-default", + profiles: [ + { + id: "voxcpm-default", + name: "VoxCPM", + provider: "voxcpm", + baseUrl: "http://192.168.1.9:8000/v1", + model: "openbmb/VoxCPM1.5", + voice: "narrator", + format: "mp3", + stylePrompt: "a calm older man", + }, + ], + }); + + expect(config.voxcpmBaseUrl).toBe("http://192.168.1.9:8000/v1"); + expect(config.voxcpmModel).toBe("openbmb/VoxCPM1.5"); + expect(config.voxcpmVoice).toBe("narrator"); + expect(config.voxcpmFormat).toBe("mp3"); + expect(config.voxcpmVoiceDesign).toBe("a calm older man"); + }); +}); + +describe("buildVoxCPMInput", () => { + it("wraps a bare Voice Design description in parentheses", () => { + expect(buildVoxCPMInput("Hello", "a warm young woman")).toBe("(a warm young woman)Hello"); + }); + + it("leaves an already-parenthesized description untouched", () => { + expect(buildVoxCPMInput("Hello", "(a warm young woman)")).toBe("(a warm young woman)Hello"); + }); + + it("returns the text unchanged when there is no description", () => { + expect(buildVoxCPMInput("Hello", "")).toBe("Hello"); + expect(buildVoxCPMInput("Hello", " ")).toBe("Hello"); + expect(buildVoxCPMInput("Hello", undefined)).toBe("Hello"); + }); +}); diff --git a/packages/core/src/tts/types.ts b/packages/core/src/tts/types.ts index 69915ab93..d51fc5ead 100644 --- a/packages/core/src/tts/types.ts +++ b/packages/core/src/tts/types.ts @@ -2,7 +2,13 @@ * TTS types and constants — shared across all platforms. */ -export type TTSEngine = "system" | "edge" | "dashscope" | "xiaomi" | "openai-compatible"; +export type TTSEngine = + | "system" + | "edge" + | "dashscope" + | "xiaomi" + | "openai-compatible" + | "voxcpm"; export type LegacyTTSEngine = TTSEngine | "browser"; export type TTSProviderType = TTSEngine; export type TTSAudioFormat = "pcm16" | "wav" | "mp3"; @@ -50,6 +56,21 @@ export interface TTSConfig { openaiTtsFormat: TTSAudioFormat; /** Optional style/system prompt for chat-completions audio providers */ openaiTtsStylePrompt: string; + /** VoxCPM OpenAI-compatible base URL (e.g. a local vLLM-Omni server) */ + voxcpmBaseUrl: string; + /** VoxCPM API key. Optional — self-hosted servers usually need none. */ + voxcpmApiKey: string; + /** VoxCPM model id (e.g. "openbmb/VoxCPM2") */ + voxcpmModel: string; + /** VoxCPM voice id, or a precomputed custom voice name */ + voxcpmVoice: string; + /** VoxCPM audio format */ + voxcpmFormat: TTSAudioFormat; + /** + * VoxCPM Voice Design: a natural-language voice description. Sent as a + * parenthesized prefix on the synthesis text, per the VoxCPM2 convention. + */ + voxcpmVoiceDesign: string; /** Saved voice profiles for scalable provider UI. */ profiles: TTSProfile[]; } @@ -135,6 +156,17 @@ export const TTS_PROVIDER_DEFINITIONS: TTSProviderDefinition[] = [ supportsBaseUrl: true, supportsStreaming: true, }, + { + id: "voxcpm", + label: "VoxCPM", + description: "OpenBMB VoxCPM on a self-hosted OpenAI-compatible endpoint.", + category: "custom", + requiresApiKey: false, + supportsVoice: true, + supportsStylePrompt: true, + supportsBaseUrl: true, + supportsStreaming: false, + }, ] as const; export const XIAOMI_TTS_VOICES = [ @@ -153,6 +185,24 @@ export const DEFAULT_XIAOMI_TTS_VOICE = "mimo_default"; export const DEFAULT_XIAOMI_TTS_BASE_URL = "https://api.xiaomimimo.com/v1"; export const DEFAULT_XIAOMI_STYLE_PROMPT = "自然、平稳、适合长时间听书。"; +/** Default endpoint for a locally served VoxCPM (e.g. `vllm serve openbmb/VoxCPM2 --omni`). */ +export const DEFAULT_VOXCPM_BASE_URL = "http://localhost:8000/v1"; +export const DEFAULT_VOXCPM_MODEL = "openbmb/VoxCPM2"; +export const DEFAULT_VOXCPM_VOICE = "default"; +/** VoxCPM emits 48kHz WAV; WAV avoids depending on server-side transcoding. */ +export const DEFAULT_VOXCPM_FORMAT: TTSAudioFormat = "wav"; + +/** + * Wrap a Voice Design description in the parentheses VoxCPM2 expects, and + * prepend it to the text. An already-parenthesized description is left as-is. + */ +export function buildVoxCPMInput(text: string, voiceDesign: string | null | undefined): string { + const design = voiceDesign?.trim(); + if (!design) return text; + const wrapped = /^\(.*\)$/su.test(design) ? design : `(${design})`; + return `${wrapped}${text}`; +} + export function normalizeXiaomiTTSVoice(voice: string | null | undefined): string { if (voice && XIAOMI_TTS_VOICES.some((item) => item.id === voice)) return voice; return DEFAULT_XIAOMI_TTS_VOICE; @@ -179,6 +229,12 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = { openaiTtsVoice: "alloy", openaiTtsFormat: "mp3", openaiTtsStylePrompt: DEFAULT_XIAOMI_STYLE_PROMPT, + voxcpmBaseUrl: DEFAULT_VOXCPM_BASE_URL, + voxcpmApiKey: "", + voxcpmModel: DEFAULT_VOXCPM_MODEL, + voxcpmVoice: DEFAULT_VOXCPM_VOICE, + voxcpmFormat: DEFAULT_VOXCPM_FORMAT, + voxcpmVoiceDesign: "", profiles: [], }; @@ -192,7 +248,8 @@ export function normalizeTTSEngine(engine: LegacyTTSEngine | string | null | und engine === "edge" || engine === "dashscope" || engine === "xiaomi" || - engine === "openai-compatible" + engine === "openai-compatible" || + engine === "voxcpm" ) { return engine; } @@ -255,6 +312,17 @@ export function createDefaultTTSProfiles(config: Partial = {}): TTSPr format: config.openaiTtsFormat ?? DEFAULT_TTS_CONFIG.openaiTtsFormat, stylePrompt: config.openaiTtsStylePrompt ?? DEFAULT_XIAOMI_STYLE_PROMPT, }, + { + id: "voxcpm-default", + name: "VoxCPM", + provider: "voxcpm", + baseUrl: config.voxcpmBaseUrl ?? DEFAULT_TTS_CONFIG.voxcpmBaseUrl, + apiKey: config.voxcpmApiKey ?? "", + model: config.voxcpmModel ?? DEFAULT_TTS_CONFIG.voxcpmModel, + voice: config.voxcpmVoice ?? DEFAULT_TTS_CONFIG.voxcpmVoice, + format: config.voxcpmFormat ?? DEFAULT_TTS_CONFIG.voxcpmFormat, + stylePrompt: config.voxcpmVoiceDesign ?? "", + }, ]; } @@ -322,6 +390,13 @@ export function syncConfigFromActiveProfile(config: TTSConfig): TTSConfig { next.openaiTtsVoice = activeProfile.voice ?? next.openaiTtsVoice; next.openaiTtsFormat = activeProfile.format ?? next.openaiTtsFormat; next.openaiTtsStylePrompt = activeProfile.stylePrompt ?? next.openaiTtsStylePrompt; + } else if (engine === "voxcpm") { + next.voxcpmBaseUrl = activeProfile.baseUrl ?? next.voxcpmBaseUrl; + next.voxcpmApiKey = activeProfile.apiKey ?? next.voxcpmApiKey; + next.voxcpmModel = activeProfile.model ?? next.voxcpmModel; + next.voxcpmVoice = activeProfile.voice ?? next.voxcpmVoice; + next.voxcpmFormat = activeProfile.format ?? next.voxcpmFormat; + next.voxcpmVoiceDesign = activeProfile.stylePrompt ?? next.voxcpmVoiceDesign; } return next; @@ -361,6 +436,12 @@ export function normalizeTTSConfig(config: PersistedTTSConfig | null | undefined openaiTtsFormat: config?.openaiTtsFormat ?? DEFAULT_TTS_CONFIG.openaiTtsFormat, openaiTtsStylePrompt: config?.openaiTtsStylePrompt ?? DEFAULT_TTS_CONFIG.openaiTtsStylePrompt, + voxcpmBaseUrl: config?.voxcpmBaseUrl ?? DEFAULT_TTS_CONFIG.voxcpmBaseUrl, + voxcpmApiKey: config?.voxcpmApiKey ?? DEFAULT_TTS_CONFIG.voxcpmApiKey, + voxcpmModel: config?.voxcpmModel ?? DEFAULT_TTS_CONFIG.voxcpmModel, + voxcpmVoice: config?.voxcpmVoice ?? DEFAULT_TTS_CONFIG.voxcpmVoice, + voxcpmFormat: config?.voxcpmFormat ?? DEFAULT_TTS_CONFIG.voxcpmFormat, + voxcpmVoiceDesign: config?.voxcpmVoiceDesign ?? DEFAULT_TTS_CONFIG.voxcpmVoiceDesign, profiles: normalizeProfiles(profileSource), };
+ {t("tts.voxcpmBaseUrlHint", "")} +
+ {t("tts.voiceDesignHint", "")} +