From 5e0e32b1c290eaa22167302d458f5c443996f688 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 20 Sep 2026 11:20:13 +0800 Subject: [PATCH] feat(web): add TTS emotion style selector across TTS modals and avatar panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TTS_STYLE_OPTIONS (6 presets: natural/excited/professional/sweet/news/livestream) with DEFAULT_TTS_STYLE=natural and shared getTtsStyle() helper - Add reusable TtsStyleSelector component (compact + normal modes, purple theme) - Wire style param through all TTS paths: * api/tts types: TTSSynthesizeRequest + TTSPreviewRequest add style?: string * voices library: hook state → TtsModal compact selector → synthesize payload * voice-materials library: hook state → TtsModal compact selector → payload * generate Step5: formState → TtsVoiceModal (internal/external controlled) → synthesize payload → narrative video payload tts_style * ai-avatar lipsync: hook state → PanelVoiceSelector → previewTts + createLipsyncJob - SegmentTtsConfig adds style?: string for editing-planner TtsPanel (UI pending) - All style params optional; backend will honor when supported, default natural --- apps/web/src/api/template-editor/types.ts | 2 + apps/web/src/api/tts/index.ts | 3 + apps/web/src/api/tts/styles.ts | 71 ++++++++ apps/web/src/api/tts/types.ts | 4 + .../voice/TtsStyleSelector/index.tsx | 151 ++++++++++++++++++ apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 15 +- apps/web/src/pages/ai-avatar/api/aiAvatar.ts | 3 + .../components/PanelVoiceSelector.tsx | 15 +- .../src/pages/ai-avatar/hooks/useAiAvatar.ts | 4 + apps/web/src/pages/generate/GeneratePage.tsx | 14 +- .../generate/components/TtsVoiceModal.tsx | 24 ++- .../generate/hooks/generate-video/types.ts | 2 + .../hooks/useGenerateFormState/index.ts | 7 + .../pages/generate/hooks/useGenerateVideo.ts | 1 + .../voice-materials/VoiceMaterialLibrary.tsx | 4 + .../voice-materials/components/TtsModal.tsx | 9 ++ .../voice-materials/hooks/useTtsSynthesize.ts | 7 +- apps/web/src/pages/voices/VoiceLibrary.tsx | 4 + .../src/pages/voices/components/TtsModal.tsx | 4 + .../pages/voices/components/VoiceModals.tsx | 7 + .../voices/components/tts-modal/types.ts | 3 + .../pages/voices/hooks/useTtsSynthesize.ts | 8 +- 22 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/api/tts/styles.ts create mode 100644 apps/web/src/components/voice/TtsStyleSelector/index.tsx diff --git a/apps/web/src/api/template-editor/types.ts b/apps/web/src/api/template-editor/types.ts index 40c806a21..9d80f144f 100644 --- a/apps/web/src/api/template-editor/types.ts +++ b/apps/web/src/api/template-editor/types.ts @@ -54,6 +54,8 @@ export interface SegmentTtsConfig { pitch: number volume: number subtitle_sync: boolean + /** 配音风格预设(natural/excited/professional/sweet/news/livestream) */ + style?: string } /** 片段裁剪配置 */ diff --git a/apps/web/src/api/tts/index.ts b/apps/web/src/api/tts/index.ts index 0c006f6d0..4817839c0 100644 --- a/apps/web/src/api/tts/index.ts +++ b/apps/web/src/api/tts/index.ts @@ -18,6 +18,9 @@ export type { TTSPreviewResponse, } from "./types" +export type { TtsStyle, TtsStyleOption } from "./styles" +export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles" + // API 函数 export { synthesizeSpeech, diff --git a/apps/web/src/api/tts/styles.ts b/apps/web/src/api/tts/styles.ts new file mode 100644 index 000000000..9af9c6190 --- /dev/null +++ b/apps/web/src/api/tts/styles.ts @@ -0,0 +1,71 @@ +/** + * TTS 配音风格预设(情感/语气风格) + * - key:传给后端的 style 标识,便于后端按策略合成 + * - 未传 style 时后端默认自然亲切 + * + * 注:与原 emotion(CosyVoice 7 种基础情绪枚举)解耦; + * style 是更高层的"说话风格预设",后端可能映射到 emotion + speed + prompt 组合。 + */ + +export interface TtsStyleOption { + /** 传给后端的风格标识 */ + value: string + /** 展示名 */ + label: string + /** emoji 图标 */ + emoji: string + /** 给用户/后端的风格描述(prompt 风格) */ + description: string +} + +export const TTS_STYLE_OPTIONS: readonly TtsStyleOption[] = [ + { + value: "natural", + label: "自然亲切", + emoji: "😊", + description: "亲切自然,像朋友聊天", + }, + { + value: "excited", + label: "激动兴奋", + emoji: "🤩", + description: "激动兴奋,语速稍快,充满活力", + }, + { + value: "professional", + label: "沉稳专业", + emoji: "🧑‍💼", + description: "沉稳专业,语速适中,正式可靠", + }, + { + value: "sweet", + label: "温柔甜美", + emoji: "🌸", + description: "温柔甜美,语速轻柔", + }, + { + value: "news", + label: "新闻播报", + emoji: "📰", + description: "字正腔圆,严肃正式", + }, + { + value: "livestream", + label: "直播带货", + emoji: "🎤", + description: "热情有感染力,有节奏感", + }, +] as const + +export type TtsStyle = (typeof TTS_STYLE_OPTIONS)[number]["value"] + +/** 默认风格:自然亲切 */ +export const DEFAULT_TTS_STYLE: TtsStyle = "natural" + +/** 根据 value 查找风格选项(容错:找不到回退 natural) */ +export function getTtsStyle(value: string | null | undefined): TtsStyleOption { + return ( + (TTS_STYLE_OPTIONS as readonly TtsStyleOption[]).find((o) => o.value === value) ?? + (TTS_STYLE_OPTIONS as readonly TtsStyleOption[])[0] + ) +} diff --git a/apps/web/src/api/tts/types.ts b/apps/web/src/api/tts/types.ts index 9bfec5498..6b394d4fd 100644 --- a/apps/web/src/api/tts/types.ts +++ b/apps/web/src/api/tts/types.ts @@ -17,6 +17,8 @@ export interface TTSSynthesizeRequest { output_name?: string language?: string emotion?: string + /** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */ + style?: string speed?: number voice_model?: string voice_clone_profile_id?: string @@ -106,6 +108,8 @@ export interface TTSPreviewRequest { pitch?: number language?: string emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签) + /** 配音风格预设 */ + style?: string } /** TTS 试听响应 */ diff --git a/apps/web/src/components/voice/TtsStyleSelector/index.tsx b/apps/web/src/components/voice/TtsStyleSelector/index.tsx new file mode 100644 index 000000000..56e023f88 --- /dev/null +++ b/apps/web/src/components/voice/TtsStyleSelector/index.tsx @@ -0,0 +1,151 @@ +/** + * TTS 配音风格选择器 + * - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货) + * - 卡片单选,选中高亮紫色 + * - 默认 natural + * + * 复用方式: + * + * // 紧凑模式(小尺寸) + */ +import React from "react" +import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" + +export interface TtsStyleSelectorProps { + value?: TtsStyle | string + onChange: (style: TtsStyle) => void + /** 紧凑模式(小卡片),适合与其他参数并排 */ + compact?: boolean + /** 是否显示"配音风格"标签 */ + showLabel?: boolean +} + +const TtsStyleSelector: React.FC = ({ + value, + onChange, + compact = false, + showLabel = true, +}) => { + const current = value || DEFAULT_TTS_STYLE + + if (compact) { + return ( +
+ {showLabel && ( +
+ 配音风格 +
+ )} +
+ {TTS_STYLE_OPTIONS.map((opt) => { + const selected = current === opt.value + return ( + + ) + })} +
+
+ ) + } + + return ( +
+ {showLabel && ( +
+ 配音风格 +
+ )} +
+ {TTS_STYLE_OPTIONS.map((opt) => { + const selected = current === opt.value + return ( + + ) + })} +
+
+ ) +} + +export default TtsStyleSelector diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index a9a10e723..5698549fa 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -94,7 +94,7 @@ const AiAvatarPage: React.FC = () => { state.resetTtsPreview() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion]) + }, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion, state.style]) const _clearTtsProgressTimer = useCallback(() => { if (ttsProgressTimerRef.current) { @@ -175,7 +175,14 @@ const AiAvatarPage: React.FC = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion]) + }, [ + state.selectedVideo, + state.selectedVoice, + state.scriptText, + state.speed, + state.emotion, + state.style, + ]) const handleRetryTts = useCallback(() => { handleGenerateTts() @@ -255,6 +262,7 @@ const AiAvatarPage: React.FC = () => { video_url: videoUrl, speed: state.speed, emotion: normalizeEmotion(state.emotion), + style: state.style, } } const job = await createLipsyncJob(payload) @@ -299,6 +307,7 @@ const AiAvatarPage: React.FC = () => { state.scriptText, state.speed, state.emotion, + state.style, state.ttsPreview, ]) @@ -600,6 +609,8 @@ const AiAvatarPage: React.FC = () => { onSelectVoice={state.setSelectedVoice} emotion={state.emotion} onEmotionChange={state.setEmotion} + style={state.style} + onStyleChange={state.setStyle} speed={state.speed} onSpeedChange={state.setSpeed} language={state.language} diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index cdbcf6a50..83412bace 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -41,6 +41,8 @@ export const createLipsyncJob = async (data: { speed?: number /** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */ emotion?: string + /** 配音风格预设(natural/excited/professional/sweet/news/livestream) */ + style?: string enable_video_loop?: boolean project_id?: string }): Promise => { @@ -55,6 +57,7 @@ export const previewTts = async (data: { script_text: string speed?: number emotion?: string + style?: string }): Promise<{ audio_url: string duration: number diff --git a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx index 58cd92f33..7bb1f2eb9 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx @@ -7,6 +7,8 @@ import { message } from "antd" import { fetchVoices } from "@/api/voices/voices" import { previewTts } from "@/api/tts" import { normalizeEmotion } from "../utils/contract" +import TtsStyleSelector from "@/components/voice/TtsStyleSelector" +import type { TtsStyle } from "@/api/tts/styles" import type { UnifiedVoiceItem } from "@/api/voices/types" import { type VoiceSource, @@ -24,6 +26,8 @@ interface PanelVoiceSelectorProps { onSelectVoice: (voice: UnifiedVoiceItem) => void emotion: VoiceEmotion onEmotionChange: (e: VoiceEmotion) => void + style: TtsStyle + onStyleChange: (s: TtsStyle) => void speed: number onSpeedChange: (s: number) => void language: VoiceLanguage @@ -37,6 +41,8 @@ export function PanelVoiceSelector({ onSelectVoice, emotion, onEmotionChange, + style, + onStyleChange, speed, onSpeedChange, language, @@ -139,7 +145,8 @@ export function PanelVoiceSelector({ /* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview * 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */ if (voice.type === "clone") { - const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id) + const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}` + const cached = previewCacheRef.current.get(cacheKey) if (cached) { playAudioUrl(voice.id, cached) return @@ -153,13 +160,14 @@ export function PanelVoiceSelector({ voice_id: targetId, speed: speed, // 透传用户选择的语速(#1822) emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举 + style, }) if (!res.audio_url) { setPreviewingId(null) message.error("合成试听失败:未返回音频") return } - previewCacheRef.current.set(targetId, res.audio_url) + previewCacheRef.current.set(cacheKey, res.audio_url) playAudioUrl(voice.id, res.audio_url) } catch (err) { setPreviewingId(null) @@ -324,6 +332,9 @@ export function PanelVoiceSelector({ onChange={(e) => handleSpeedChange(e.target.value)} /> +
+ +
) diff --git a/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts b/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts index fb8f86660..dc38c64a0 100644 --- a/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/hooks/useAiAvatar.ts @@ -17,6 +17,7 @@ import { DEFAULT_TITLE_CONFIG, DEFAULT_COVER_CONFIG, } from "../types" +import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" const DEFAULT_TTS_PREVIEW: TtsPreviewResult = { audioUrl: null, @@ -35,6 +36,7 @@ export function useAiAvatar() { const [voiceSource, setVoiceSource] = useState("preset") const [selectedVoice, setSelectedVoice] = useState(null) const [emotion, setEmotion] = useState("neutral") + const [style, setStyle] = useState(DEFAULT_TTS_STYLE) const [speed, setSpeed] = useState(1.0) const [language, setLanguage] = useState("zh") @@ -115,6 +117,8 @@ export function useAiAvatar() { setSelectedVoice, emotion, setEmotion, + style, + setStyle, speed, setSpeed, language, diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index a9a00b4b1..76bea3dc8 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -77,6 +77,8 @@ const GeneratePage: React.FC = () => { setTtsVoiceId, ttsVoiceSource, setTtsVoiceSource, + ttsStyle, + setTtsStyle, ttsVoiceAssetId, setTtsVoiceAssetId, dedupEnabled, @@ -329,6 +331,7 @@ const GeneratePage: React.FC = () => { selectedScript, ttsVoiceId, ttsVoiceSource, + ttsStyle, ttsVoiceAssetId, dedupEnabled, style, @@ -410,9 +413,15 @@ const GeneratePage: React.FC = () => { ) const handleTtsSynthesized = useCallback( - (payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => { + (payload: { + voiceAssetId: string + ttsVoiceId: string + ttsVoiceSource: "preset" | "clone" + ttsStyle?: string + }) => { setTtsVoiceId(payload.ttsVoiceId) setTtsVoiceSource(payload.ttsVoiceSource) + if (payload.ttsStyle) setTtsStyle(payload.ttsStyle) setTtsVoiceAssetId(payload.voiceAssetId) if (payload.ttsVoiceSource === "clone") { setSelectedClonedVoice(payload.ttsVoiceId) @@ -428,6 +437,7 @@ const GeneratePage: React.FC = () => { [ setTtsVoiceId, setTtsVoiceSource, + setTtsStyle, setTtsVoiceAssetId, setSelectedVoice, setSelectedClonedVoice, @@ -791,6 +801,8 @@ const GeneratePage: React.FC = () => { open={ttsModalOpen} scriptText={selectedScript?.content ?? ""} scriptTitle={selectedScript?.title ?? ""} + style={ttsStyle} + onStyleChange={setTtsStyle} onCancel={() => setTtsModalOpen(false)} onSynthesized={handleTtsSynthesized} /> diff --git a/apps/web/src/pages/generate/components/TtsVoiceModal.tsx b/apps/web/src/pages/generate/components/TtsVoiceModal.tsx index e99ad89b7..9a5d2c643 100644 --- a/apps/web/src/pages/generate/components/TtsVoiceModal.tsx +++ b/apps/web/src/pages/generate/components/TtsVoiceModal.tsx @@ -19,6 +19,8 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" import type { PresetVoiceItem } from "@/api/voices" import type { VoiceClone } from "@/api/voice-clone" import { VOICE_GENDER_ICON } from "../constants" +import TtsStyleSelector from "@/components/voice/TtsStyleSelector" +import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" interface TtsVoiceModalProps { open: boolean @@ -31,7 +33,11 @@ interface TtsVoiceModalProps { voiceAssetId: string ttsVoiceId: string ttsVoiceSource: "preset" | "clone" + ttsStyle: TtsStyle }) => void + /** 当前风格 */ + style?: TtsStyle + onStyleChange?: (s: TtsStyle) => void } type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error" @@ -42,7 +48,15 @@ const TtsVoiceModal: React.FC = ({ scriptTitle, onCancel, onSynthesized, + style: externalStyle, + onStyleChange, }) => { + const [internalStyle, setInternalStyle] = useState(DEFAULT_TTS_STYLE) + const currentStyle: TtsStyle = externalStyle ?? internalStyle + const handleStyleChange = (s: TtsStyle) => { + setInternalStyle(s) + onStyleChange?.(s) + } const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset") const [selectedVoiceId, setSelectedVoiceId] = useState("") const [status, setStatus] = useState("idle") @@ -77,6 +91,7 @@ const TtsVoiceModal: React.FC = ({ setStatus("idle") setError(null) setActiveTab("preset") + setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE) } else { if (timerRef.current) { clearInterval(timerRef.current) @@ -91,6 +106,7 @@ const TtsVoiceModal: React.FC = ({ return () => { if (timerRef.current) clearInterval(timerRef.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) const handlePreview = useCallback( @@ -143,6 +159,7 @@ const TtsVoiceModal: React.FC = ({ text: textToSynth, speed: 1.0, language: "zh-CN", + style: currentStyle, } if (isClone) { payload.voice_clone_profile_id = selectedVoiceId @@ -187,13 +204,14 @@ const TtsVoiceModal: React.FC = ({ voiceAssetId: jobId, ttsVoiceId: selectedVoiceId, ttsVoiceSource: isClone ? "clone" : "preset", + ttsStyle: currentStyle, }) } catch (err: unknown) { setStatus("error") const msg = err instanceof Error ? err.message : "合成失败,请稍后重试" setError(msg) } - }, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized]) + }, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized, currentStyle]) const renderVoiceCard = (v: { id: string @@ -393,6 +411,10 @@ const TtsVoiceModal: React.FC = ({ {textToSynth.length} 字 +
+ +
+ { diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index b0ba92201..c8b72657a 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -22,6 +22,8 @@ export interface UseGenerateVideoProps { ttsVoiceId?: string /** TTS 音色来源 */ ttsVoiceSource?: "preset" | "clone" + /** TTS 配音风格 */ + ttsStyle?: string /** 合成后保存到配音库的 asset id / job id(叙事模式) */ ttsVoiceAssetId?: string /** 智能降重开关(默认 true) */ diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index a9ea75090..ca3fd438b 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -13,6 +13,7 @@ import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../../types/cover" import type { PresetVoiceItem } from "@/api/voices" import type { ScriptItem } from "@/api/scripts" +import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles" import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants" import type { TitleSettings } from "../../types" import { usePlanConfigLoader } from "./usePlanConfigLoader" @@ -95,6 +96,9 @@ export interface GenerateFormState { /** TTS 音色来源:preset 系统 / clone 克隆 */ ttsVoiceSource: "preset" | "clone" setTtsVoiceSource: (src: "preset" | "clone") => void + /** TTS 配音风格 */ + ttsStyle: TtsStyle + setTtsStyle: (s: TtsStyle) => void /** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice) */ ttsVoiceAssetId: string setTtsVoiceAssetId: (id: string) => void @@ -234,6 +238,7 @@ export const useGenerateFormState = (): GenerateFormState => { const [selectedScript, setSelectedScript] = useState(null) const [ttsVoiceId, setTtsVoiceId] = useState("") const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset") + const [ttsStyle, setTtsStyle] = useState(DEFAULT_TTS_STYLE) const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState("") const [dedupEnabled, setDedupEnabled] = useState(true) @@ -311,6 +316,8 @@ export const useGenerateFormState = (): GenerateFormState => { setTtsVoiceId, ttsVoiceSource, setTtsVoiceSource, + ttsStyle, + setTtsStyle, ttsVoiceAssetId, setTtsVoiceAssetId, dedupEnabled, diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 8f51a2dbf..ffb85965c 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -208,6 +208,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { script_id: props.selectedScript.id, tts_voice_id: props.ttsVoiceId || undefined, tts_voice_source: props.ttsVoiceSource || undefined, + tts_style: props.ttsStyle || undefined, } : {}), dedup_enabled: dedupEnabled, diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index 51c353bad..353aa8baf 100755 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -98,6 +98,7 @@ const VoiceMaterialLibrary: React.FC = () => { ttsText, ttsVoiceId, ttsSpeed, + ttsStyle, ttsStatus, ttsAudioUrl, ttsError, @@ -107,6 +108,7 @@ const VoiceMaterialLibrary: React.FC = () => { setTtsText, setTtsVoiceId, setTtsSpeed, + setTtsStyle, handleTtsSynthesize, handleTtsSave, handleTtsClose, @@ -315,6 +317,7 @@ const VoiceMaterialLibrary: React.FC = () => { text={ttsText} voiceId={ttsVoiceId} speed={ttsSpeed} + style={ttsStyle} status={ttsStatus} audioUrl={ttsAudioUrl ?? ""} error={ttsError ?? ""} @@ -324,6 +327,7 @@ const VoiceMaterialLibrary: React.FC = () => { onTextChange={setTtsText} onVoiceChange={setTtsVoiceId} onSpeedChange={setTtsSpeed} + onStyleChange={setTtsStyle} onSynthesize={handleTtsSynthesize} onSave={handleTtsSave} /> diff --git a/apps/web/src/pages/voice-materials/components/TtsModal.tsx b/apps/web/src/pages/voice-materials/components/TtsModal.tsx index 3a00712c1..e300e379a 100755 --- a/apps/web/src/pages/voice-materials/components/TtsModal.tsx +++ b/apps/web/src/pages/voice-materials/components/TtsModal.tsx @@ -1,6 +1,8 @@ import React from "react" import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons" import { Button } from "@/components/ui" +import TtsStyleSelector from "@/components/voice/TtsStyleSelector" +import type { TtsStyle } from "@/api/tts/styles" export type TtsStatus = "idle" | "synthesizing" | "done" | "error" @@ -20,6 +22,8 @@ interface TtsModalProps { text: string voiceId: string speed: number + style: TtsStyle + onStyleChange: (style: TtsStyle) => void status: TtsStatus audioUrl: string error: string @@ -39,6 +43,8 @@ const TtsModal: React.FC = ({ text, voiceId, speed, + style, + onStyleChange, status, audioUrl, error, @@ -143,6 +149,9 @@ const TtsModal: React.FC = ({ /> + {/* 配音风格 */} + + {/* 合成按钮 */}