This commit is contained in:
root
2025-07-11 10:51:38 +08:00
parent fd884a5380
commit 9e95d73087
12 changed files with 1807 additions and 0 deletions

View File

@@ -0,0 +1,505 @@
"""
音频库管理服务
管理音频文件包括上传、批量处理、MD5唯一键、节奏提取和频率图绘制
"""
import json
import uuid
import os
import hashlib
import shutil
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime
import base64
from python_core.config import settings
from python_core.utils.logger import logger
from python_core.utils.jsonrpc import create_response_handler
# 音频处理库
try:
import librosa
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg') # 使用非交互式后端
AUDIO_LIBS_AVAILABLE = True
except ImportError:
logger.warning("Audio processing libraries not available. Install librosa, numpy, matplotlib for full functionality.")
AUDIO_LIBS_AVAILABLE = False
@dataclass
class AudioFile:
"""音频文件数据结构"""
id: str
filename: str
file_path: str
md5_hash: str
file_size: int
duration: float
sample_rate: int
channels: int
format: str
tempo: Optional[float] = None
beat_times: Optional[List[float]] = None
spectral_centroid: Optional[float] = None
spectral_rolloff: Optional[float] = None
zero_crossing_rate: Optional[float] = None
mfcc_features: Optional[List[float]] = None
frequency_chart_path: Optional[str] = None
created_at: str = ""
updated_at: str = ""
is_active: bool = True
# 视频分析 推荐使用 audio_processing.core.AudioProcessor
class AudioManager:
"""音频管理器"""
def __init__(self):
self.cache_dir = settings.temp_dir / "cache"
self.cache_dir.mkdir(parents=True, exist_ok=True)
# 音频数据文件
self.audio_files_file = self.cache_dir / "audio_files.json"
self.audio_files = self._load_audio_files()
# 音频存储目录
self.audio_storage_dir = settings.temp_dir / "audio_storage"
self.audio_storage_dir.mkdir(parents=True, exist_ok=True)
# 频率图存储目录
self.charts_dir = settings.temp_dir / "audio_charts"
self.charts_dir.mkdir(parents=True, exist_ok=True)
def _load_audio_files(self) -> List[AudioFile]:
"""加载音频文件数据"""
if self.audio_files_file.exists():
try:
with open(self.audio_files_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return [AudioFile(**item) for item in data]
except Exception as e:
logger.error(f"Failed to load audio files: {e}")
return []
else:
return []
def _save_audio_files(self, audio_files: List[AudioFile] = None):
"""保存音频文件数据"""
if audio_files is None:
audio_files = self.audio_files
try:
data = [asdict(audio_file) for audio_file in audio_files]
with open(self.audio_files_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"Audio files saved to {self.audio_files_file}")
except Exception as e:
logger.error(f"Failed to save audio files: {e}")
raise
def _calculate_md5(self, file_path: str) -> str:
"""计算文件MD5哈希值"""
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def _get_audio_info(self, file_path: str) -> Dict:
"""获取音频基本信息"""
if not AUDIO_LIBS_AVAILABLE:
# 如果没有音频处理库,返回基本信息
file_size = os.path.getsize(file_path)
return {
'duration': 0.0,
'sample_rate': 0,
'channels': 0,
'file_size': file_size
}
try:
# 使用librosa获取音频信息
y, sr = librosa.load(file_path, sr=None)
duration = librosa.get_duration(y=y, sr=sr)
return {
'duration': duration,
'sample_rate': sr,
'channels': 1 if len(y.shape) == 1 else y.shape[0],
'file_size': os.path.getsize(file_path)
}
except Exception as e:
logger.error(f"Failed to get audio info: {e}")
file_size = os.path.getsize(file_path)
return {
'duration': 0.0,
'sample_rate': 0,
'channels': 0,
'file_size': file_size
}
def _extract_audio_features(self, file_path: str) -> Dict:
"""提取音频特征(节奏、频谱等)"""
if not AUDIO_LIBS_AVAILABLE:
return {
'tempo': None,
'beat_times': None,
'spectral_centroid': None,
'spectral_rolloff': None,
'zero_crossing_rate': None,
'mfcc_features': None
}
try:
# 加载音频
y, sr = librosa.load(file_path, sr=None)
# 提取节拍和节奏
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
# 提取频谱特征
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)[0]
zero_crossing_rate = librosa.feature.zero_crossing_rate(y)[0]
# 提取MFCC特征
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
mfcc_mean = np.mean(mfccs, axis=1).tolist()
return {
'tempo': float(tempo),
'beat_times': beat_times,
'spectral_centroid': float(np.mean(spectral_centroids)),
'spectral_rolloff': float(np.mean(spectral_rolloff)),
'zero_crossing_rate': float(np.mean(zero_crossing_rate)),
'mfcc_features': mfcc_mean
}
except Exception as e:
logger.error(f"Failed to extract audio features: {e}")
return {
'tempo': None,
'beat_times': None,
'spectral_centroid': None,
'spectral_rolloff': None,
'zero_crossing_rate': None,
'mfcc_features': None
}
def _generate_frequency_chart(self, file_path: str, audio_id: str) -> Optional[str]:
"""生成音频频率图"""
if not AUDIO_LIBS_AVAILABLE:
return None
try:
# 加载音频
y, sr = librosa.load(file_path, sr=None)
# 生成频谱图
plt.figure(figsize=(12, 8))
# 子图1: 波形图
plt.subplot(3, 1, 1)
plt.plot(np.linspace(0, len(y)/sr, len(y)), y)
plt.title('Waveform')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
# 子图2: 频谱图
plt.subplot(3, 1, 2)
D = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max)
librosa.display.specshow(D, sr=sr, x_axis='time', y_axis='hz')
plt.colorbar(format='%+2.0f dB')
plt.title('Spectrogram')
# 子图3: 梅尔频谱图
plt.subplot(3, 1, 3)
S = librosa.feature.melspectrogram(y=y, sr=sr)
S_dB = librosa.power_to_db(S, ref=np.max)
librosa.display.specshow(S_dB, sr=sr, x_axis='time', y_axis='mel')
plt.colorbar(format='%+2.0f dB')
plt.title('Mel-frequency spectrogram')
plt.tight_layout()
# 保存图表
chart_filename = f"{audio_id}_frequency_chart.png"
chart_path = self.charts_dir / chart_filename
plt.savefig(chart_path, dpi=150, bbox_inches='tight')
plt.close()
return str(chart_path)
except Exception as e:
logger.error(f"Failed to generate frequency chart: {e}")
return None
def get_audio_by_md5(self, md5_hash: str) -> Optional[Dict]:
"""根据MD5获取音频文件"""
for audio_file in self.audio_files:
if audio_file.md5_hash == md5_hash and audio_file.is_active:
return asdict(audio_file)
return None
def get_all_audio_files(self) -> List[Dict]:
"""获取所有音频文件"""
return [asdict(audio_file) for audio_file in self.audio_files if audio_file.is_active]
def get_audio_by_id(self, audio_id: str) -> Optional[Dict]:
"""根据ID获取音频文件"""
for audio_file in self.audio_files:
if audio_file.id == audio_id and audio_file.is_active:
return asdict(audio_file)
return None
def upload_audio_file(self, source_path: str, filename: str = None) -> Dict:
"""上传单个音频文件"""
if not os.path.exists(source_path):
raise FileNotFoundError(f"Source file not found: {source_path}")
# 计算MD5
md5_hash = self._calculate_md5(source_path)
# 检查是否已存在相同MD5的文件
existing = self.get_audio_by_md5(md5_hash)
if existing:
logger.info(f"Audio file with MD5 {md5_hash} already exists")
return existing
# 生成新的音频ID和文件名
audio_id = str(uuid.uuid4())
if filename is None:
filename = os.path.basename(source_path)
# 获取文件扩展名
file_ext = os.path.splitext(filename)[1].lower()
stored_filename = f"{audio_id}{file_ext}"
stored_path = self.audio_storage_dir / stored_filename
# 复制文件到存储目录
shutil.copy2(source_path, stored_path)
# 获取音频基本信息
audio_info = self._get_audio_info(str(stored_path))
# 提取音频特征
features = self._extract_audio_features(str(stored_path))
# 生成频率图
chart_path = self._generate_frequency_chart(str(stored_path), audio_id)
# 创建音频文件记录
now = datetime.now().isoformat()
audio_file = AudioFile(
id=audio_id,
filename=filename,
file_path=str(stored_path),
md5_hash=md5_hash,
file_size=audio_info['file_size'],
duration=audio_info['duration'],
sample_rate=audio_info['sample_rate'],
channels=audio_info['channels'],
format=file_ext[1:] if file_ext else 'unknown',
tempo=features['tempo'],
beat_times=features['beat_times'],
spectral_centroid=features['spectral_centroid'],
spectral_rolloff=features['spectral_rolloff'],
zero_crossing_rate=features['zero_crossing_rate'],
mfcc_features=features['mfcc_features'],
frequency_chart_path=chart_path,
created_at=now,
updated_at=now
)
self.audio_files.append(audio_file)
self._save_audio_files()
logger.info(f"Uploaded audio file: {filename} (MD5: {md5_hash})")
return asdict(audio_file)
def batch_upload_audio_files(self, source_directory: str) -> Dict:
"""批量上传音频文件"""
if not os.path.exists(source_directory):
raise FileNotFoundError(f"Source directory not found: {source_directory}")
# 支持的音频格式
audio_extensions = {'.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma'}
results = {
'total_files': 0,
'uploaded_files': 0,
'skipped_files': 0,
'failed_files': 0,
'uploaded_list': [],
'skipped_list': [],
'failed_list': []
}
# 遍历目录中的所有文件
for root, dirs, files in os.walk(source_directory):
for file in files:
file_path = os.path.join(root, file)
file_ext = os.path.splitext(file)[1].lower()
# 检查是否为音频文件
if file_ext not in audio_extensions:
continue
results['total_files'] += 1
try:
# 尝试上传文件
result = self.upload_audio_file(file_path, file)
# 检查是否为新上传的文件
if any(existing['md5_hash'] == result['md5_hash']
for existing in results['uploaded_list']):
results['skipped_files'] += 1
results['skipped_list'].append({
'filename': file,
'reason': 'Already exists (same MD5)'
})
else:
results['uploaded_files'] += 1
results['uploaded_list'].append(result)
except Exception as e:
results['failed_files'] += 1
results['failed_list'].append({
'filename': file,
'error': str(e)
})
logger.error(f"Failed to upload {file}: {e}")
logger.info(f"Batch upload completed: {results['uploaded_files']} uploaded, "
f"{results['skipped_files']} skipped, {results['failed_files']} failed")
return results
def delete_audio_file(self, audio_id: str) -> bool:
"""删除音频文件"""
for i, audio_file in enumerate(self.audio_files):
if audio_file.id == audio_id:
# 删除物理文件
try:
if os.path.exists(audio_file.file_path):
os.remove(audio_file.file_path)
# 删除频率图
if audio_file.frequency_chart_path and os.path.exists(audio_file.frequency_chart_path):
os.remove(audio_file.frequency_chart_path)
except Exception as e:
logger.error(f"Failed to delete physical files: {e}")
# 从列表中移除
deleted_audio = self.audio_files.pop(i)
self._save_audio_files()
logger.info(f"Deleted audio file: {audio_id} - {deleted_audio.filename}")
return True
return False
def search_audio_files(self, keyword: str) -> List[Dict]:
"""搜索音频文件"""
keyword = keyword.lower()
results = []
for audio_file in self.audio_files:
if (audio_file.is_active and
keyword in audio_file.filename.lower()):
results.append(asdict(audio_file))
return results
# 全局实例
audio_manager = AudioManager()
def main():
"""命令行接口 - 使用JSON-RPC协议"""
import sys
import json
# 创建响应处理器
rpc = create_response_handler()
if len(sys.argv) < 2:
rpc.error("INVALID_REQUEST", "No command specified")
return
command = sys.argv[1]
try:
if command == "get_all_audio_files":
audio_files = audio_manager.get_all_audio_files()
rpc.success(audio_files)
elif command == "get_audio_by_id":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Audio ID required")
return
audio_id = sys.argv[2]
audio_file = audio_manager.get_audio_by_id(audio_id)
if audio_file:
rpc.success(audio_file)
else:
rpc.error("NOT_FOUND", "Audio file not found")
elif command == "get_audio_by_md5":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "MD5 hash required")
return
md5_hash = sys.argv[2]
audio_file = audio_manager.get_audio_by_md5(md5_hash)
if audio_file:
rpc.success(audio_file)
else:
rpc.error("NOT_FOUND", "Audio file not found")
elif command == "upload_audio_file":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Source path required")
return
source_path = sys.argv[2]
filename = sys.argv[3] if len(sys.argv) > 3 else None
result = audio_manager.upload_audio_file(source_path, filename)
rpc.success(result)
elif command == "batch_upload_audio_files":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Source directory required")
return
source_directory = sys.argv[2]
result = audio_manager.batch_upload_audio_files(source_directory)
rpc.success(result)
elif command == "delete_audio_file":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Audio ID required")
return
audio_id = sys.argv[2]
success = audio_manager.delete_audio_file(audio_id)
rpc.success(success)
elif command == "search_audio_files":
if len(sys.argv) < 3:
rpc.error("INVALID_REQUEST", "Search keyword required")
return
keyword = sys.argv[2]
results = audio_manager.search_audio_files(keyword)
rpc.success(results)
else:
rpc.error("INVALID_REQUEST", f"Unknown command: {command}")
except Exception as e:
logger.error(f"Command execution failed: {e}")
rpc.error("INTERNAL_ERROR", str(e))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
use tauri::{command, AppHandle};
use crate::python_executor::execute_python_command;
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadAudioRequest {
pub source_path: String,
pub filename: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchUploadRequest {
pub source_directory: String,
}
/// 获取所有音频文件
#[command]
pub async fn get_all_audio_files(app: AppHandle) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"get_all_audio_files".to_string(),
];
execute_python_command(app, &args, None).await
}
/// 根据ID获取音频文件
#[command]
pub async fn get_audio_by_id(app: AppHandle, audio_id: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"get_audio_by_id".to_string(),
audio_id,
];
execute_python_command(app, &args, None).await
}
/// 根据MD5获取音频文件
#[command]
pub async fn get_audio_by_md5(app: AppHandle, md5_hash: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"get_audio_by_md5".to_string(),
md5_hash,
];
execute_python_command(app, &args, None).await
}
/// 上传单个音频文件
#[command]
pub async fn upload_audio_file(app: AppHandle, request: UploadAudioRequest) -> Result<String, String> {
let mut args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"upload_audio_file".to_string(),
request.source_path,
];
if let Some(filename) = request.filename {
args.push(filename);
}
execute_python_command(app, &args, None).await
}
/// 批量上传音频文件
#[command]
pub async fn batch_upload_audio_files(app: AppHandle, request: BatchUploadRequest) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"batch_upload_audio_files".to_string(),
request.source_directory,
];
execute_python_command(app, &args, None).await
}
/// 删除音频文件
#[command]
pub async fn delete_audio_file(app: AppHandle, audio_id: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"delete_audio_file".to_string(),
audio_id,
];
execute_python_command(app, &args, None).await
}
/// 搜索音频文件
#[command]
pub async fn search_audio_files(app: AppHandle, keyword: String) -> Result<String, String> {
let args = vec![
"-m".to_string(),
"python_core.services.audio_manager".to_string(),
"search_audio_files".to_string(),
keyword,
];
execute_python_command(app, &args, None).await
}

