Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export function VoicePickerModal({

{/* Engine selector */}
<View style={s.engineSection}>
{(["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"
Expand Down
1 change: 1 addition & 0 deletions packages/app-expo/src/lib/platform/rn-tts-factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export const rnTTSPlayerFactories: TTSPlayerFactories = {
createDashScopeTTS: () => new TrackPlayerDashScopeTTSPlayer(),
createXiaomiTTS: () => new TrackPlayerCloudTTSPlayer(),
createOpenAICompatibleTTS: () => new TrackPlayerCloudTTSPlayer(),
createVoxCPMTTS: () => new TrackPlayerCloudTTSPlayer(),
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type ITTSPlayer,
type TTSConfig,
fetchOpenAITTSAudio,
fetchVoxCPMAudio,
fetchXiaomiTTSWav,
isTTSAbortError,
splitIntoChunks,
Expand All @@ -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<Uint8Array> {
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;
Expand Down Expand Up @@ -264,10 +274,7 @@ export class TrackPlayerCloudTTSPlayer implements ITTSPlayer {
private async _fetchChunkFile(index: number, gen: number): Promise<string> {
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);
Expand Down
4 changes: 3 additions & 1 deletion packages/app-expo/src/lib/platform/tts-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
115 changes: 115 additions & 0 deletions packages/app-expo/src/screens/settings/TTSSettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -573,6 +576,113 @@ export default function TTSSettingsScreen() {
</View>
</>
)}

{config.engine === "voxcpm" && (
<>
<View style={styles.fieldGroup}>
<Text style={styles.fieldLabel}>{t("tts.baseUrl", "Base URL")}</Text>
<TextInput
style={styles.input}
value={config.voxcpmBaseUrl}
onChangeText={(v) => {
updateActiveProfile({ baseUrl: v });
updateConfig({ voxcpmBaseUrl: v });
}}
placeholder={DEFAULT_VOXCPM_BASE_URL}
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
/>
<Text style={styles.fieldHint}>{t("tts.voxcpmBaseUrlHint", "")}</Text>
</View>
<View style={styles.fieldGroup}>
<Text style={styles.fieldLabel}>{t("tts.model", "Model")}</Text>
<TextInput
style={styles.input}
value={config.voxcpmModel}
onChangeText={(v) => {
updateActiveProfile({ model: v });
updateConfig({ voxcpmModel: v });
}}
placeholder={DEFAULT_VOXCPM_MODEL}
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
/>
</View>
<View style={styles.fieldGroup}>
<Text style={styles.fieldLabel}>{t("tts.voice", "语音")}</Text>
<TextInput
style={styles.input}
value={config.voxcpmVoice}
onChangeText={(v) => {
updateActiveProfile({ voice: v });
updateConfig({ voxcpmVoice: v });
}}
placeholder={DEFAULT_VOXCPM_VOICE}
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
/>
</View>
<View style={styles.fieldGroup}>
<Text style={styles.fieldLabel}>{t("tts.voiceDesign", "Voice design")}</Text>
<TextInput
style={[styles.input, styles.multilineInput]}
value={config.voxcpmVoiceDesign}
onChangeText={(v) => {
updateActiveProfile({ stylePrompt: v });
updateConfig({ voxcpmVoiceDesign: v });
}}
multiline
placeholder={t("tts.voiceDesignPlaceholder", "")}
placeholderTextColor={colors.mutedForeground}
/>
<Text style={styles.fieldHint}>{t("tts.voiceDesignHint", "")}</Text>
</View>
<View style={styles.fieldGroup}>
<Text style={styles.fieldLabel}>{t("tts.format", "Format")}</Text>
<View style={styles.optionRow}>
{(["wav", "mp3"] as const).map((format) => {
const active = config.voxcpmFormat === format;
return (
<TouchableOpacity
key={format}
style={[styles.optionChip, active && styles.optionChipActive]}
onPress={() => {
updateActiveProfile({ format });
updateConfig({ voxcpmFormat: format });
}}
activeOpacity={0.7}
>
<Text
style={[
styles.optionChipText,
active && styles.optionChipTextActive,
]}
>
{format}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={styles.fieldGroup}>
<Text style={styles.fieldLabel}>
{t("tts.apiKeyOptional", "API Key (optional)")}
</Text>
<TextInput
style={styles.input}
value={config.voxcpmApiKey || ""}
onChangeText={(v) => {
updateActiveProfile({ apiKey: v });
updateConfig({ voxcpmApiKey: v });
}}
placeholder=""
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
/>
</View>
</>
)}
</View>

{/* Rate & Pitch */}
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion packages/app-expo/src/stores/tts-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface TTSPlayerFactories {
createDashScopeTTS: () => ITTSPlayer;
createXiaomiTTS: () => ITTSPlayer;
createOpenAICompatibleTTS: () => ITTSPlayer;
createVoxCPMTTS: () => ITTSPlayer;
}

const defaultFactories: TTSPlayerFactories = {
Expand All @@ -44,6 +45,7 @@ const defaultFactories: TTSPlayerFactories = {
createDashScopeTTS: () => new TrackPlayerDashScopeTTSPlayer(),
createXiaomiTTS: () => new TrackPlayerCloudTTSPlayer(),
createOpenAICompatibleTTS: () => new TrackPlayerCloudTTSPlayer(),
createVoxCPMTTS: () => new TrackPlayerCloudTTSPlayer(),
};

let _factories: TTSPlayerFactories = defaultFactories;
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -133,6 +141,7 @@ function detachAndStopAllPlayers(): void {
detachAndStopPlayer(_dashscopeTTS);
detachAndStopPlayer(_xiaomiTTS);
detachAndStopPlayer(_openAICompatibleTTS);
detachAndStopPlayer(_voxcpmTTS);
}

function normalizeSegments(text: string | string[]): string[] {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -217,6 +235,9 @@ function getPlayerForConfig(config: TTSConfig): ITTSPlayer {
if (config.engine === "openai-compatible") {
return getOpenAICompatibleTTS();
}
if (config.engine === "voxcpm") {
return getVoxCPMTTS();
}
return getSystemTTS();
}

Expand Down Expand Up @@ -380,7 +401,11 @@ export const useTTSStore = create<TTSState>()(
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: "",
Expand Down Expand Up @@ -683,4 +708,5 @@ export function setTTSPlayerFactories(factories: Partial<TTSPlayerFactories>): v
_dashscopeTTS = null;
_xiaomiTTS = null;
_openAICompatibleTTS = null;
_voxcpmTTS = null;
}
11 changes: 10 additions & 1 deletion packages/app/src/components/reader/FooterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,16 @@ export function FooterBar({
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-16 shrink-0">{t("tts.engine")}</span>
<div className="flex gap-1">
{(["edge", "system", "dashscope", "xiaomi", "openai-compatible"] as TTSEngine[]).map((eng) => (
{(
[
"edge",
"system",
"dashscope",
"xiaomi",
"openai-compatible",
"voxcpm",
] as TTSEngine[]
).map((eng) => (
<Button
key={eng}
variant={config.engine === eng ? "default" : "secondary"}
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/components/reader/TTSControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export function TTSControls({ onClose, className }: TTSControlsProps) {
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-16 shrink-0">{t("tts.engine")}</span>
<div className="flex gap-1">
{(["system", "dashscope", "xiaomi", "openai-compatible"] as TTSEngine[]).map((eng) => (
{(
["system", "dashscope", "xiaomi", "openai-compatible", "voxcpm"] as TTSEngine[]
).map((eng) => (
<Button
key={eng}
variant={config.engine === eng ? "default" : "secondary"}
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/components/reader/TTSPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,16 @@ export function TTSPage({
<div className="sticky top-0 z-10 border-b border-border/30 bg-background/95 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("tts.selectEngine")}
</div>
{(["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"
Expand Down
Loading