feat: 任务 3.14 一键生成功能前端对接 TTS API #172

Merged
xiaoxia merged 1 commits from feature/task-314-tts-integration into develop 2026-07-02 15:11:50 +08:00
2 changed files with 352 additions and 97 deletions
+127
View File
@@ -0,0 +1,127 @@
/**
* TTS 语音合成 API
* 对接后端 /api/v1/tts/* 端点
*
* 任务 3.14 新增
*/
import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
voice_id?: string;
output_name?: string;
language?: string;
speed?: number;
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: Record<string, unknown>;
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string;
status: string;
message: string;
}
/** TTS 任务详情 */
export interface TTSJob {
id: string;
user_id: string;
project_id: string | null;
text: string;
voice_id: string | null;
voice_model: string | null;
voice_clone_profile_id: string | null;
language: string;
speed: number;
output_name: string | null;
output_audio_url: string | null;
output_format: string;
duration_seconds: number | null;
file_size_bytes: number | null;
sample_rate: number | null;
status: string;
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string;
status: string;
output_audio_url: string | null;
error_message: string | null;
duration_seconds: number | null;
retry_count: number;
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[];
total: number;
skip: number;
limit: number;
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string;
skip?: number;
limit?: number;
}
/* ── API 函数 ──────────────────────────────────── */
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>(
"/tts/synthesize",
data,
);
return response.data;
};
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`);
return response.data;
};
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(
`/tts/jobs/${jobId}/status`,
);
return response.data;
};
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (
params?: TTSJobListParams,
): Promise<TTSJobListResponse> => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
const qs = searchParams.toString();
const response = await apiClient.get<TTSJobListResponse>(
`/tts/jobs${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
};
+225 -97
View File
@@ -4,7 +4,7 @@
* 使用 Task 1.5 UI 组件 + V21 CSS 变量,Mock 数据
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
Typography,
Collapse,
@@ -43,54 +43,21 @@ import {
generateEditPlan,
} from "@/api/editPlans";
import apiClient from "@/api/client";
import type { VoiceItem } from "@/api/voices";
import { fetchPresetVoices } from "@/api/voices";
import type { PresetVoiceItem } from "@/api/voices";
import { getVoiceClones, formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import VoiceCloneModal from "@/components/modals/VoiceCloneModal";
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts";
import "./generate.css";
const { TextArea } = Input;
const { Text } = Typography;
/* ================================================================
Mock 数据(配音 & 时间线保留,素材改为 API 获取)
Mock 数据(时间线保留,素材 & 配音改为 API 获取)
================================================================ */
const MOCK_VOICES: VoiceItem[] = [
{
id: "v1",
name: "小晓 · 温柔女声",
text: "",
voice_type: "female_gentle",
duration_seconds: 0,
audio_url: "",
},
{
id: "v2",
name: "小宇 · 磁性男声",
text: "",
voice_type: "male_magnetic",
duration_seconds: 0,
audio_url: "",
},
{
id: "v3",
name: "小雅 · 专业播音",
text: "",
voice_type: "female_professional",
duration_seconds: 0,
audio_url: "",
},
{
id: "v4",
name: "小杰 · 活力男声",
text: "",
voice_type: "male_energetic",
duration_seconds: 0,
audio_url: "",
},
];
/* ── 时间线 Mock ── */
interface TimelineScene {
scene: string;
@@ -234,6 +201,19 @@ const GeneratePage: React.FC = () => {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
/* ── 预置音色:通过 API 获取 ── */
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
});
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [];
/* ── TTS 自定义合成状态 ── */
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
/* ── 素材数据:通过 API 获取 ── */
const { data: libraries = [] } = useQuery({
@@ -282,16 +262,100 @@ const GeneratePage: React.FC = () => {
);
}, []);
const toggleVoicePlay = useCallback((id: string) => {
setPlayingVoice((prev) => {
if (prev === id) {
message.info("Mock:停止播放");
return null;
const toggleVoicePlay = useCallback(
(voiceId: string, previewUrl: string | null) => {
if (playingVoice === voiceId) {
// 停止播放
audioRef.current?.pause();
audioRef.current = null;
setPlayingVoice(null);
return;
}
message.info("Mock:开始播放配音预览");
return id;
// 停止之前的
audioRef.current?.pause();
if (!previewUrl) {
message.warning("该音色暂无试听音频");
return;
}
const audio = new Audio(previewUrl);
audioRef.current = audio;
audio.play().catch(() => {
message.error("播放失败,请检查网络");
});
audio.onended = () => {
setPlayingVoice(null);
audioRef.current = null;
};
setPlayingVoice(voiceId);
},
[playingVoice],
);
/* ── TTS 自定义合成 mutation ── */
const synthesizeMutation = useMutation({
mutationFn: synthesizeSpeech,
onSuccess: (data) => {
setTtsJobId(data.job_id);
message.info("语音合成已提交,等待处理…");
},
onError: () => {
setTtsError("语音合成请求失败,请重试");
},
});
/** 轮询 TTS 任务状态 */
useEffect(() => {
if (!ttsJobId) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
try {
const status = await getTTSJobStatus(ttsJobId);
if (cancelled) return;
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url);
setTtsJobId(null);
setTtsError(null);
message.success("语音合成完成!");
return;
}
if (status.status === "failed" || status.status === "cancelled") {
setTtsError(status.error_message || "语音合成失败");
setTtsJobId(null);
return;
}
// 继续轮询
timer = setTimeout(poll, 2000);
} catch {
if (!cancelled) {
setTtsError("查询合成状态失败");
setTtsJobId(null);
}
}
};
timer = setTimeout(poll, 2000);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [ttsJobId]);
/** 触发自定义文本 TTS 合成 */
const handleSynthesizeVoice = useCallback(() => {
if (!customVoiceText.trim()) {
message.warning("请先输入配音文案");
return;
}
setTtsError(null);
setCustomAudioUrl(null);
synthesizeMutation.mutate({
text: customVoiceText.trim(),
voice_id: selectedVoice || undefined,
language: "zh-CN",
});
}, []);
}, [customVoiceText, selectedVoice, synthesizeMutation]);
const handleGenerate = useCallback(async () => {
if (!title.trim()) {
@@ -303,18 +367,44 @@ const GeneratePage: React.FC = () => {
return;
}
// 配音模式验证
if (voiceMode === "custom" && !customAudioUrl && !customVoiceText.trim()) {
message.warning("自定义录制模式:请输入配音文案或上传录音");
return;
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色");
return;
}
setGenerating(true);
setProgress(0);
setGenerated(false);
try {
// 构建配音配置
const voiceConfig: Record<string, unknown> = {};
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined;
} else if (voiceMode === "clone") {
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined;
} else if (voiceMode === "custom") {
voiceConfig.voice_id = selectedVoice || undefined;
if (customAudioUrl) {
voiceConfig.custom_audio_url = customAudioUrl;
}
if (customVoiceText.trim()) {
voiceConfig.custom_text = customVoiceText.trim();
}
}
// 1. 创建剪辑计划
const plan = await createEditPlan({
template_id: "default",
name: title.trim(),
config: {
asset_ids: selectedMaterials,
voice_id: selectedVoice || undefined,
...voiceConfig,
ratio: videoRatio,
style,
duration,
@@ -374,6 +464,10 @@ const GeneratePage: React.FC = () => {
title,
selectedMaterials,
selectedVoice,
voiceMode,
selectedClonedVoice,
customAudioUrl,
customVoiceText,
videoRatio,
style,
duration,
@@ -578,72 +672,106 @@ const GeneratePage: React.FC = () => {
{voiceMode === "preset" ? (
<div className="xx-voice-grid">
{MOCK_VOICES.map((v) => {
const selected = selectedVoice === v.id;
return (
<div
key={v.id}
className={`xx-voice-card ${selected ? "xx-voice-card-selected" : ""}`}
onClick={() => setSelectedVoice(v.id)}
role="button"
tabIndex={0}
aria-pressed={selected}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedVoice(v.id);
}
}}
>
<div className="xx-voice-header">
<div className="xx-voice-avatar">
<AudioOutlined />
</div>
<div>
<Typography.Paragraph className="xx-voice-name">
{v.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-voice-desc">
{v.voice_type}
</Typography.Paragraph>
</div>
</div>
<Button
buttonType="text"
buttonSize="sm"
icon={
playingVoice === v.id ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)
}
onClick={(e) => {
e.stopPropagation();
toggleVoicePlay(v.id);
{presetVoicesLoading ? (
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1" }}>
</Typography.Paragraph>
) : presetVoices.length === 0 ? (
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1" }}>
</Typography.Paragraph>
) : (
presetVoices.map((v) => {
const selected = selectedVoice === v.voice_id;
return (
<div
key={v.voice_id}
className={`xx-voice-card ${selected ? "xx-voice-card-selected" : ""}`}
onClick={() => setSelectedVoice(v.voice_id)}
role="button"
tabIndex={0}
aria-pressed={selected}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedVoice(v.voice_id);
}
}}
>
{playingVoice === v.id ? "停止" : "试听"}
</Button>
</div>
);
})}
<div className="xx-voice-header">
<div className="xx-voice-avatar">
<AudioOutlined />
</div>
<div>
<Typography.Paragraph className="xx-voice-name">
{v.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-voice-desc">
{v.description}
</Typography.Paragraph>
</div>
</div>
<Button
buttonType="text"
buttonSize="sm"
icon={
playingVoice === v.voice_id ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)
}
onClick={(e) => {
e.stopPropagation();
toggleVoicePlay(v.voice_id, v.preview_url);
}}
>
{playingVoice === v.voice_id ? "停止" : "试听"}
</Button>
</div>
);
})
)}
</div>
) : voiceMode === "custom" ? (
<div>
<TextArea
placeholder="输入配音文案,系统将自动合成语音…"
placeholder="输入配音文案,点击合成按钮生成语音…"
rows={4}
maxLength={500}
showCount
value={customVoiceText}
onChange={(e) => setCustomVoiceText(e.target.value)}
/>
<div style={{ marginTop: 12 }}>
<div style={{ marginTop: 12, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Button
buttonType="ghost"
icon={<AudioOutlined />}
loading={synthesizeMutation.isPending}
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
onClick={handleSynthesizeVoice}
>
{synthesizeMutation.isPending ? "合成中…" : "合成语音"}
</Button>
<Button buttonType="ghost" icon={<AudioOutlined />}>
</Button>
</div>
{/* TTS 状态反馈 */}
{ttsError && (
<Typography.Paragraph
style={{ color: "var(--error, #ef4444)", marginTop: 8 }}
>
{ttsError}
</Typography.Paragraph>
)}
{customAudioUrl && (
<Typography.Paragraph
style={{ color: "var(--success, #10b981)", marginTop: 8 }}
>
</Typography.Paragraph>
)}
</div>
) : (
/* ── 克隆我的声音 ── */