View File

@@ -7,6 +7,7 @@ pub mod project;
pub mod template;
pub mod file_utils;
pub mod model;
pub mod audio;
pub mod resource_category;
// Re-export all commands for easy access

View File

@@ -62,6 +62,13 @@ pub fn run() {
commands::model::update_model,
commands::model::delete_model,
commands::model::search_models,
commands::audio::get_all_audio_files,
commands::audio::get_audio_by_id,
commands::audio::get_audio_by_md5,
commands::audio::upload_audio_file,
commands::audio::batch_upload_audio_files,
commands::audio::delete_audio_file,
commands::audio::search_audio_files,
commands::file_utils::read_image_as_data_url
])
.run(tauri::generate_context!())

View File

@@ -10,6 +10,7 @@ import TemplateDetailPage from './pages/TemplateDetailPage'
import ResourceCategoryPage from './pages/ResourceCategoryPage'
import ProjectManagePage from './pages/ProjectManagePage'
import ModelManagePage from './pages/ModelManagePage'
import AudioLibraryPage from './pages/AudioLibraryPage'
import KVTestPage from './pages/KVTestPage'
function App() {
@@ -24,6 +25,7 @@ function App() {
<Route path="/resource-categories" element={<ResourceCategoryPage />} />
<Route path="/projects" element={<ProjectManagePage />} />
<Route path="/models" element={<ModelManagePage />} />
<Route path="/audio" element={<AudioLibraryPage />} />
<Route path="/kv-test" element={<KVTestPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react'
import { Play, Pause, Music, Trash2, BarChart3, Clock, HardDrive, Hash } from 'lucide-react'
import { AudioFile, AudioService } from '../services/audioService'
interface AudioCardProps {
audio: AudioFile
onDelete: (audioId: string) => void
onShowChart: (audio: AudioFile) => void
}
const AudioCard: React.FC<AudioCardProps> = ({
audio,
onDelete,
onShowChart
}) => {
const [isPlaying, setIsPlaying] = useState(false)
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null)
const handlePlayPause = () => {
if (!audioElement) {
// 创建音频元素
const audio_el = new Audio(`file://${audio.file_path}`)
audio_el.addEventListener('ended', () => setIsPlaying(false))
audio_el.addEventListener('error', (e) => {
console.error('Audio playback error:', e)
setIsPlaying(false)
})
setAudioElement(audio_el)
audio_el.play()
.then(() => setIsPlaying(true))
.catch(err => {
console.error('Failed to play audio:', err)
setIsPlaying(false)
})
} else {
if (isPlaying) {
audioElement.pause()
setIsPlaying(false)
} else {
audioElement.play()
.then(() => setIsPlaying(true))
.catch(err => {
console.error('Failed to play audio:', err)
setIsPlaying(false)
})
}
}
}
const handleDelete = () => {
if (audioElement) {
audioElement.pause()
setAudioElement(null)
setIsPlaying(false)
}
onDelete(audio.id)
}
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
{/* 音频信息头部 */}
<div className="p-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 rounded-lg">
<Music size={24} />
</div>
<div>
<h3 className="font-medium truncate max-w-48" title={audio.filename}>
{audio.filename}
</h3>
<p className="text-sm text-purple-100">
{audio.format.toUpperCase()} {AudioService.formatDuration(audio.duration)}
</p>
</div>
</div>
{/* 播放按钮 */}
<button
onClick={handlePlayPause}
className="p-2 bg-white/20 rounded-lg hover:bg-white/30 transition-colors"
title={isPlaying ? '暂停' : '播放'}
>
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
</button>
</div>
</div>
{/* 音频详细信息 */}
<div className="p-4 space-y-3">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center space-x-2">
<Clock size={14} className="text-gray-400" />
<span className="text-gray-600">:</span>
<span className="font-medium">{AudioService.formatDuration(audio.duration)}</span>
</div>
<div className="flex items-center space-x-2">
<HardDrive size={14} className="text-gray-400" />
<span className="text-gray-600">:</span>
<span className="font-medium">{AudioService.formatFileSize(audio.file_size)}</span>
</div>
</div>
{/* 音频参数 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.sample_rate} Hz</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.channels}</span>
</div>
</div>
{/* 节奏信息 */}
{audio.tempo && (
<div className="text-sm">
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.tempo.toFixed(1)} BPM</span>
</div>
)}
{/* 频谱特征 */}
{audio.spectral_centroid && (
<div className="text-sm space-y-1">
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.spectral_centroid.toFixed(0)} Hz</span>
</div>
{audio.zero_crossing_rate && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{(audio.zero_crossing_rate * 100).toFixed(2)}%</span>
</div>
)}
</div>
)}
{/* MD5哈希 */}
<div className="text-xs text-gray-500 flex items-center space-x-1">
<Hash size={12} />
<span>MD5:</span>
<code className="bg-gray-100 px-1 rounded font-mono">
{audio.md5_hash.substring(0, 8)}...
</code>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="text-xs text-gray-400">
{new Date(audio.created_at).toLocaleDateString()}
</div>
<div className="flex space-x-2">
{/* 频率图按钮 */}
{audio.frequency_chart_path && (
<button
onClick={() => onShowChart(audio)}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="查看频率图"
>
<BarChart3 size={16} />
</button>
)}
{/* 删除按钮 */}
<button
onClick={handleDelete}
className="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
</div>
)
}
export default AudioCard

