fix
This commit is contained in:
505
python_core/services/audio_manager.py
Normal file
505
python_core/services/audio_manager.py
Normal 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()
|
||||
108
src-tauri/src/commands/audio.rs
Normal file
108
src-tauri/src/commands/audio.rs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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!())
|
||||
|
||||
@@ -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>
|
||||
|
||||
184
src/components/AudioCard.tsx
Normal file
184
src/components/AudioCard.tsx
Normal 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
|
||||
191
src/components/AudioChartViewer.tsx
Normal file
191
src/components/AudioChartViewer.tsx
Normal 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
|
||||
83
src/components/AudioList.tsx
Normal file
83
src/components/AudioList.tsx
Normal 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
|
||||
39
src/components/AudioSearchBar.tsx
Normal file
39
src/components/AudioSearchBar.tsx
Normal 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
|
||||
306
src/components/AudioUpload.tsx
Normal file
306
src/components/AudioUpload.tsx
Normal 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
|
||||
117
src/pages/AudioLibraryPage.tsx
Normal file
117
src/pages/AudioLibraryPage.tsx
Normal 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
|
||||
264
src/services/audioService.ts
Normal file
264
src/services/audioService.ts
Normal 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')}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user