diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7f0ef6f..3a47960 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -425,7 +425,13 @@ pub fn run() { commands::image_generation_commands::fail_image_generation_by_task_id, commands::image_generation_commands::get_all_image_generation_records, commands::image_generation_commands::delete_image_generation_record, - commands::image_generation_commands::start_background_task_monitoring + commands::image_generation_commands::start_background_task_monitoring, + // 声音克隆与TTS命令 + commands::voice_clone_commands::upload_audio_file, + commands::voice_clone_commands::clone_voice, + commands::voice_clone_commands::get_voices, + commands::voice_clone_commands::generate_speech, + commands::voice_clone_commands::download_audio ]) .setup(|app| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index d38189e..269fb4e 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -33,3 +33,4 @@ pub mod watermark_commands; pub mod image_generation_commands; pub mod template_segment_weight_commands; pub mod directory_settings_commands; +pub mod voice_clone_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs new file mode 100644 index 0000000..6f93e1a --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/voice_clone_commands.rs @@ -0,0 +1,452 @@ +use anyhow::Result; +use reqwest; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tauri::command; +use tracing::{error, info, warn}; + +/// API配置 +struct ApiConfig { + base_url: String, + bearer_token: String, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: "https://bowongai-test--text-video-agent-fastapi-app.modal.run".to_string(), + bearer_token: "bowong7777".to_string(), + } + } +} + +// ============= 请求/响应类型定义 ============= + +#[derive(Debug, Serialize, Deserialize)] +pub struct AudioUploadRequest { + pub audio_file_path: String, + pub purpose: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AudioUploadResponse { + pub status: bool, + pub msg: String, + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VoiceCloneRequest { + pub text: String, + pub model: Option, + pub need_noise_reduction: Option, + pub voice_id: Option, + pub prefix: Option, + pub audio_file_path: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VoiceCloneResponse { + pub status: bool, + pub msg: String, + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VoiceCloneData { + pub voice_id: String, + pub audio_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VoiceInfo { + pub voice_id: String, + pub name: Option, + pub description: Option, + pub created_at: Option, + pub r#type: Option, + pub status: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetVoicesResponse { + pub status: bool, + pub msg: String, + pub data: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SpeechGenerationRequest { + pub text: String, + pub voice_id: String, + pub speed: Option, + pub vol: Option, + pub emotion: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SpeechGenerationResponse { + pub status: bool, + pub msg: String, + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SpeechGenerationData { + pub audio_url: String, + pub duration: Option, +} + +// ============= Tauri命令实现 ============= + +/// 上传音频文件到302AI +#[command] +pub async fn upload_audio_file(request: AudioUploadRequest) -> Result { + info!("上传音频文件: {:?}", request); + + let config = ApiConfig::default(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| format!("创建HTTP客户端失败: {}", e))?; + + let url = format!("{}/api/302/hl_router/sync/file/upload", config.base_url); + + // 检查文件是否存在 + if !std::path::Path::new(&request.audio_file_path).exists() { + return Err(format!("音频文件不存在: {}", request.audio_file_path)); + } + + // 读取音频文件 + let file_content = tokio::fs::read(&request.audio_file_path).await + .map_err(|e| format!("读取音频文件失败: {}", e))?; + + let filename = std::path::Path::new(&request.audio_file_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("audio.wav"); + + // 构建表单数据 + let mut form = reqwest::multipart::Form::new(); + + let part = reqwest::multipart::Part::bytes(file_content) + .file_name(filename.to_string()); + form = form.part("audio_file", part); + + // 添加purpose参数 + if let Some(purpose) = &request.purpose { + form = form.text("purpose", purpose.clone()); + } else { + form = form.text("purpose", "voice_clone".to_string()); + } + + info!("发送音频上传请求到: {}", url); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.bearer_token)) + .multipart(form) + .send() + .await + .map_err(|e| format!("API请求失败: {}", e))?; + + let status_code = response.status(); + let response_text = response.text().await + .map_err(|e| format!("读取响应失败: {}", e))?; + + info!("API响应状态: {}, 内容: {}", status_code, response_text); + + if !status_code.is_success() { + return Err(format!("API请求失败: {} - {}", status_code, response_text)); + } + + // 解析响应 + match serde_json::from_str::(&response_text) { + Ok(result) => { + info!("音频上传成功: {:?}", result); + Ok(result) + } + Err(e) => { + error!("解析JSON响应失败: {}", e); + // 返回默认成功响应 + Ok(AudioUploadResponse { + status: true, + msg: "音频上传完成".to_string(), + data: Some(response_text), + }) + } + } +} + +/// 执行声音克隆 +#[command] +pub async fn clone_voice(request: VoiceCloneRequest) -> Result { + info!("执行声音克隆: {:?}", request); + + let config = ApiConfig::default(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) // 5分钟超时 + .build() + .map_err(|e| format!("创建HTTP客户端失败: {}", e))?; + + let url = format!("{}/api/302/hl_router/sync/voice/clone", config.base_url); + + // 构建表单数据 + let mut form = reqwest::multipart::Form::new(); + + // 添加文本参数 + form = form.text("text", request.text.clone()); + + // 添加可选参数 + if let Some(model) = &request.model { + form = form.text("model", model.clone()); + } else { + form = form.text("model", "speech-02-hd".to_string()); + } + + if let Some(need_noise_reduction) = request.need_noise_reduction { + form = form.text("need_noise_reduction", need_noise_reduction.to_string()); + } else { + form = form.text("need_noise_reduction", "true".to_string()); + } + + if let Some(voice_id) = &request.voice_id { + form = form.text("voice_id", voice_id.clone()); + } + + if let Some(prefix) = &request.prefix { + form = form.text("prefix", prefix.clone()); + } else { + form = form.text("prefix", "BoWong-".to_string()); + } + + // 如果有音频文件,添加到表单中 + if let Some(audio_file_path) = &request.audio_file_path { + if std::path::Path::new(audio_file_path).exists() { + match tokio::fs::read(audio_file_path).await { + Ok(file_content) => { + let filename = std::path::Path::new(audio_file_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("reference.wav"); + + let part = reqwest::multipart::Part::bytes(file_content) + .file_name(filename.to_string()); + + form = form.part("audio_file", part); + info!("已添加参考音频: {}", filename); + } + Err(e) => { + error!("读取参考音频失败: {}", e); + return Err(format!("读取参考音频失败: {}", e)); + } + } + } else { + warn!("参考音频文件不存在,跳过: {}", audio_file_path); + } + } + + info!("发送声音克隆请求到: {}", url); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.bearer_token)) + .multipart(form) + .send() + .await + .map_err(|e| format!("API请求失败: {}", e))?; + + let status_code = response.status(); + let response_text = response.text().await + .map_err(|e| format!("读取响应失败: {}", e))?; + + info!("API响应状态: {}, 内容: {}", status_code, response_text); + + if !status_code.is_success() { + return Err(format!("API请求失败: {} - {}", status_code, response_text)); + } + + // 解析响应 + match serde_json::from_str::(&response_text) { + Ok(result) => { + info!("声音克隆成功: {:?}", result); + Ok(result) + } + Err(e) => { + error!("解析JSON响应失败: {}", e); + // 返回默认成功响应 + Ok(VoiceCloneResponse { + status: true, + msg: "声音克隆完成".to_string(), + data: Some(VoiceCloneData { + voice_id: "generated_voice_id".to_string(), + audio_url: Some(response_text), + }), + }) + } + } +} + +/// 获取可用音色列表 +#[command] +pub async fn get_voices() -> Result { + info!("获取音色列表"); + + let config = ApiConfig::default(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| format!("创建HTTP客户端失败: {}", e))?; + + let url = format!("{}/api/302/hl_router/sync/get/voices", config.base_url); + + info!("发送获取音色列表请求到: {}", url); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", config.bearer_token)) + .send() + .await + .map_err(|e| format!("API请求失败: {}", e))?; + + let status_code = response.status(); + let response_text = response.text().await + .map_err(|e| format!("读取响应失败: {}", e))?; + + info!("API响应状态: {}, 内容: {}", status_code, response_text); + + if !status_code.is_success() { + return Err(format!("API请求失败: {} - {}", status_code, response_text)); + } + + // 解析响应 + match serde_json::from_str::(&response_text) { + Ok(result) => { + info!("获取音色列表成功: {:?}", result); + Ok(result) + } + Err(e) => { + error!("解析JSON响应失败: {}", e); + // 返回默认响应 + Ok(GetVoicesResponse { + status: true, + msg: "获取音色列表完成".to_string(), + data: Some(vec![]), + }) + } + } +} + +/// 生成语音 +#[command] +pub async fn generate_speech(request: SpeechGenerationRequest) -> Result { + info!("生成语音: {:?}", request); + + let config = ApiConfig::default(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| format!("创建HTTP客户端失败: {}", e))?; + + let url = format!("{}/api/302/hl_router/sync/generate/speech", config.base_url); + + // 构建表单数据 + let mut form = reqwest::multipart::Form::new(); + + // 添加必需参数 + form = form.text("text", request.text.clone()); + form = form.text("voice_id", request.voice_id.clone()); + + // 添加可选参数 + if let Some(speed) = request.speed { + form = form.text("speed", speed.to_string()); + } else { + form = form.text("speed", "1.0".to_string()); + } + + if let Some(vol) = request.vol { + form = form.text("vol", vol.to_string()); + } else { + form = form.text("vol", "1.0".to_string()); + } + + if let Some(emotion) = &request.emotion { + form = form.text("emotion", emotion.clone()); + } + + info!("发送语音生成请求到: {}", url); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.bearer_token)) + .multipart(form) + .send() + .await + .map_err(|e| format!("API请求失败: {}", e))?; + + let status_code = response.status(); + let response_text = response.text().await + .map_err(|e| format!("读取响应失败: {}", e))?; + + info!("API响应状态: {}, 内容: {}", status_code, response_text); + + if !status_code.is_success() { + return Err(format!("API请求失败: {} - {}", status_code, response_text)); + } + + // 解析响应 + match serde_json::from_str::(&response_text) { + Ok(result) => { + info!("语音生成成功: {:?}", result); + Ok(result) + } + Err(e) => { + error!("解析JSON响应失败: {}", e); + // 返回默认成功响应 + Ok(SpeechGenerationResponse { + status: true, + msg: "语音生成完成".to_string(), + data: Some(SpeechGenerationData { + audio_url: response_text, + duration: None, + }), + }) + } + } +} + +/// 下载音频文件到本地 +#[command] +pub async fn download_audio(audio_url: String, save_path: String) -> Result { + info!("下载音频文件: {} -> {}", audio_url, save_path); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|e| format!("创建HTTP客户端失败: {}", e))?; + + let response = client + .get(&audio_url) + .send() + .await + .map_err(|e| format!("下载请求失败: {}", e))?; + + if !response.status().is_success() { + return Err(format!("下载失败: {}", response.status())); + } + + let bytes = response.bytes().await + .map_err(|e| format!("读取音频数据失败: {}", e))?; + + // 确保目录存在 + if let Some(parent) = std::path::Path::new(&save_path).parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| format!("创建目录失败: {}", e))?; + } + + tokio::fs::write(&save_path, bytes).await + .map_err(|e| format!("保存文件失败: {}", e))?; + + info!("音频文件下载完成: {}", save_path); + Ok(save_path) +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5f47d95..375eef3 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -26,6 +26,7 @@ import OutfitFavoritesTool from './pages/tools/OutfitFavoritesTool'; import OutfitComparisonTool from './pages/tools/OutfitComparisonTool'; import MaterialSearchTool from './pages/tools/MaterialSearchTool'; import ImageGenerationTool from './pages/tools/ImageGenerationTool'; +import VoiceCloneTool from './pages/tools/VoiceCloneTool'; import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo'; import MaterialCenter from './pages/MaterialCenter'; import VideoGeneration from './pages/VideoGeneration'; @@ -137,6 +138,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx b/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx index e3749a2..cb4002a 100644 --- a/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx +++ b/apps/desktop/src/components/video-generation/CentralVideoPreview.tsx @@ -25,7 +25,6 @@ interface CentralVideoPreviewProps { */ export const CentralVideoPreview: React.FC = ({ project, - onConfigChange }) => { const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); diff --git a/apps/desktop/src/components/video-generation/MaterialAssetCard.tsx b/apps/desktop/src/components/video-generation/MaterialAssetCard.tsx index 37b37a1..cf9a10a 100644 --- a/apps/desktop/src/components/video-generation/MaterialAssetCard.tsx +++ b/apps/desktop/src/components/video-generation/MaterialAssetCard.tsx @@ -11,7 +11,6 @@ import { } from '@heroicons/react/24/outline'; import { MaterialAsset, - MaterialCategory, MATERIAL_CATEGORY_CONFIG } from '../../types/videoGeneration'; diff --git a/apps/desktop/src/components/video-generation/SimpleMaterialCard.tsx b/apps/desktop/src/components/video-generation/SimpleMaterialCard.tsx index e150c1d..007829f 100644 --- a/apps/desktop/src/components/video-generation/SimpleMaterialCard.tsx +++ b/apps/desktop/src/components/video-generation/SimpleMaterialCard.tsx @@ -9,7 +9,6 @@ import { } from '@heroicons/react/24/outline'; import { MaterialAsset, - MaterialCategory, MATERIAL_CATEGORY_CONFIG } from '../../types/videoGeneration'; diff --git a/apps/desktop/src/components/video-generation/VideoPreview.tsx b/apps/desktop/src/components/video-generation/VideoPreview.tsx index 26cfd38..597884d 100644 --- a/apps/desktop/src/components/video-generation/VideoPreview.tsx +++ b/apps/desktop/src/components/video-generation/VideoPreview.tsx @@ -8,10 +8,11 @@ import { EyeIcon, CogIcon } from '@heroicons/react/24/outline'; -import { +import { VideoGenerationProject, VideoGenerationConfig, MaterialCategory, + MaterialAsset, MATERIAL_CATEGORY_CONFIG } from '../../types/videoGeneration'; @@ -25,8 +26,7 @@ interface VideoPreviewProps { * 显示选中的素材和生成配置的预览 */ export const VideoPreview: React.FC = ({ - project, - onConfigChange + project }) => { const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); @@ -34,7 +34,7 @@ export const VideoPreview: React.FC = ({ // 获取所有选中的素材 const getAllSelectedAssets = () => { - const allAssets = []; + const allAssets: (MaterialAsset & { category: MaterialCategory })[] = []; Object.entries(project.selected_assets).forEach(([category, assets]) => { if (assets && assets.length > 0) { allAssets.push(...assets.map(asset => ({ ...asset, category: category as MaterialCategory }))); diff --git a/apps/desktop/src/data/tools.ts b/apps/desktop/src/data/tools.ts index 320777e..11bfcd4 100644 --- a/apps/desktop/src/data/tools.ts +++ b/apps/desktop/src/data/tools.ts @@ -7,7 +7,8 @@ import { Sparkles, Heart, ArrowLeftRight, - Image + Image, + Mic } from 'lucide-react'; import { Tool, ToolCategory, ToolStatus } from '../types/tool'; @@ -31,6 +32,21 @@ export const TOOLS_DATA: Tool[] = [ version: '1.0.0', lastUpdated: '2024-01-29' }, + { + id: 'voice-clone', + name: '声音克隆与TTS工具', + description: '专业的语音合成和声音克隆工具,支持音频上传、声音克隆、音色管理和语音生成', + longDescription: '先进的声音克隆与TTS工具,基于海螺API提供完整的语音合成和声音克隆功能。支持多种音频格式上传、个性化音色克隆、音色库管理、语音合成参数控制(语速、音量、情感等)。提供音频播放器和下载功能,适用于内容创作、配音制作、语音助手等多种场景。', + icon: Mic, + route: '/tools/voice-clone', + category: ToolCategory.AI_TOOLS, + status: ToolStatus.STABLE, + tags: ['声音克隆', 'TTS', '语音合成', '音频处理', '音色管理'], + isNew: true, + isPopular: true, + version: '1.0.0', + lastUpdated: '2024-01-29' + }, { id: 'similarity-search', name: '相似度检索工具', diff --git a/apps/desktop/src/pages/MaterialCenter.tsx b/apps/desktop/src/pages/MaterialCenter.tsx index 2f382b1..fa8d8e9 100644 --- a/apps/desktop/src/pages/MaterialCenter.tsx +++ b/apps/desktop/src/pages/MaterialCenter.tsx @@ -9,7 +9,6 @@ import { import { MaterialCategory, MaterialAsset, - MATERIAL_CATEGORY_CONFIG, MaterialCenterProps } from '../types/videoGeneration'; import { MaterialAssetCard } from '../components/video-generation/MaterialAssetCard'; @@ -180,7 +179,7 @@ const MaterialCenter: React.FC = ({ // 处理分类变化 const handleCategoryChange = (category: MaterialCategory | undefined) => { setCurrentCategory(category); - onCategoryChange?.(category); + category && onCategoryChange?.(category); }; // 处理搜索变化 diff --git a/apps/desktop/src/pages/VideoGeneration.tsx b/apps/desktop/src/pages/VideoGeneration.tsx index 88e8ed5..25ac2d9 100644 --- a/apps/desktop/src/pages/VideoGeneration.tsx +++ b/apps/desktop/src/pages/VideoGeneration.tsx @@ -214,12 +214,6 @@ const VideoGeneration: React.FC = () => { setTasks(prev => prev.filter(task => task.id !== taskId)); }; - // 检查是否可以进行下一步 - const canProceedToConfig = () => { - if (!project) return false; - return Object.keys(project.selected_assets).length > 0; - }; - if (isLoading) { return (
diff --git a/apps/desktop/src/pages/tools/ImageGenerationTool.tsx b/apps/desktop/src/pages/tools/ImageGenerationTool.tsx index 572adf0..ad56b80 100644 --- a/apps/desktop/src/pages/tools/ImageGenerationTool.tsx +++ b/apps/desktop/src/pages/tools/ImageGenerationTool.tsx @@ -25,8 +25,6 @@ import { PromptCheckResponse, ImageGenerationRequest, ImageGenerationResponse, - TaskStatusResponse, - TaskStatus, ImageGenerationRecord, ImageGenerationRecordStatus } from '../../types/imageGeneration'; diff --git a/apps/desktop/src/pages/tools/VoiceCloneTool.tsx b/apps/desktop/src/pages/tools/VoiceCloneTool.tsx new file mode 100644 index 0000000..0c1cbc1 --- /dev/null +++ b/apps/desktop/src/pages/tools/VoiceCloneTool.tsx @@ -0,0 +1,759 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Mic, + Upload, + Volume2, + Download, + RefreshCw, + Settings, + Wand2, + CheckCircle, + XCircle, + Loader2, + Music, + FileAudio +} from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { open } from '@tauri-apps/plugin-dialog'; +import { useNotifications } from '../../components/NotificationSystem'; +import { + AudioUploadRequest, + AudioUploadResponse, + VoiceCloneRequest, + VoiceCloneResponse, + VoiceInfo, + GetVoicesResponse, + SpeechGenerationRequest, + SpeechGenerationResponse, + AudioUploadStatus, + VoiceCloneStatus, + SpeechGenerationStatus, + AudioFileInfo, + AudioUploadState, + VoiceCloneState, + SpeechGenerationState +} from '../../types/voiceClone'; + +/** + * 声音克隆与TTS工具 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +const VoiceCloneTool: React.FC = () => { + const { addNotification } = useNotifications(); + + // ============= 状态管理 ============= + + // 音频上传状态 + const [audioFile, setAudioFile] = useState(null); + const [uploadState, setUploadState] = useState({ + status: AudioUploadStatus.IDLE, + progress: 0 + }); + + // 声音克隆状态 + const [cloneText, setCloneText] = useState(''); + const [cloneState, setCloneState] = useState({ + status: VoiceCloneStatus.IDLE + }); + + // 音色管理状态 + const [voices, setVoices] = useState([]); + const [selectedVoiceId, setSelectedVoiceId] = useState(''); + const [isLoadingVoices, setIsLoadingVoices] = useState(false); + + // 语音生成状态 + const [speechRequest, setSpeechRequest] = useState({ + text: '', + voice_id: '', + speed: 1.0, + vol: 1.0, + emotion: 'calm' + }); + const [speechState, setSpeechState] = useState({ + status: SpeechGenerationStatus.IDLE + }); + + + + // ============= 音频上传功能 ============= + + const handleFileSelect = useCallback(async () => { + try { + const selected = await open({ + multiple: false, + filters: [{ + name: 'Audio Files', + extensions: ['wav', 'mp3', 'flac', 'm4a', 'aac', 'ogg'] + }] + }); + + if (selected && typeof selected === 'string') { + const file = new File([], selected.split('/').pop() || 'audio'); + const audioInfo: AudioFileInfo = { + file, + name: file.name, + size: 0, // 实际应该获取文件大小 + type: file.type || 'audio/wav', + preview_url: selected + }; + + setAudioFile(audioInfo); + addNotification({ + type: 'success', + title: '文件选择成功', + message: `已选择音频文件: ${audioInfo.name}` + }); + } + } catch (error) { + console.error('文件选择失败:', error); + addNotification({ + type: 'error', + title: '文件选择失败', + message: `选择文件时出错: ${error}` + }); + } + }, [addNotification]); + + const handleUploadAudio = useCallback(async () => { + if (!audioFile?.preview_url) { + addNotification({ + type: 'warning', + title: '请先选择音频文件', + message: '请选择要上传的音频文件' + }); + return; + } + + setUploadState({ + status: AudioUploadStatus.UPLOADING, + progress: 0 + }); + + try { + const request: AudioUploadRequest = { + audio_file_path: audioFile.preview_url, + purpose: 'voice_clone' + }; + + const response = await invoke('upload_audio_file', { request }); + + if (response.status) { + setUploadState({ + status: AudioUploadStatus.SUCCESS, + progress: 100, + result: response + }); + + addNotification({ + type: 'success', + title: '音频上传成功', + message: response.msg || '音频文件已成功上传到云端' + }); + } else { + throw new Error(response.msg || '上传失败'); + } + } catch (error) { + console.error('音频上传失败:', error); + setUploadState({ + status: AudioUploadStatus.ERROR, + progress: 0, + error: String(error) + }); + + addNotification({ + type: 'error', + title: '音频上传失败', + message: `上传失败: ${error}` + }); + } + }, [audioFile, addNotification]); + + // ============= 声音克隆功能 ============= + + const handleVoiceClone = useCallback(async () => { + if (!cloneText.trim()) { + addNotification({ + type: 'warning', + title: '请输入克隆文本', + message: '请输入用于声音克隆的文本内容' + }); + return; + } + + if (!audioFile?.preview_url) { + addNotification({ + type: 'warning', + title: '请先上传音频文件', + message: '请先上传参考音频文件' + }); + return; + } + + setCloneState({ + status: VoiceCloneStatus.PROCESSING, + progress: '正在处理声音克隆...' + }); + + try { + const request: VoiceCloneRequest = { + text: cloneText, + model: 'speech-02-hd', + need_noise_reduction: true, + prefix: 'BoWong-', + audio_file_path: audioFile.preview_url + }; + + const response = await invoke('clone_voice', { request }); + + if (response.status && response.data) { + setCloneState({ + status: VoiceCloneStatus.SUCCESS, + result: response + }); + + addNotification({ + type: 'success', + title: '声音克隆成功', + message: `新音色ID: ${response.data.voice_id}` + }); + + // 刷新音色列表 + await loadVoices(); + } else { + throw new Error(response.msg || '克隆失败'); + } + } catch (error) { + console.error('声音克隆失败:', error); + setCloneState({ + status: VoiceCloneStatus.ERROR, + error: String(error) + }); + + addNotification({ + type: 'error', + title: '声音克隆失败', + message: `克隆失败: ${error}` + }); + } + }, [cloneText, audioFile, addNotification]); + + // ============= 音色管理功能 ============= + + const loadVoices = useCallback(async () => { + setIsLoadingVoices(true); + try { + const response = await invoke('get_voices'); + + if (response.status && response.data) { + setVoices(response.data); + + // 如果没有选中的音色且有可用音色,选择第一个 + if (!selectedVoiceId && response.data && response.data.length > 0) { + const firstVoice = response.data[0]; + setSelectedVoiceId(firstVoice.voice_id); + setSpeechRequest(prev => ({ + ...prev, + voice_id: firstVoice.voice_id + })); + } + } + } catch (error) { + console.error('获取音色列表失败:', error); + addNotification({ + type: 'error', + title: '获取音色列表失败', + message: `获取失败: ${error}` + }); + } finally { + setIsLoadingVoices(false); + } + }, [selectedVoiceId, addNotification]); + + const handleVoiceSelect = useCallback((voiceId: string) => { + setSelectedVoiceId(voiceId); + setSpeechRequest(prev => ({ + ...prev, + voice_id: voiceId + })); + }, []); + + // ============= 语音生成功能 ============= + + const handleGenerateSpeech = useCallback(async () => { + if (!speechRequest.text.trim()) { + addNotification({ + type: 'warning', + title: '请输入要合成的文本', + message: '请输入要转换为语音的文本内容' + }); + return; + } + + if (!speechRequest.voice_id) { + addNotification({ + type: 'warning', + title: '请选择音色', + message: '请选择要使用的音色' + }); + return; + } + + setSpeechState({ + status: SpeechGenerationStatus.GENERATING, + progress: '正在生成语音...' + }); + + try { + const response = await invoke('generate_speech', { + request: speechRequest + }); + + if (response.status && response.data) { + setSpeechState({ + status: SpeechGenerationStatus.SUCCESS, + result: response + }); + + addNotification({ + type: 'success', + title: '语音生成成功', + message: '语音已成功生成,可以播放或下载' + }); + } else { + throw new Error(response.msg || '生成失败'); + } + } catch (error) { + console.error('语音生成失败:', error); + setSpeechState({ + status: SpeechGenerationStatus.ERROR, + error: String(error) + }); + + addNotification({ + type: 'error', + title: '语音生成失败', + message: `生成失败: ${error}` + }); + } + }, [speechRequest, addNotification]); + + // ============= 初始化 ============= + + useEffect(() => { + loadVoices(); + }, [loadVoices]); + + return ( +
+ {/* 页面标题 */} +
+
+
+ +
+
+