View File

@@ -0,0 +1,191 @@
import React, { useState, useEffect } from 'react'
import { X, BarChart3, Download } from 'lucide-react'
import { invoke } from '@tauri-apps/api/core'
import { AudioFile } from '../services/audioService'
interface AudioChartViewerProps {
isOpen: boolean
audio: AudioFile | null
onClose: () => void
}
const AudioChartViewer: React.FC<AudioChartViewerProps> = ({
isOpen,
audio,
onClose
}) => {
const [chartImageSrc, setChartImageSrc] = useState<string>('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (isOpen && audio && audio.frequency_chart_path) {
loadChartImage()
}
}, [isOpen, audio])
const loadChartImage = async () => {
if (!audio?.frequency_chart_path) return
setLoading(true)
try {
// 使用Tauri的文件读取API将图片转换为data URL
const dataUrl = await invoke<string>('read_image_as_data_url', {
filePath: audio.frequency_chart_path
})
setChartImageSrc(dataUrl)
} catch (error) {
console.error('Failed to load chart image:', error)
setChartImageSrc('')
} finally {
setLoading(false)
}
}
const handleDownload = () => {
if (!chartImageSrc || !audio) return
// 创建下载链接
const link = document.createElement('a')
link.href = chartImageSrc
link.download = `${audio.filename}_frequency_chart.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
if (!isOpen || !audio) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-6xl mx-4 max-h-[90vh] overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center space-x-3">
<BarChart3 className="text-blue-600" size={24} />
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-600">{audio.filename}</p>
</div>
</div>
<div className="flex items-center space-x-2">
{chartImageSrc && (
<button
onClick={handleDownload}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="下载图表"
>
<Download size={20} />
</button>
)}
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="关闭"
>
<X size={24} />
</button>
</div>
</div>
{/* 内容区域 */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{/* 音频信息摘要 */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">
{Math.floor(audio.duration / 60)}:{(audio.duration % 60).toFixed(0).padStart(2, '0')}
</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.sample_rate} Hz</span>
</div>
{audio.tempo && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.tempo.toFixed(1)} BPM</span>
</div>
)}
{audio.spectral_centroid && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.spectral_centroid.toFixed(0)} Hz</span>
</div>
)}
</div>
</div>
{/* 频率图表 */}
<div className="text-center">
{loading ? (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">...</span>
</div>
) : chartImageSrc ? (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<img
src={chartImageSrc}
alt={`${audio.filename} 频率分析图`}
className="max-w-full h-auto mx-auto"
style={{ maxHeight: '600px' }}
/>
</div>
) : (
<div className="flex items-center justify-center h-96 text-gray-500">
<div className="text-center">
<BarChart3 size={48} className="mx-auto mb-4 text-gray-300" />
<p></p>
<p className="text-sm"></p>
</div>
</div>
)}
</div>
{/* 音频特征详情 */}
{(audio.spectral_rolloff || audio.zero_crossing_rate || audio.mfcc_features) && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 className="text-md font-medium text-gray-900 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
{audio.spectral_rolloff && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.spectral_rolloff.toFixed(0)} Hz</span>
</div>
)}
{audio.zero_crossing_rate && (
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{(audio.zero_crossing_rate * 100).toFixed(2)}%</span>
</div>
)}
{audio.mfcc_features && (
<div>
<span className="text-gray-600">MFCC特征:</span>
<span className="ml-2 font-medium">{audio.mfcc_features.length} </span>
</div>
)}
</div>
{/* 节拍时间点 */}
{audio.beat_times && audio.beat_times.length > 0 && (
<div className="mt-3">
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{audio.beat_times.length} </span>
<div className="mt-2 text-xs text-gray-500">
5: {audio.beat_times.slice(0, 5).map(t => t.toFixed(2)).join('s, ')}s
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
export default AudioChartViewer

View File

@@ -0,0 +1,83 @@
import React from 'react'
import { Music, Upload } from 'lucide-react'
import { AudioFile } from '../services/audioService'
import AudioCard from './AudioCard'
interface AudioListProps {
audioFiles: AudioFile[]
loading: boolean
searchTerm: string
onDelete: (audioId: string) => void
onShowChart: (audio: AudioFile) => void
onUpload: () => void
}
const AudioList: React.FC<AudioListProps> = ({
audioFiles,
loading,
searchTerm,
onDelete,
onShowChart,
onUpload
}) => {
const filteredAudioFiles = audioFiles.filter(audio =>
audio.filename.toLowerCase().includes(searchTerm.toLowerCase())
)
// 加载状态
if (loading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 bg-gray-200 animate-pulse">
<div className="h-16 bg-gray-300 rounded"></div>
</div>
<div className="p-4 space-y-3">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/2"></div>
</div>
</div>
))}
</div>
)
}
// 空状态
if (filteredAudioFiles.length === 0) {
return (
<div className="text-center py-12">
<Music className="mx-auto text-gray-400 mb-4" size={64} />
<div className="text-gray-400 mb-4">
{searchTerm ? '没有找到匹配的音频文件' : '暂无音频文件'}
</div>
{!searchTerm && (
<button
onClick={onUpload}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload size={16} className="mr-2" />
</button>
)}
</div>
)
}
// 音频文件列表
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAudioFiles.map((audio) => (
<AudioCard
key={audio.id}
audio={audio}
onDelete={onDelete}
onShowChart={onShowChart}
/>
))}
</div>
)
}
export default AudioList

View File

@@ -0,0 +1,39 @@
import React from 'react'
import { Search, Upload } from 'lucide-react'
interface AudioSearchBarProps {
searchTerm: string
onSearchChange: (value: string) => void
onUpload: () => void
}
const AudioSearchBar: React.FC<AudioSearchBarProps> = ({
searchTerm,
onSearchChange,
onUpload
}) => {
return (
<div className="flex items-center justify-between mb-6">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="搜索音频文件..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent w-full"
/>
</div>
<button
onClick={onUpload}
className="ml-4 flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload size={20} className="mr-2" />
</button>
</div>
)
}
export default AudioSearchBar

View File

@@ -0,0 +1,306 @@
import React, { useState } from 'react'
import { Upload, FolderOpen, X, Music, CheckCircle, AlertCircle } from 'lucide-react'
import { open } from '@tauri-apps/plugin-dialog'
import { AudioService, BatchUploadResult } from '../services/audioService'
interface AudioUploadProps {
isOpen: boolean
onClose: () => void
onUploadComplete: () => void
}
const AudioUpload: React.FC<AudioUploadProps> = ({
isOpen,
onClose,
onUploadComplete
}) => {
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<BatchUploadResult | null>(null)
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
const [selectedDirectory, setSelectedDirectory] = useState<string>('')
if (!isOpen) return null
const selectFiles = async () => {
try {
const files = await open({
multiple: true,
title: '选择音频文件',
filters: [
{
name: '音频文件',
extensions: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma']
}
]
})
if (files && Array.isArray(files)) {
setSelectedFiles(files)
setSelectedDirectory('')
}
} catch (error) {
console.error('Failed to select files:', error)
}
}
const selectDirectory = async () => {
try {
const directory = await open({
directory: true,
multiple: false,
title: '选择音频文件夹'
})
if (directory && typeof directory === 'string') {
setSelectedDirectory(directory)
setSelectedFiles([])
}
} catch (error) {
console.error('Failed to select directory:', error)
}
}
const handleUpload = async () => {
if (selectedFiles.length === 0 && !selectedDirectory) {
alert('请先选择文件或文件夹')
return
}
setUploading(true)
setUploadResult(null)
try {
if (selectedDirectory) {
// 批量上传文件夹
const response = await AudioService.batchUploadAudioFiles({
source_directory: selectedDirectory
})
if (response.status && response.data) {
setUploadResult(response.data)
} else {
alert('批量上传失败: ' + (response.msg || '未知错误'))
}
} else {
// 上传单个或多个文件
const results: BatchUploadResult = {
total_files: selectedFiles.length,
uploaded_files: 0,
skipped_files: 0,
failed_files: 0,
uploaded_list: [],
skipped_list: [],
failed_list: []
}
for (const filePath of selectedFiles) {
try {
const response = await AudioService.uploadAudioFile({
source_path: filePath
})
if (response.status && response.data) {
results.uploaded_files++
results.uploaded_list.push(response.data)
} else {
results.failed_files++
results.failed_list.push({
filename: filePath.split('/').pop() || filePath,
error: response.msg || '未知错误'
})
}
} catch (error) {
results.failed_files++
results.failed_list.push({
filename: filePath.split('/').pop() || filePath,
error: error instanceof Error ? error.message : '未知错误'
})
}
}
setUploadResult(results)
}
} catch (error) {
console.error('Upload failed:', error)
alert('上传失败: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setUploading(false)
}
}
const handleClose = () => {
if (uploadResult && uploadResult.uploaded_files > 0) {
onUploadComplete()
}
setSelectedFiles([])
setSelectedDirectory('')
setUploadResult(null)
onClose()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 flex items-center">
<Upload className="mr-2" size={20} />
</h2>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
</div>
{/* 内容区域 */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-120px)]">
{!uploadResult ? (
<>
{/* 选择文件方式 */}
<div className="space-y-4">
<h3 className="text-md font-medium text-gray-900"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 选择文件 */}
<button
onClick={selectFiles}
disabled={uploading}
className="p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-colors disabled:opacity-50"
>
<div className="text-center">
<Music className="mx-auto mb-2 text-gray-400" size={32} />
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
{/* 选择文件夹 */}
<button
onClick={selectDirectory}
disabled={uploading}
className="p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-green-400 hover:bg-green-50 transition-colors disabled:opacity-50"
>
<div className="text-center">
<FolderOpen className="mx-auto mb-2 text-gray-400" size={32} />
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"></p>
</div>
</button>
</div>
</div>
{/* 已选择的文件/文件夹 */}
{(selectedFiles.length > 0 || selectedDirectory) && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900">:</h4>
<div className="bg-gray-50 rounded-lg p-3 max-h-32 overflow-y-auto">
{selectedDirectory ? (
<div className="text-sm text-gray-700">
📁 {selectedDirectory}
</div>
) : (
selectedFiles.map((file, index) => (
<div key={index} className="text-sm text-gray-700">
🎵 {file.split('/').pop()}
</div>
))
)}
</div>
</div>
)}
{/* 上传按钮 */}
<div className="flex justify-end space-x-3">
<button
onClick={handleClose}
disabled={uploading}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
</button>
<button
onClick={handleUpload}
disabled={uploading || (selectedFiles.length === 0 && !selectedDirectory)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Upload size={16} className="mr-2" />
</>
)}
</button>
</div>
</>
) : (
/* 上传结果 */
<div className="space-y-4">
<h3 className="text-md font-medium text-gray-900"></h3>
{/* 统计信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{uploadResult.total_files}</div>
<div className="text-sm text-blue-600"></div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{uploadResult.uploaded_files}</div>
<div className="text-sm text-green-600"></div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{uploadResult.skipped_files}</div>
<div className="text-sm text-yellow-600"></div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{uploadResult.failed_files}</div>
<div className="text-sm text-red-600"></div>
</div>
</div>
{/* 详细结果 */}
<div className="space-y-3 max-h-64 overflow-y-auto">
{/* 成功上传的文件 */}
{uploadResult.uploaded_list.map((audio, index) => (
<div key={index} className="flex items-center space-x-2 text-sm">
<CheckCircle size={16} className="text-green-500" />
<span className="text-gray-700">{audio.filename}</span>
<span className="text-gray-500">({AudioService.formatFileSize(audio.file_size)})</span>
</div>
))}
{/* 失败的文件 */}
{uploadResult.failed_list.map((failed, index) => (
<div key={index} className="flex items-center space-x-2 text-sm">
<AlertCircle size={16} className="text-red-500" />
<span className="text-gray-700">{failed.filename}</span>
<span className="text-red-500 text-xs">({failed.error})</span>
</div>
))}
</div>
{/* 关闭按钮 */}
<div className="flex justify-end">
<button
onClick={handleClose}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default AudioUpload

View File

@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react'
import { AudioService, AudioFile } from '../services/audioService'
import AudioSearchBar from '../components/AudioSearchBar'
import AudioList from '../components/AudioList'
import AudioUpload from '../components/AudioUpload'
import AudioChartViewer from '../components/AudioChartViewer'
const AudioLibraryPage: React.FC = () => {
const [audioFiles, setAudioFiles] = useState<AudioFile[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [showUpload, setShowUpload] = useState(false)
const [showChart, setShowChart] = useState(false)
const [selectedAudio, setSelectedAudio] = useState<AudioFile | null>(null)
useEffect(() => {
loadAudioFiles()
}, [])
const loadAudioFiles = async () => {
try {
setLoading(true)
const response = await AudioService.getAllAudioFiles()
if (response.status && response.data) {
setAudioFiles(response.data)
} else {
console.error('Failed to load audio files:', response.msg)
}
} catch (error) {
console.error('Failed to load audio files:', error)
} finally {
setLoading(false)
}
}
const handleDeleteAudio = async (audioId: string) => {
console.log('handleDeleteAudio called with:', audioId)
const isOk = confirm('确定要删除这个音频文件吗?')
console.log('User confirmed deletion:', isOk)
if (!isOk) {
console.log('User cancelled deletion, returning early')
return
}
try {
console.log('Calling AudioService.deleteAudioFile...')
const response = await AudioService.deleteAudioFile(audioId)
console.log('Delete response:', response)
if (response.status) {
console.log('Delete successful, updating audio files list')
setAudioFiles(audioFiles.filter(audio => audio.id !== audioId))
} else {
console.error('删除失败:', response.msg || '未知错误')
alert('删除失败: ' + (response.msg || '未知错误'))
}
} catch (error) {
console.error('Failed to delete audio file:', error)
alert('删除失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
const handleShowChart = (audio: AudioFile) => {
setSelectedAudio(audio)
setShowChart(true)
}
const handleUploadComplete = () => {
loadAudioFiles()
}
return (
<div className="p-6">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2"></p>
</div>
{/* 搜索和操作栏 */}
<AudioSearchBar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onUpload={() => setShowUpload(true)}
/>
{/* 音频文件列表 */}
<AudioList
audioFiles={audioFiles}
loading={loading}
searchTerm={searchTerm}
onDelete={handleDeleteAudio}
onShowChart={handleShowChart}
onUpload={() => setShowUpload(true)}
/>
{/* 上传对话框 */}
<AudioUpload
isOpen={showUpload}
onClose={() => setShowUpload(false)}
onUploadComplete={handleUploadComplete}
/>
{/* 频率图查看器 */}
<AudioChartViewer
isOpen={showChart}
audio={selectedAudio}
onClose={() => {
setShowChart(false)
setSelectedAudio(null)
}}
/>
</div>
)
}
export default AudioLibraryPage

View File

@@ -0,0 +1,264 @@
import { invoke } from '@tauri-apps/api/core'
export interface AudioFile {
id: string
filename: string
file_path: string
md5_hash: string
file_size: number
duration: number
sample_rate: number
channels: number
format: string
tempo?: number
beat_times?: number[]
spectral_centroid?: number
spectral_rolloff?: number
zero_crossing_rate?: number
mfcc_features?: number[]
frequency_chart_path?: string
created_at: string
updated_at: string
is_active: boolean
}
export interface UploadAudioRequest {
source_path: string
filename?: string
}
export interface BatchUploadRequest {
source_directory: string
}
export interface BatchUploadResult {
total_files: number
uploaded_files: number
skipped_files: number
failed_files: number
uploaded_list: AudioFile[]
skipped_list: Array<{ filename: string; reason: string }>
failed_list: Array<{ filename: string; error: string }>
}
export interface ApiResponse<T> {
status: boolean
data?: T
msg?: string
}
export class AudioService {
/**
* 尝试解析JSON响应
*/
private static tryJsonParse(result: any): any {
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result)
// 如果是JSON-RPC响应提取result字段
if (parsed.jsonrpc && 'result' in parsed) {
return parsed.result
}
// 如果是普通JSON响应直接返回
return parsed
} catch (error) {
console.error('Failed to parse JSON:', error)
return result
}
}
return result
}
/**
* 获取所有音频文件
*/
static async getAllAudioFiles(): Promise<ApiResponse<AudioFile[]>> {
try {
const result = await invoke('get_all_audio_files')
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<AudioFile[]>
}
// 如果没有状态信息,假设成功并包装数据
return { status: true, msg: 'ok', data: parsed } as ApiResponse<AudioFile[]>
} catch (error) {
console.error('Failed to get all audio files:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 根据ID获取音频文件
*/
static async getAudioById(audioId: string): Promise<ApiResponse<AudioFile>> {
try {
const result = await invoke('get_audio_by_id', { audioId })
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<AudioFile>
}
// 如果没有状态信息,假设成功
return { status: true, msg: 'ok', data: parsed } as ApiResponse<AudioFile>
} catch (error) {
console.error('Failed to get audio by id:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 根据MD5获取音频文件
*/
static async getAudioByMd5(md5Hash: string): Promise<ApiResponse<AudioFile>> {
try {
const result = await invoke('get_audio_by_md5', { md5Hash })
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<AudioFile>
}
// 如果没有状态信息,假设成功
return { status: true, msg: 'ok', data: parsed } as ApiResponse<AudioFile>
} catch (error) {
console.error('Failed to get audio by md5:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 上传单个音频文件
*/
static async uploadAudioFile(request: UploadAudioRequest): Promise<ApiResponse<AudioFile>> {
try {
const result = await invoke('upload_audio_file', { request })
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<AudioFile>
}
// 如果没有状态信息,假设成功
return { status: true, msg: 'ok', data: parsed } as ApiResponse<AudioFile>
} catch (error) {
console.error('Failed to upload audio file:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 批量上传音频文件
*/
static async batchUploadAudioFiles(request: BatchUploadRequest): Promise<ApiResponse<BatchUploadResult>> {
try {
const result = await invoke('batch_upload_audio_files', { request })
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<BatchUploadResult>
}
// 如果没有状态信息,假设成功
return { status: true, msg: 'ok', data: parsed } as ApiResponse<BatchUploadResult>
} catch (error) {
console.error('Failed to batch upload audio files:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 删除音频文件
*/
static async deleteAudioFile(audioId: string): Promise<ApiResponse<boolean>> {
try {
const result = await invoke('delete_audio_file', { audioId })
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<boolean>
}
// 如果没有状态信息,假设成功
return { status: true, msg: 'ok', data: parsed } as ApiResponse<boolean>
} catch (error) {
console.error('Failed to delete audio file:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 搜索音频文件
*/
static async searchAudioFiles(keyword: string): Promise<ApiResponse<AudioFile[]>> {
try {
const result = await invoke('search_audio_files', { keyword })
const parsed = this.tryJsonParse(result)
// 检查后端返回的实际状态
if (parsed && typeof parsed === 'object' && 'status' in parsed) {
return parsed as ApiResponse<AudioFile[]>
}
// 如果没有状态信息,假设成功
return { status: true, msg: 'ok', data: parsed } as ApiResponse<AudioFile[]>
} catch (error) {
console.error('Failed to search audio files:', error)
return {
status: false,
msg: error instanceof Error ? error.message : 'Unknown error'
}
}
}
/**
* 格式化文件大小
*/
static formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 格式化时长
*/
static formatDuration(seconds: number): string {
if (seconds === 0) return '0:00'
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
}