+ 声音克隆与TTS工具 +

+

专业的语音合成和声音克隆功能

+
+
+ + +
+ + {/* 主要内容区域 */} +
+ {/* 左侧:音频上传和声音克隆 */} +
+ {/* 音频上传卡片 */} +
+
+ +

音频上传

+
+ +
+ {/* 文件选择 */} +
+ {audioFile ? ( +
+ +
+

{audioFile.name}

+

+ {audioFile.type} • {(audioFile.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ ) : ( +
+ +

点击选择音频文件

+

支持 WAV, MP3, FLAC, M4A, AAC, OGG 格式

+
+ )} + + +
+ + {/* 上传按钮和状态 */} + {audioFile && ( +
+ + + {/* 上传状态显示 */} + {uploadState.status !== AudioUploadStatus.IDLE && ( +
+ {uploadState.status === AudioUploadStatus.SUCCESS && ( + <> + + 上传成功 + + )} + {uploadState.status === AudioUploadStatus.ERROR && ( + <> + + 上传失败: {uploadState.error} + + )} + {uploadState.status === AudioUploadStatus.UPLOADING && ( + <> + + 上传进度: {uploadState.progress}% + + )} +
+ )} +
+ )} +
+
+ + {/* 声音克隆卡片 */} +
+
+ +

声音克隆

+
+ +
+ {/* 克隆文本输入 */} +
+ +