提交qiming-mcp-proxy
This commit is contained in:
88
qiming-mcp-proxy/voice-cli/src/cli/mod.rs
Normal file
88
qiming-mcp-proxy/voice-cli/src/cli/mod.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
pub mod model;
|
||||
pub mod tts;
|
||||
|
||||
pub use tts::TtsAction;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "voice-cli")]
|
||||
#[command(about = "Speech-to-text HTTP service with CLI interface")]
|
||||
#[command(version = "0.1.0")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
||||
/// Configuration file path
|
||||
#[arg(short, long, default_value = "config.yml")]
|
||||
pub config: String,
|
||||
|
||||
/// Verbose output
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Server management commands
|
||||
Server {
|
||||
#[command(subcommand)]
|
||||
action: ServerAction,
|
||||
},
|
||||
/// Model management commands
|
||||
Model {
|
||||
#[command(subcommand)]
|
||||
action: ModelAction,
|
||||
},
|
||||
/// TTS management commands
|
||||
Tts {
|
||||
#[command(subcommand)]
|
||||
action: TtsAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ServerAction {
|
||||
/// Initialize server configuration
|
||||
Init {
|
||||
/// Configuration file output path (default: ./server-config.yml)
|
||||
#[arg(short, long)]
|
||||
config: Option<std::path::PathBuf>,
|
||||
|
||||
/// Force overwrite existing configuration file
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Run server in foreground mode
|
||||
Run {
|
||||
/// Configuration file path
|
||||
#[arg(short, long)]
|
||||
config: Option<std::path::PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ModelAction {
|
||||
/// Download a specific model
|
||||
Download {
|
||||
/// Model name to download (e.g., base, small, large)
|
||||
model_name: String,
|
||||
},
|
||||
/// List available and downloaded models
|
||||
List,
|
||||
/// Validate downloaded models
|
||||
Validate,
|
||||
/// Remove a downloaded model
|
||||
Remove {
|
||||
/// Model name to remove
|
||||
model_name: String,
|
||||
},
|
||||
/// Diagnose issues with a downloaded model
|
||||
Diagnose {
|
||||
/// Model name to diagnose
|
||||
model_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
// Daemon mode is no longer supported
|
||||
// Use foreground mode with shell scripts for background operation
|
||||
242
qiming-mcp-proxy/voice-cli/src/cli/model.rs
Normal file
242
qiming-mcp-proxy/voice-cli/src/cli/model.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::Config;
|
||||
use crate::services::ModelService;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub async fn handle_model_download(config: &Config, model_name: &str) -> crate::Result<()> {
|
||||
info!("Downloading model: {}", model_name);
|
||||
|
||||
// Validate model name
|
||||
if !config
|
||||
.whisper
|
||||
.supported_models
|
||||
.contains(&model_name.to_string())
|
||||
{
|
||||
return Err(VoiceCliError::InvalidModelName(format!(
|
||||
"Model '{}' is not supported. Supported models: {:?}",
|
||||
model_name, config.whisper.supported_models
|
||||
)));
|
||||
}
|
||||
|
||||
let model_service = ModelService::new(config.clone());
|
||||
|
||||
// Check if model already exists
|
||||
if model_service.is_model_downloaded(model_name).await? {
|
||||
info!("Model '{}' already exists locally", model_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Download the model
|
||||
match model_service.download_model(model_name).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully downloaded model: {}", model_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download model '{}': {}", model_name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_model_list(config: &Config) -> crate::Result<()> {
|
||||
info!("Listing models...");
|
||||
|
||||
let model_service = ModelService::new(config.clone());
|
||||
|
||||
println!("\n=== Available Models ===");
|
||||
for model in &config.whisper.supported_models {
|
||||
let is_downloaded = model_service
|
||||
.is_model_downloaded(model)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let status = if is_downloaded {
|
||||
"✓ Downloaded"
|
||||
} else {
|
||||
"○ Not Downloaded"
|
||||
};
|
||||
let default_marker = if model == &config.whisper.default_model {
|
||||
" (default)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" {} {}{}", status, model, default_marker);
|
||||
}
|
||||
|
||||
println!("\n=== Downloaded Models Info ===");
|
||||
let downloaded_models = model_service.list_downloaded_models().await?;
|
||||
|
||||
if downloaded_models.is_empty() {
|
||||
println!(" No models downloaded yet");
|
||||
} else {
|
||||
for model_name in downloaded_models {
|
||||
match model_service.get_model_info(&model_name).await {
|
||||
Ok(info) => {
|
||||
println!(
|
||||
" {} - Size: {}, Status: {}",
|
||||
model_name, info.size, info.status
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" {} - Error: {}", model_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nModels directory: {}", config.whisper.models_dir);
|
||||
println!("Default model: {}", config.whisper.default_model);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_model_validate(config: &Config) -> crate::Result<()> {
|
||||
info!("Validating all downloaded models...");
|
||||
|
||||
let model_service = ModelService::new(config.clone());
|
||||
let downloaded_models = model_service.list_downloaded_models().await?;
|
||||
|
||||
if downloaded_models.is_empty() {
|
||||
info!("No models to validate");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut all_valid = true;
|
||||
|
||||
for model_name in downloaded_models {
|
||||
print!("Validating {}... ", model_name);
|
||||
match model_service.validate_model(&model_name).await {
|
||||
Ok(_) => {
|
||||
println!("✓ Valid");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✗ Invalid: {}", e);
|
||||
all_valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_valid {
|
||||
info!("All models are valid");
|
||||
} else {
|
||||
warn!("Some models have validation issues");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_model_remove(config: &Config, model_name: &str) -> crate::Result<()> {
|
||||
info!("Removing model: {}", model_name);
|
||||
|
||||
let model_service = ModelService::new(config.clone());
|
||||
|
||||
// Check if model exists
|
||||
if !model_service.is_model_downloaded(model_name).await? {
|
||||
warn!("Model '{}' is not downloaded", model_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Confirm deletion (in a real CLI, you might want user confirmation)
|
||||
if model_name == config.whisper.default_model {
|
||||
warn!("Warning: Removing the default model '{}'", model_name);
|
||||
}
|
||||
|
||||
match model_service.remove_model(model_name).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully removed model: {}", model_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to remove model '{}': {}", model_name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interactive model download - downloads the default model if none exist
|
||||
pub async fn ensure_default_model(config: &Config) -> crate::Result<()> {
|
||||
let model_service = ModelService::new(config.clone());
|
||||
|
||||
// Check if default model exists
|
||||
if model_service
|
||||
.is_model_downloaded(&config.whisper.default_model)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if any model exists
|
||||
let downloaded_models = model_service.list_downloaded_models().await?;
|
||||
if !downloaded_models.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No models exist, download default
|
||||
if config.whisper.auto_download {
|
||||
info!(
|
||||
"No models found. Auto-downloading default model: {}",
|
||||
config.whisper.default_model
|
||||
);
|
||||
handle_model_download(config, &config.whisper.default_model).await?;
|
||||
} else {
|
||||
return Err(VoiceCliError::ModelNotFound(format!(
|
||||
"No models found and auto_download is disabled. Please run: voice-cli model download {}",
|
||||
config.whisper.default_model
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Diagnose issues with a downloaded model
|
||||
pub async fn handle_model_diagnose(config: &Config, model_name: &str) -> crate::Result<()> {
|
||||
info!("Diagnosing model: {}", model_name);
|
||||
|
||||
let model_service = ModelService::new(config.clone());
|
||||
|
||||
match model_service.diagnose_model(model_name).await {
|
||||
Ok(diagnosis) => {
|
||||
println!("\n=== Model Diagnosis for '{}' ===", model_name);
|
||||
println!("{}", diagnosis);
|
||||
|
||||
// Provide fix suggestions
|
||||
println!("\n=== Fix Suggestions ===");
|
||||
if !model_service
|
||||
.is_model_downloaded(model_name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!(
|
||||
"💡 Model not found - run: voice-cli model download {}",
|
||||
model_name
|
||||
);
|
||||
} else {
|
||||
match model_service.validate_model(model_name).await {
|
||||
Ok(_) => {
|
||||
println!("✓ Model is valid and ready to use");
|
||||
}
|
||||
Err(_) => {
|
||||
println!("🔧 To fix the corrupted model:");
|
||||
println!(
|
||||
" 1. Remove the corrupted file: voice-cli model remove {}",
|
||||
model_name
|
||||
);
|
||||
println!(
|
||||
" 2. Re-download the model: voice-cli model download {}",
|
||||
model_name
|
||||
);
|
||||
println!(" 3. Validate the model: voice-cli model validate");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to diagnose model '{}': {}", model_name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
122
qiming-mcp-proxy/voice-cli/src/cli/tts.rs
Normal file
122
qiming-mcp-proxy/voice-cli/src/cli/tts.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use clap::Subcommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum TtsAction {
|
||||
/// Initialize TTS environment
|
||||
Init {
|
||||
/// Force overwrite existing environment
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Test TTS functionality
|
||||
Test {
|
||||
/// Text to synthesize
|
||||
#[arg(short, long, default_value = "Hello, world!")]
|
||||
text: String,
|
||||
|
||||
/// Output file path
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Model to use
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
|
||||
/// Speech speed (0.5-2.0)
|
||||
#[arg(short, long, default_value = "1.0")]
|
||||
speed: f32,
|
||||
|
||||
/// Pitch adjustment (-20 to 20)
|
||||
#[arg(short, long, default_value = "0")]
|
||||
pitch: i32,
|
||||
|
||||
/// Volume adjustment (0.5-2.0)
|
||||
#[arg(short, long, default_value = "1.0")]
|
||||
volume: f32,
|
||||
|
||||
/// Output format
|
||||
#[arg(short, long, default_value = "mp3")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Initialize TTS environment
|
||||
pub async fn handle_tts_init(_force: bool) -> anyhow::Result<()> {
|
||||
println!("🎤 Initializing TTS environment...");
|
||||
|
||||
// Reuse the server init logic for Python environment
|
||||
crate::server::init_python_tts_environment()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize TTS environment: {}", e))?;
|
||||
|
||||
println!("✅ TTS environment initialized successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test TTS functionality
|
||||
pub async fn handle_tts_test(
|
||||
config: &crate::Config,
|
||||
text: String,
|
||||
output: Option<std::path::PathBuf>,
|
||||
model: Option<String>,
|
||||
speed: f32,
|
||||
pitch: i32,
|
||||
volume: f32,
|
||||
format: String,
|
||||
) -> anyhow::Result<()> {
|
||||
println!("🎤 Testing TTS functionality...");
|
||||
|
||||
// Create TTS service
|
||||
let tts_service = crate::services::TtsService::new(
|
||||
config.tts.python_path.clone(),
|
||||
config.tts.model_path.clone(),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create TTS service: {}", e))?;
|
||||
|
||||
// Create request
|
||||
let request = crate::models::TtsSyncRequest {
|
||||
text,
|
||||
model,
|
||||
speed: Some(speed),
|
||||
pitch: Some(pitch),
|
||||
volume: Some(volume),
|
||||
format: Some(format),
|
||||
};
|
||||
|
||||
// Test synthesis
|
||||
let result_path = tts_service
|
||||
.synthesize_sync(request)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("TTS synthesis failed: {}", e))?;
|
||||
|
||||
println!("✅ TTS test successful!");
|
||||
println!("📁 Output file: {}", result_path.display());
|
||||
|
||||
// Copy to specified output path if provided
|
||||
if let Some(output_path) = output {
|
||||
tokio::fs::copy(&result_path, &output_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to copy output file: {}", e))?;
|
||||
println!("📁 Copied to: {}", output_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle TTS-related commands
|
||||
pub async fn handle_tts_command(action: TtsAction, config: &crate::Config) -> anyhow::Result<()> {
|
||||
match action {
|
||||
TtsAction::Init { force } => handle_tts_init(force).await,
|
||||
|
||||
TtsAction::Test {
|
||||
text,
|
||||
output,
|
||||
model,
|
||||
speed,
|
||||
pitch,
|
||||
volume,
|
||||
format,
|
||||
} => handle_tts_test(config, text, output, model, speed, pitch, volume, format).await,
|
||||
}
|
||||
}
|
||||
92
qiming-mcp-proxy/voice-cli/src/config.rs
Normal file
92
qiming-mcp-proxy/voice-cli/src/config.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use crate::models::Config;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use tracing::info;
|
||||
|
||||
/// 服务类型枚举,定义服务模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ServiceType {
|
||||
/// 单节点服务器模式
|
||||
Server,
|
||||
}
|
||||
|
||||
impl ServiceType {
|
||||
/// 获取默认配置文件名
|
||||
pub fn default_config_filename(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceType::Server => "server-config.yml",
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取服务显示名称
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceType::Server => "Server",
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有支持的服务类型
|
||||
pub fn all() -> &'static [ServiceType] {
|
||||
&[ServiceType::Server]
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置模板生成器
|
||||
pub struct ConfigTemplateGenerator;
|
||||
|
||||
impl ConfigTemplateGenerator {
|
||||
/// 生成指定服务类型的配置文件
|
||||
pub fn generate_config_file(
|
||||
service_type: ServiceType,
|
||||
output_path: &PathBuf,
|
||||
) -> crate::Result<()> {
|
||||
let template_content = Self::get_template_content(service_type)?;
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
std::fs::write(output_path, template_content)?;
|
||||
|
||||
info!(
|
||||
"Generated {} configuration file: {:?}",
|
||||
service_type.display_name(),
|
||||
output_path
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取服务类型对应的模板内容
|
||||
fn get_template_content(service_type: ServiceType) -> crate::Result<&'static str> {
|
||||
match service_type {
|
||||
ServiceType::Server => Ok(include_str!("../templates/server-config.yml.template")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成所有类型的配置文件到指定目录
|
||||
pub fn generate_all_configs(
|
||||
output_dir: &PathBuf,
|
||||
) -> crate::Result<HashMap<ServiceType, PathBuf>> {
|
||||
let mut generated_files = HashMap::new();
|
||||
|
||||
for &service_type in ServiceType::all() {
|
||||
let filename = service_type.default_config_filename();
|
||||
let output_path = output_dir.join(filename);
|
||||
|
||||
Self::generate_config_file(service_type, &output_path)?;
|
||||
generated_files.insert(service_type, output_path);
|
||||
}
|
||||
|
||||
Ok(generated_files)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration change notification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigChangeNotification {
|
||||
pub old_config: Config,
|
||||
pub new_config: Config,
|
||||
pub changed_at: SystemTime,
|
||||
}
|
||||
206
qiming-mcp-proxy/voice-cli/src/config_rs_integration.rs
Normal file
206
qiming-mcp-proxy/voice-cli/src/config_rs_integration.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::Config;
|
||||
use config::{Config as ConfigRs, Environment, File};
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Instead of implementing TryFrom, we'll create a default config file and load it
|
||||
fn create_default_config_source() -> Result<ConfigRs, VoiceCliError> {
|
||||
// Create a temporary config with defaults
|
||||
let default_config = Config::default();
|
||||
|
||||
// Serialize to YAML and then parse back as config source
|
||||
let yaml_content = serde_yaml::to_string(&default_config)?;
|
||||
|
||||
// Create config from YAML content
|
||||
let config_rs = ConfigRs::builder()
|
||||
.add_source(File::from_str(&yaml_content, config::FileFormat::Yaml))
|
||||
.build()?;
|
||||
|
||||
Ok(config_rs)
|
||||
}
|
||||
|
||||
/// Configuration settings that can be overridden via CLI arguments
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CliOverrides {
|
||||
/// Server host override
|
||||
pub host: Option<String>,
|
||||
/// Server port override
|
||||
pub port: Option<u16>,
|
||||
/// Log level override
|
||||
pub log_level: Option<String>,
|
||||
/// Models directory override
|
||||
pub models_dir: Option<String>,
|
||||
/// Default model override
|
||||
pub default_model: Option<String>,
|
||||
/// Transcription workers override
|
||||
pub transcription_workers: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for CliOverrides {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
port: None,
|
||||
log_level: None,
|
||||
models_dir: None,
|
||||
default_model: None,
|
||||
transcription_workers: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration loader using config-rs with proper hierarchy
|
||||
pub struct ConfigRsLoader;
|
||||
|
||||
impl ConfigRsLoader {
|
||||
/// Load configuration with proper hierarchy: CLI args > env vars > config files
|
||||
pub fn load(
|
||||
config_path: Option<&PathBuf>,
|
||||
cli_overrides: &CliOverrides,
|
||||
service_type: Option<crate::config::ServiceType>,
|
||||
) -> Result<Config, VoiceCliError> {
|
||||
let mut config_rs = ConfigRs::builder();
|
||||
|
||||
// 1. Load default configuration (built-in defaults)
|
||||
let default_config_source = create_default_config_source()?;
|
||||
config_rs = config_rs.add_source(default_config_source);
|
||||
|
||||
// 2. Load configuration from file if specified or from default location
|
||||
if let Some(path) = config_path {
|
||||
if path.exists() {
|
||||
config_rs = config_rs.add_source(File::from(path.clone()));
|
||||
}
|
||||
} else if let Some(service_type) = service_type {
|
||||
// Try to load service-specific default config
|
||||
let default_config_path =
|
||||
std::env::current_dir()?.join(service_type.default_config_filename());
|
||||
if default_config_path.exists() {
|
||||
config_rs = config_rs.add_source(File::from(default_config_path));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load environment variables (with proper prefix)
|
||||
config_rs = config_rs.add_source(
|
||||
Environment::with_prefix("VOICE_CLI")
|
||||
.prefix_separator("_")
|
||||
.separator("__")
|
||||
.try_parsing(true)
|
||||
.ignore_empty(true),
|
||||
);
|
||||
|
||||
// 4. Build the config and debug what's being loaded
|
||||
let built_config = config_rs.build()?;
|
||||
|
||||
// 5. Deserialize the built config
|
||||
let mut config: Config = built_config.try_deserialize()?;
|
||||
|
||||
// 6. Apply CLI overrides (highest priority)
|
||||
Self::apply_cli_overrides(&mut config, cli_overrides);
|
||||
|
||||
// 9. Apply service-specific settings
|
||||
if let Some(service_type) = service_type {
|
||||
Self::apply_service_specific_settings(&mut config, service_type)?;
|
||||
}
|
||||
|
||||
// 6. Validate configuration
|
||||
config.validate()?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Apply CLI argument overrides to configuration
|
||||
fn apply_cli_overrides(config: &mut Config, cli_overrides: &CliOverrides) {
|
||||
if let Some(host) = &cli_overrides.host {
|
||||
config.server.host = host.clone();
|
||||
}
|
||||
|
||||
if let Some(port) = cli_overrides.port {
|
||||
config.server.port = port;
|
||||
}
|
||||
|
||||
if let Some(log_level) = &cli_overrides.log_level {
|
||||
config.logging.level = log_level.clone();
|
||||
}
|
||||
|
||||
if let Some(models_dir) = &cli_overrides.models_dir {
|
||||
config.whisper.models_dir = models_dir.clone();
|
||||
}
|
||||
|
||||
if let Some(default_model) = &cli_overrides.default_model {
|
||||
config.whisper.default_model = default_model.clone();
|
||||
}
|
||||
|
||||
if let Some(workers) = cli_overrides.transcription_workers {
|
||||
config.whisper.workers.transcription_workers = workers;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply service-specific settings based on service type
|
||||
fn apply_service_specific_settings(
|
||||
config: &mut Config,
|
||||
service_type: crate::config::ServiceType,
|
||||
) -> Result<(), VoiceCliError> {
|
||||
match service_type {
|
||||
crate::config::ServiceType::Server => {
|
||||
config.daemon.pid_file = "./voice-cli-server.pid".to_string();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate CLI overrides from command line arguments
|
||||
pub fn generate_cli_overrides_from_args(
|
||||
args: &crate::cli::Cli,
|
||||
) -> Result<CliOverrides, VoiceCliError> {
|
||||
let overrides = CliOverrides::default();
|
||||
|
||||
match &args.command {
|
||||
crate::cli::Commands::Server { action } => {
|
||||
if let crate::cli::ServerAction::Run { .. } = action {
|
||||
// Server run command can have port overrides
|
||||
// These will be extracted from the action in the main handler
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(overrides)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_config_loading_hierarchy() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.yml");
|
||||
|
||||
// Create a test config file
|
||||
let test_config = Config::default();
|
||||
test_config.save(&config_path).unwrap();
|
||||
|
||||
let cli_overrides = CliOverrides::default();
|
||||
let result = ConfigRsLoader::load(Some(&config_path), &cli_overrides, None);
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_overrides_application() {
|
||||
let mut config = Config::default();
|
||||
let cli_overrides = CliOverrides {
|
||||
port: Some(9090),
|
||||
log_level: Some("debug".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ConfigRsLoader::apply_cli_overrides(&mut config, &cli_overrides);
|
||||
|
||||
assert_eq!(config.server.port, 9090);
|
||||
assert_eq!(config.logging.level, "debug");
|
||||
}
|
||||
}
|
||||
344
qiming-mcp-proxy/voice-cli/src/error.rs
Normal file
344
qiming-mcp-proxy/voice-cli/src/error.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use config::ConfigError;
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum VoiceCliError {
|
||||
#[error("{0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("{0}")]
|
||||
AudioProcessing(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Transcription(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Model(String),
|
||||
|
||||
#[error("{0}")]
|
||||
FileIo(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Json(String),
|
||||
|
||||
#[error("{0}")]
|
||||
ConfigRs(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Daemon(String),
|
||||
|
||||
#[error("{0}")]
|
||||
UnsupportedFormat(String),
|
||||
|
||||
#[error("{0}")]
|
||||
FileTooLarge(String),
|
||||
|
||||
#[error("{0}")]
|
||||
ModelNotFound(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidModelName(String),
|
||||
|
||||
// Worker pool related errors
|
||||
#[error("{0}")]
|
||||
WorkerPoolError(String),
|
||||
|
||||
#[error("{0}")]
|
||||
TranscriptionTimeout(String),
|
||||
|
||||
#[error("{0}")]
|
||||
TranscriptionFailed(String),
|
||||
|
||||
// Audio processing specific errors
|
||||
#[error("{0}")]
|
||||
AudioConversionFailed(String),
|
||||
|
||||
#[error("{0}")]
|
||||
AudioProbeError(String),
|
||||
|
||||
#[error("{0}")]
|
||||
TempFileError(String),
|
||||
|
||||
// Multipart form handling errors
|
||||
#[error("{0}")]
|
||||
MultipartError(String),
|
||||
|
||||
#[error("{0}")]
|
||||
MissingField(String),
|
||||
|
||||
// Network related errors
|
||||
#[error("{0}")]
|
||||
Network(String),
|
||||
|
||||
// Storage related errors
|
||||
#[error("{0}")]
|
||||
Storage(String),
|
||||
|
||||
// Task management errors
|
||||
#[error("{0}")]
|
||||
TaskManagementDisabled(String),
|
||||
|
||||
#[error("{0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Initialization(String),
|
||||
|
||||
// TTS related errors
|
||||
#[error("{0}")]
|
||||
TtsError(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl VoiceCliError {
|
||||
// ===========================================
|
||||
// 工厂方法 - 创建带国际化消息的错误
|
||||
// ===========================================
|
||||
|
||||
pub fn config_error(detail: impl Into<String>) -> Self {
|
||||
Self::Config(t!("errors.voice.config", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn audio_processing_error(detail: impl Into<String>) -> Self {
|
||||
Self::AudioProcessing(
|
||||
t!("errors.voice.audio_processing", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transcription_error(detail: impl Into<String>) -> Self {
|
||||
Self::Transcription(t!("errors.voice.transcription", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn model_error(detail: impl Into<String>) -> Self {
|
||||
Self::Model(t!("errors.voice.model", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn file_io_error(detail: impl Into<String>) -> Self {
|
||||
Self::FileIo(t!("errors.voice.file_io", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn http_error(detail: impl Into<String>) -> Self {
|
||||
Self::Http(t!("errors.voice.http", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn serialization_error(detail: impl Into<String>) -> Self {
|
||||
Self::Serialization(t!("errors.voice.serialization", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn json_error(detail: impl Into<String>) -> Self {
|
||||
Self::Json(t!("errors.voice.json", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn config_rs_error(detail: impl Into<String>) -> Self {
|
||||
Self::ConfigRs(t!("errors.voice.config_rs", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn daemon_error(detail: impl Into<String>) -> Self {
|
||||
Self::Daemon(t!("errors.voice.daemon", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn unsupported_format(detail: impl Into<String>) -> Self {
|
||||
Self::UnsupportedFormat(
|
||||
t!("errors.voice.unsupported_format", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn file_too_large(size: usize, max: usize) -> Self {
|
||||
Self::FileTooLarge(t!("errors.voice.file_too_large", size = size, max = max).to_string())
|
||||
}
|
||||
|
||||
pub fn model_not_found(model: impl Into<String>) -> Self {
|
||||
Self::ModelNotFound(t!("errors.voice.model_not_found", model = model.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn invalid_model_name(model: impl Into<String>) -> Self {
|
||||
Self::InvalidModelName(
|
||||
t!("errors.voice.invalid_model_name", model = model.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn worker_pool_error(detail: impl Into<String>) -> Self {
|
||||
Self::WorkerPoolError(t!("errors.voice.worker_pool", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn transcription_timeout(seconds: u64) -> Self {
|
||||
Self::TranscriptionTimeout(
|
||||
t!("errors.voice.transcription_timeout", seconds = seconds).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transcription_failed(detail: impl Into<String>) -> Self {
|
||||
Self::TranscriptionFailed(
|
||||
t!("errors.voice.transcription_failed", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn audio_conversion_failed(detail: impl Into<String>) -> Self {
|
||||
Self::AudioConversionFailed(
|
||||
t!(
|
||||
"errors.voice.audio_conversion_failed",
|
||||
detail = detail.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn audio_probe_error(detail: impl Into<String>) -> Self {
|
||||
Self::AudioProbeError(
|
||||
t!("errors.voice.audio_probe_error", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn temp_file_error(detail: impl Into<String>) -> Self {
|
||||
Self::TempFileError(t!("errors.voice.temp_file_error", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn multipart_error(detail: impl Into<String>) -> Self {
|
||||
Self::MultipartError(t!("errors.voice.multipart_error", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn missing_field(field: impl Into<String>) -> Self {
|
||||
Self::MissingField(t!("errors.voice.missing_field", field = field.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn network_error(detail: impl Into<String>) -> Self {
|
||||
Self::Network(t!("errors.voice.network", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn storage_error(detail: impl Into<String>) -> Self {
|
||||
Self::Storage(t!("errors.voice.storage", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn task_management_disabled() -> Self {
|
||||
Self::TaskManagementDisabled(t!("errors.voice.task_management_disabled").to_string())
|
||||
}
|
||||
|
||||
pub fn not_found(resource: impl Into<String>) -> Self {
|
||||
Self::NotFound(t!("errors.voice.not_found", resource = resource.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn initialization_error(detail: impl Into<String>) -> Self {
|
||||
Self::Initialization(t!("errors.voice.initialization", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn tts_error(detail: impl Into<String>) -> Self {
|
||||
Self::TtsError(t!("errors.voice.tts", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn invalid_input(detail: impl Into<String>) -> Self {
|
||||
Self::InvalidInput(t!("errors.voice.invalid_input", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
pub fn io_error(detail: impl Into<String>) -> Self {
|
||||
Self::Io(t!("errors.voice.io", detail = detail.into()).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// 从外部错误类型转换
|
||||
// ===========================================
|
||||
|
||||
impl From<std::io::Error> for VoiceCliError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::file_io_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for VoiceCliError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
Self::http_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_yaml::Error> for VoiceCliError {
|
||||
fn from(err: serde_yaml::Error) -> Self {
|
||||
Self::serialization_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for VoiceCliError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::json_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigError> for VoiceCliError {
|
||||
fn from(err: ConfigError) -> Self {
|
||||
Self::config_rs_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for VoiceCliError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self {
|
||||
VoiceCliError::Config(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::AudioProcessing(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::Transcription(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
VoiceCliError::Model(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::FileIo(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::Http(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::Serialization(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
VoiceCliError::Json(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::Daemon(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::UnsupportedFormat(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::FileTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()),
|
||||
VoiceCliError::ModelNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
VoiceCliError::InvalidModelName(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::WorkerPoolError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
VoiceCliError::TranscriptionTimeout(_) => {
|
||||
(StatusCode::REQUEST_TIMEOUT, self.to_string())
|
||||
}
|
||||
VoiceCliError::TranscriptionFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
VoiceCliError::AudioConversionFailed(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::AudioProbeError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::TempFileError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
VoiceCliError::MultipartError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::MissingField(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::Network(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||
VoiceCliError::Storage(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::ConfigRs(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::TaskManagementDisabled(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
VoiceCliError::Initialization(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
VoiceCliError::TtsError(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
VoiceCliError::InvalidInput(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
VoiceCliError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"error": error_message,
|
||||
"status": status.as_u16()
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, VoiceCliError>;
|
||||
27
qiming-mcp-proxy/voice-cli/src/lib.rs
Normal file
27
qiming-mcp-proxy/voice-cli/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
// 初始化 i18n,使用 crate 内置翻译文件
|
||||
#[macro_use]
|
||||
extern crate rust_i18n;
|
||||
|
||||
// 初始化翻译文件,使用 crate 内置 locales(支持独立发布)
|
||||
i18n!("locales", fallback = "en");
|
||||
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod config_rs_integration;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod openapi;
|
||||
pub mod server;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use error::{Result, VoiceCliError};
|
||||
pub use models::*;
|
||||
|
||||
// Re-export services
|
||||
pub use services::{AudioProcessor, ModelService, transcription_engine};
|
||||
|
||||
// Tests module
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
264
qiming-mcp-proxy/voice-cli/src/main.rs
Normal file
264
qiming-mcp-proxy/voice-cli/src/main.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
use voice_cli::{
|
||||
cli::{Cli, Commands, ModelAction, ServerAction, TtsAction},
|
||||
config::ServiceType,
|
||||
config_rs_integration::ConfigRsLoader,
|
||||
server,
|
||||
};
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_locale_from_env();
|
||||
|
||||
// Parse command line arguments
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Don't initialize logging here - let each service initialize its own logging
|
||||
// based on configuration files
|
||||
|
||||
// Generate CLI overrides from command line arguments
|
||||
let cli_overrides = match ConfigRsLoader::generate_cli_overrides_from_args(&cli) {
|
||||
Ok(overrides) => overrides,
|
||||
Err(e) => {
|
||||
error!("Failed to generate CLI overrides: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Load configuration based on command type using config-rs with proper hierarchy
|
||||
let config = match &cli.command {
|
||||
// For init commands, we don't need to load existing config
|
||||
Commands::Server {
|
||||
action: ServerAction::Init { .. },
|
||||
} => {
|
||||
// Use default config for init commands
|
||||
voice_cli::Config::default()
|
||||
}
|
||||
|
||||
// For server commands, use server-specific config
|
||||
Commands::Server { action } => {
|
||||
let config_path = get_config_path_for_server_action(action, &cli.config);
|
||||
match ConfigRsLoader::load(
|
||||
config_path.as_ref(),
|
||||
&cli_overrides,
|
||||
Some(ServiceType::Server),
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
error!("Failed to load server configuration: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For other commands, use default config loading
|
||||
_ => {
|
||||
let config_path = PathBuf::from(&cli.config);
|
||||
match ConfigRsLoader::load(Some(&config_path), &cli_overrides, None) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
error!("Failed to load configuration: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Log configuration summary if verbose
|
||||
if cli.verbose {
|
||||
info!("Configuration loaded successfully");
|
||||
}
|
||||
|
||||
// Route to appropriate handler
|
||||
let result = match cli.command {
|
||||
Commands::Server { action } => handle_server_command(action, &config).await,
|
||||
Commands::Model { action } => handle_model_command(action, &config).await,
|
||||
Commands::Tts { action } => handle_tts_command(action, &config).await,
|
||||
};
|
||||
|
||||
// Handle result
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!("Command completed successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
// Print error to stderr to ensure it's always visible
|
||||
eprintln!("❌ Error: {}", e);
|
||||
|
||||
// Also print the error chain if available
|
||||
let mut current_error = e.source();
|
||||
while let Some(err) = current_error {
|
||||
eprintln!(" Caused by: {}", err);
|
||||
current_error = err.source();
|
||||
}
|
||||
|
||||
// Also log the error
|
||||
error!("Command failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AVAILABLE_LOCALES: &[&str] = &["en", "zh-CN", "zh-TW"];
|
||||
const DEFAULT_LOCALE: &str = "en";
|
||||
|
||||
fn init_locale_from_env() {
|
||||
for env_key in ["DEFAULT_LOCALE", "LANG"] {
|
||||
let Ok(raw_locale) = std::env::var(env_key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let normalized = if env_key == "LANG" {
|
||||
parse_lang_env(&raw_locale)
|
||||
} else {
|
||||
normalize_locale(&raw_locale)
|
||||
};
|
||||
|
||||
if AVAILABLE_LOCALES.contains(&normalized.as_str()) {
|
||||
rust_i18n::set_locale(&normalized);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
rust_i18n::set_locale(DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
fn parse_lang_env(lang: &str) -> String {
|
||||
let lang = lang.split('.').next().unwrap_or(lang);
|
||||
let lang = lang.split('@').next().unwrap_or(lang);
|
||||
normalize_locale(lang)
|
||||
}
|
||||
|
||||
fn normalize_locale(input: &str) -> String {
|
||||
let input = input.trim();
|
||||
let input = input.split('.').next().unwrap_or(input);
|
||||
let input = input.split('@').next().unwrap_or(input);
|
||||
|
||||
match input.to_lowercase().as_str() {
|
||||
"en" | "en_us" | "en-us" | "en_gb" | "en-gb" => "en".to_string(),
|
||||
"zh-cn" | "zh_cn" | "zh-hans" | "zh" => "zh-CN".to_string(),
|
||||
"zh-tw" | "zh_tw" | "zh-hant" => "zh-TW".to_string(),
|
||||
_ => input.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle server-related commands
|
||||
async fn handle_server_command(action: ServerAction, config: &voice_cli::Config) -> Result<()> {
|
||||
match action {
|
||||
ServerAction::Init {
|
||||
config: config_path,
|
||||
force,
|
||||
} => {
|
||||
info!("Initializing server configuration");
|
||||
server::handle_server_init(config_path, force)
|
||||
.await
|
||||
.context("Failed to initialize server configuration")
|
||||
}
|
||||
ServerAction::Run { config: _ } => {
|
||||
info!("Running server in foreground mode");
|
||||
server::handle_server_run(config)
|
||||
.await
|
||||
.context("Failed to run server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle model-related commands
|
||||
async fn handle_model_command(action: ModelAction, config: &voice_cli::Config) -> Result<()> {
|
||||
use voice_cli::cli::model;
|
||||
|
||||
match action {
|
||||
ModelAction::Download { model_name } => {
|
||||
info!("Downloading model: {}", model_name);
|
||||
model::handle_model_download(config, &model_name)
|
||||
.await
|
||||
.context("Failed to download model")
|
||||
}
|
||||
ModelAction::List => {
|
||||
info!("Listing models");
|
||||
model::handle_model_list(config)
|
||||
.await
|
||||
.context("Failed to list models")
|
||||
}
|
||||
ModelAction::Validate => {
|
||||
info!("Validating models");
|
||||
model::handle_model_validate(config)
|
||||
.await
|
||||
.context("Failed to validate models")
|
||||
}
|
||||
ModelAction::Remove { model_name } => {
|
||||
info!("Removing model: {}", model_name);
|
||||
model::handle_model_remove(config, &model_name)
|
||||
.await
|
||||
.context("Failed to remove model")
|
||||
}
|
||||
ModelAction::Diagnose { model_name } => {
|
||||
info!("Diagnosing model: {}", model_name);
|
||||
model::handle_model_diagnose(config, &model_name)
|
||||
.await
|
||||
.context("Failed to diagnose model")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle TTS-related commands
|
||||
async fn handle_tts_command(action: TtsAction, config: &voice_cli::Config) -> Result<()> {
|
||||
use voice_cli::cli::tts;
|
||||
|
||||
match action {
|
||||
TtsAction::Init { force } => {
|
||||
info!("Initializing TTS environment");
|
||||
tts::handle_tts_init(force)
|
||||
.await
|
||||
.context("Failed to initialize TTS environment")
|
||||
}
|
||||
TtsAction::Test {
|
||||
text,
|
||||
output,
|
||||
model,
|
||||
speed,
|
||||
pitch,
|
||||
volume,
|
||||
format,
|
||||
} => {
|
||||
info!("Testing TTS functionality");
|
||||
tts::handle_tts_test(config, text, output, model, speed, pitch, volume, format)
|
||||
.await
|
||||
.context("Failed to test TTS functionality")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract config path from server action
|
||||
fn get_config_path_for_server_action(
|
||||
action: &ServerAction,
|
||||
_default_config: &str,
|
||||
) -> Option<PathBuf> {
|
||||
match action {
|
||||
ServerAction::Run { config } => config.clone(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_cli_parsing() {
|
||||
use clap::Parser;
|
||||
|
||||
// Test server run command
|
||||
let args = vec!["voice-cli", "server", "run"];
|
||||
let cli = Cli::try_parse_from(args);
|
||||
assert!(cli.is_ok());
|
||||
|
||||
// Test model download command
|
||||
let args = vec!["voice-cli", "model", "download", "base"];
|
||||
let cli = Cli::try_parse_from(args);
|
||||
assert!(cli.is_ok());
|
||||
}
|
||||
}
|
||||
706
qiming-mcp-proxy/voice-cli/src/models/config.rs
Normal file
706
qiming-mcp-proxy/voice-cli/src/models/config.rs
Normal file
@@ -0,0 +1,706 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// 服务器配置
|
||||
#[serde(default)]
|
||||
pub server: ServerConfig,
|
||||
/// Whisper 模型配置
|
||||
#[serde(default)]
|
||||
pub whisper: WhisperConfig,
|
||||
/// 日志配置
|
||||
pub logging: LoggingConfig,
|
||||
/// 守护进程配置
|
||||
pub daemon: DaemonConfig,
|
||||
/// 任务管理配置
|
||||
#[serde(default)]
|
||||
pub task_management: TaskManagementConfig,
|
||||
/// TTS配置
|
||||
#[serde(default)]
|
||||
pub tts: TtsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
/// 服务器监听主机
|
||||
pub host: String,
|
||||
/// 服务器监听端口
|
||||
pub port: u16,
|
||||
/// 最大文件大小(字节)
|
||||
pub max_file_size: usize,
|
||||
/// 是否启用 CORS
|
||||
pub cors_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WhisperConfig {
|
||||
/// 默认 Whisper 模型
|
||||
pub default_model: String,
|
||||
/// 模型文件存储目录
|
||||
pub models_dir: String,
|
||||
/// 是否自动下载模型
|
||||
pub auto_download: bool,
|
||||
/// 支持的模型列表
|
||||
pub supported_models: Vec<String>,
|
||||
/// 音频处理配置
|
||||
pub audio_processing: AudioProcessingConfig,
|
||||
/// Worker 配置
|
||||
pub workers: WorkersConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioProcessingConfig {
|
||||
/// 支持的音频格式列表
|
||||
pub supported_formats: Vec<String>,
|
||||
/// 是否自动转换音频格式
|
||||
pub auto_convert: bool,
|
||||
/// 音频转换超时时间(秒)
|
||||
pub conversion_timeout: u32,
|
||||
/// 是否清理临时文件
|
||||
pub temp_file_cleanup: bool,
|
||||
/// 临时文件保留时间(秒)
|
||||
pub temp_file_retention: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkersConfig {
|
||||
/// 转录工作线程数
|
||||
pub transcription_workers: usize,
|
||||
/// 通道缓冲区大小
|
||||
pub channel_buffer_size: usize,
|
||||
/// Worker 超时时间(秒)
|
||||
pub worker_timeout: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
/// 日志级别
|
||||
pub level: String,
|
||||
/// 日志目录
|
||||
pub log_dir: String,
|
||||
/// 最大日志文件大小
|
||||
pub max_file_size: String,
|
||||
/// 最大日志文件数量
|
||||
pub max_files: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DaemonConfig {
|
||||
/// PID 文件路径
|
||||
pub pid_file: String,
|
||||
/// 日志文件路径
|
||||
pub log_file: String,
|
||||
/// 工作目录
|
||||
pub work_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskManagementConfig {
|
||||
/// 最大并发任务数
|
||||
pub max_concurrent_tasks: usize,
|
||||
/// SQLite 数据库文件路径
|
||||
pub sqlite_db_path: String,
|
||||
/// 任务重试次数
|
||||
pub retry_attempts: usize,
|
||||
/// 任务超时时间(秒)
|
||||
pub task_timeout_seconds: u64,
|
||||
/// 是否捕获 panic
|
||||
pub catch_panic: bool,
|
||||
/// 任务保留分钟数
|
||||
pub task_retention_minutes: u32,
|
||||
/// Sled 数据库路径
|
||||
pub sled_db_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TtsConfig {
|
||||
/// Python解释器路径
|
||||
pub python_path: Option<PathBuf>,
|
||||
/// TTS模型路径
|
||||
pub model_path: Option<PathBuf>,
|
||||
/// 默认语音模型
|
||||
pub default_model: String,
|
||||
/// 支持的音频格式
|
||||
pub supported_formats: Vec<String>,
|
||||
/// 最大文本长度
|
||||
pub max_text_length: usize,
|
||||
/// 默认语速
|
||||
pub default_speed: f32,
|
||||
/// 默认音调
|
||||
pub default_pitch: i32,
|
||||
/// 默认音量
|
||||
pub default_volume: f32,
|
||||
/// TTS任务超时时间(秒)
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig::default(),
|
||||
whisper: WhisperConfig::default(),
|
||||
logging: LoggingConfig::default(),
|
||||
daemon: DaemonConfig::default(),
|
||||
task_management: TaskManagementConfig::default(),
|
||||
tts: TtsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 8080,
|
||||
max_file_size: 200 * 1024 * 1024, // 200MB
|
||||
cors_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WhisperConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_model: "base".to_string(),
|
||||
models_dir: "./models".to_string(),
|
||||
auto_download: true,
|
||||
supported_models: vec![
|
||||
"tiny".to_string(),
|
||||
"tiny.en".to_string(),
|
||||
"base".to_string(),
|
||||
"base.en".to_string(),
|
||||
"small".to_string(),
|
||||
"small.en".to_string(),
|
||||
"medium".to_string(),
|
||||
"medium.en".to_string(),
|
||||
"large-v1".to_string(),
|
||||
"large-v2".to_string(),
|
||||
"large-v3".to_string(),
|
||||
],
|
||||
audio_processing: AudioProcessingConfig::default(),
|
||||
workers: WorkersConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioProcessingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
supported_formats: vec![
|
||||
"mp3".to_string(),
|
||||
"wav".to_string(),
|
||||
"flac".to_string(),
|
||||
"m4a".to_string(),
|
||||
"ogg".to_string(),
|
||||
],
|
||||
auto_convert: true,
|
||||
conversion_timeout: 60,
|
||||
temp_file_cleanup: true,
|
||||
temp_file_retention: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkersConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transcription_workers: 3,
|
||||
channel_buffer_size: 100,
|
||||
worker_timeout: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: "info".to_string(),
|
||||
log_dir: "./logs".to_string(),
|
||||
max_file_size: "10MB".to_string(),
|
||||
max_files: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pid_file: "./voice-cli.pid".to_string(),
|
||||
log_file: "./logs/daemon.log".to_string(),
|
||||
work_dir: "./".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskManagementConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrent_tasks: 4,
|
||||
sqlite_db_path: "./data/tasks.db".to_string(),
|
||||
retry_attempts: 2,
|
||||
task_timeout_seconds: 3600,
|
||||
catch_panic: true,
|
||||
task_retention_minutes: 1440, // 24 hours in minutes
|
||||
sled_db_path: "./data/sled".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TtsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
python_path: Some(if cfg!(windows) {
|
||||
PathBuf::from(".venv/Scripts/python.exe")
|
||||
} else {
|
||||
PathBuf::from(".venv/bin/python")
|
||||
}),
|
||||
model_path: None,
|
||||
default_model: "default".to_string(),
|
||||
supported_formats: vec!["mp3".to_string(), "wav".to_string()],
|
||||
max_text_length: 5000,
|
||||
default_speed: 1.0,
|
||||
default_pitch: 0,
|
||||
default_volume: 1.0,
|
||||
timeout_seconds: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(config_path: &PathBuf) -> crate::Result<Self> {
|
||||
let config_content = std::fs::read_to_string(config_path).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Failed to read configuration file {:?}: {}",
|
||||
config_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
serde_yaml::from_str(&config_content).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Failed to parse configuration file {:?}: {}",
|
||||
config_path, e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_or_create(config_path: &PathBuf) -> crate::Result<Self> {
|
||||
Self::load_with_env_overrides(config_path)
|
||||
}
|
||||
|
||||
/// Load configuration with environment variable overrides
|
||||
pub fn load_with_env_overrides(config_path: &PathBuf) -> crate::Result<Self> {
|
||||
let mut config = if config_path.exists() {
|
||||
let config_content = std::fs::read_to_string(config_path).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Failed to read configuration file {:?}: {}",
|
||||
config_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
serde_yaml::from_str(&config_content).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Failed to parse configuration file {:?}: {}",
|
||||
config_path, e
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
let default_config = Config::default();
|
||||
default_config.save(config_path).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Failed to create default configuration file {:?}: {}",
|
||||
config_path, e
|
||||
))
|
||||
})?;
|
||||
tracing::info!("Created default configuration file at {:?}", config_path);
|
||||
default_config
|
||||
};
|
||||
|
||||
// Apply environment variable overrides
|
||||
config.apply_env_overrides()?;
|
||||
|
||||
// Validate the final configuration
|
||||
config.validate()?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides to the configuration
|
||||
pub fn apply_env_overrides(&mut self) -> crate::Result<()> {
|
||||
// Server configuration overrides
|
||||
if let Ok(host) = std::env::var("VOICE_CLI_HOST") {
|
||||
if host.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_HOST environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.server.host = host.clone();
|
||||
tracing::info!("Applied environment override: VOICE_CLI_HOST = {}", host);
|
||||
}
|
||||
|
||||
if let Ok(port_str) = std::env::var("VOICE_CLI_PORT") {
|
||||
let port = port_str.parse::<u16>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_PORT value '{}': must be a valid port number (1-65535)",
|
||||
port_str
|
||||
))
|
||||
})?;
|
||||
self.server.port = port;
|
||||
tracing::info!("Applied environment override: VOICE_CLI_PORT = {}", port);
|
||||
}
|
||||
|
||||
// Max file size override
|
||||
if let Ok(size_str) = std::env::var("VOICE_CLI_MAX_FILE_SIZE") {
|
||||
let size = size_str.parse::<usize>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_MAX_FILE_SIZE value '{}': must be a valid number in bytes",
|
||||
size_str
|
||||
))
|
||||
})?;
|
||||
if size == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_MAX_FILE_SIZE must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
self.server.max_file_size = size;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_MAX_FILE_SIZE = {}",
|
||||
size
|
||||
);
|
||||
}
|
||||
|
||||
// CORS enabled override
|
||||
if let Ok(cors_str) = std::env::var("VOICE_CLI_CORS_ENABLED") {
|
||||
let cors_enabled = cors_str.parse::<bool>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_CORS_ENABLED value '{}': must be 'true' or 'false'",
|
||||
cors_str
|
||||
))
|
||||
})?;
|
||||
self.server.cors_enabled = cors_enabled;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_CORS_ENABLED = {}",
|
||||
cors_enabled
|
||||
);
|
||||
}
|
||||
|
||||
// Logging configuration overrides
|
||||
if let Ok(level) = std::env::var("VOICE_CLI_LOG_LEVEL") {
|
||||
let level = level.to_lowercase();
|
||||
let valid_levels = ["trace", "debug", "info", "warn", "error"];
|
||||
if !valid_levels.contains(&level.as_str()) {
|
||||
return Err(crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_LOG_LEVEL value '{}': must be one of {:?}",
|
||||
level, valid_levels
|
||||
)));
|
||||
}
|
||||
self.logging.level = level.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_LOG_LEVEL = {}",
|
||||
level
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(log_dir) = std::env::var("VOICE_CLI_LOG_DIR") {
|
||||
if log_dir.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_LOG_DIR environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.logging.log_dir = log_dir.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_LOG_DIR = {}",
|
||||
log_dir
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(max_files_str) = std::env::var("VOICE_CLI_LOG_MAX_FILES") {
|
||||
let max_files = max_files_str.parse::<u32>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_LOG_MAX_FILES value '{}': must be a valid number",
|
||||
max_files_str
|
||||
))
|
||||
})?;
|
||||
if max_files == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_LOG_MAX_FILES must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
self.logging.max_files = max_files;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_LOG_MAX_FILES = {}",
|
||||
max_files
|
||||
);
|
||||
}
|
||||
|
||||
// Whisper configuration overrides
|
||||
if let Ok(model) = std::env::var("VOICE_CLI_DEFAULT_MODEL") {
|
||||
if model.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_DEFAULT_MODEL environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.whisper.default_model = model.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_DEFAULT_MODEL = {}",
|
||||
model
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(models_dir) = std::env::var("VOICE_CLI_MODELS_DIR") {
|
||||
if models_dir.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_MODELS_DIR environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.whisper.models_dir = models_dir.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_MODELS_DIR = {}",
|
||||
models_dir
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(auto_download_str) = std::env::var("VOICE_CLI_AUTO_DOWNLOAD") {
|
||||
let auto_download = auto_download_str.parse::<bool>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_AUTO_DOWNLOAD value '{}': must be 'true' or 'false'",
|
||||
auto_download_str
|
||||
))
|
||||
})?;
|
||||
self.whisper.auto_download = auto_download;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_AUTO_DOWNLOAD = {}",
|
||||
auto_download
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(workers_str) = std::env::var("VOICE_CLI_TRANSCRIPTION_WORKERS") {
|
||||
let workers = workers_str.parse::<usize>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_TRANSCRIPTION_WORKERS value '{}': must be a valid number",
|
||||
workers_str
|
||||
))
|
||||
})?;
|
||||
if workers == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_TRANSCRIPTION_WORKERS must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
self.whisper.workers.transcription_workers = workers;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_TRANSCRIPTION_WORKERS = {}",
|
||||
workers
|
||||
);
|
||||
}
|
||||
|
||||
// Daemon configuration overrides
|
||||
if let Ok(work_dir) = std::env::var("VOICE_CLI_WORK_DIR") {
|
||||
if work_dir.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_WORK_DIR environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.daemon.work_dir = work_dir.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_WORK_DIR = {}",
|
||||
work_dir
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(pid_file) = std::env::var("VOICE_CLI_PID_FILE") {
|
||||
if pid_file.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_PID_FILE environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.daemon.pid_file = pid_file.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_PID_FILE = {}",
|
||||
pid_file
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(max_tasks_str) = std::env::var("VOICE_CLI_MAX_CONCURRENT_TASKS") {
|
||||
let max_tasks = max_tasks_str.parse::<usize>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_MAX_CONCURRENT_TASKS value '{}': must be a valid number",
|
||||
max_tasks_str
|
||||
))
|
||||
})?;
|
||||
if max_tasks == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_MAX_CONCURRENT_TASKS must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
self.task_management.max_concurrent_tasks = max_tasks;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_MAX_CONCURRENT_TASKS = {}",
|
||||
max_tasks
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(db_path) = std::env::var("VOICE_CLI_SQLITE_DB_PATH") {
|
||||
if db_path.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_SQLITE_DB_PATH environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.task_management.sqlite_db_path = db_path.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_SQLITE_DB_PATH = {}",
|
||||
db_path
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(retention_minutes_str) = std::env::var("VOICE_CLI_TASK_RETENTION_MINUTES") {
|
||||
let retention_minutes = retention_minutes_str.parse::<u32>().map_err(|_| {
|
||||
crate::VoiceCliError::Config(format!(
|
||||
"Invalid VOICE_CLI_TASK_RETENTION_MINUTES value '{}': must be a valid number",
|
||||
retention_minutes_str
|
||||
))
|
||||
})?;
|
||||
if retention_minutes == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_TASK_RETENTION_MINUTES must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
self.task_management.task_retention_minutes = retention_minutes;
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_TASK_RETENTION_MINUTES = {}",
|
||||
retention_minutes
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(sled_path) = std::env::var("VOICE_CLI_SLED_DB_PATH") {
|
||||
if sled_path.trim().is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"VOICE_CLI_SLED_DB_PATH environment variable cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.task_management.sled_db_path = sled_path.clone();
|
||||
tracing::info!(
|
||||
"Applied environment override: VOICE_CLI_SLED_DB_PATH = {}",
|
||||
sled_path
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save(&self, config_path: &PathBuf) -> crate::Result<()> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let config_yaml = serde_yaml::to_string(self)?;
|
||||
std::fs::write(config_path, config_yaml)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn models_dir_path(&self) -> PathBuf {
|
||||
PathBuf::from(&self.whisper.models_dir)
|
||||
}
|
||||
|
||||
pub fn log_dir_path(&self) -> PathBuf {
|
||||
PathBuf::from(&self.logging.log_dir)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> crate::Result<()> {
|
||||
// Validate server configuration
|
||||
if self.server.host.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Server host cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.server.port == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Server port must be between 1 and 65535".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.server.max_file_size == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Max file size must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate whisper configuration
|
||||
if self.whisper.default_model.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Default model cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !self
|
||||
.whisper
|
||||
.supported_models
|
||||
.contains(&self.whisper.default_model)
|
||||
{
|
||||
return Err(crate::VoiceCliError::Config(format!(
|
||||
"Default model '{}' is not in supported models list",
|
||||
self.whisper.default_model
|
||||
)));
|
||||
}
|
||||
|
||||
if self.whisper.models_dir.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Models directory cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.whisper.workers.transcription_workers == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Transcription workers must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate logging configuration
|
||||
if self.logging.log_dir.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Log directory cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.logging.max_files == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Max log files must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let valid_log_levels = ["trace", "debug", "info", "warn", "error"];
|
||||
if !valid_log_levels.contains(&self.logging.level.to_lowercase().as_str()) {
|
||||
return Err(crate::VoiceCliError::Config(format!(
|
||||
"Invalid log level '{}'. Valid levels: {:?}",
|
||||
self.logging.level, valid_log_levels
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate daemon configuration
|
||||
if self.daemon.work_dir.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Work directory cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.daemon.pid_file.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"PID file path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate task management configuration
|
||||
if self.task_management.max_concurrent_tasks == 0 {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"Max concurrent tasks must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.task_management.sqlite_db_path.is_empty() {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"SQLite database path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
129
qiming-mcp-proxy/voice-cli/src/models/http_result.rs
Normal file
129
qiming-mcp-proxy/voice-cli/src/models/http_result.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use crate::VoiceCliError;
|
||||
use axum::{
|
||||
Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// HttpResult 响应标记(仅用于内部中间件识别)
|
||||
#[derive(Clone)]
|
||||
pub struct HttpResultMarker;
|
||||
|
||||
/// 统一HTTP响应格式
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HttpResult<T> {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub data: Option<T>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tid: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> HttpResult<T> {
|
||||
/// 成功响应
|
||||
pub fn success(data: T) -> Self {
|
||||
Self {
|
||||
code: "0000".to_string(),
|
||||
message: "操作成功".to_string(),
|
||||
data: Some(data),
|
||||
tid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功响应(自定义消息)
|
||||
pub fn success_with_message(data: T, message: String) -> Self {
|
||||
Self {
|
||||
code: "0000".to_string(),
|
||||
message,
|
||||
data: Some(data),
|
||||
tid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误响应(与泛型保持一致,data为空)
|
||||
pub fn error<E>(code: String, message: String) -> HttpResult<E> {
|
||||
HttpResult {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
tid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置追踪ID
|
||||
pub fn with_tid(mut self, tid: String) -> Self {
|
||||
self.tid = Some(tid);
|
||||
self
|
||||
}
|
||||
|
||||
/// 系统错误
|
||||
pub fn system_error<E>(message: String) -> HttpResult<E> {
|
||||
Self::error("E001".to_string(), message)
|
||||
}
|
||||
|
||||
/// 格式不支持错误
|
||||
pub fn unsupported_format<E>(message: String) -> HttpResult<E> {
|
||||
Self::error("E002".to_string(), message)
|
||||
}
|
||||
|
||||
/// 任务不存在错误
|
||||
pub fn task_not_found<E>(message: String) -> HttpResult<E> {
|
||||
Self::error("E003".to_string(), message)
|
||||
}
|
||||
|
||||
/// 处理失败错误
|
||||
pub fn processing_failed<E>(message: String) -> HttpResult<E> {
|
||||
Self::error("E004".to_string(), message)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for HttpResult<T>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
let mut res = Json(self).into_response();
|
||||
// 标记该响应为 HttpResult,供中间件识别
|
||||
res.extensions_mut().insert(HttpResultMarker);
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert VoiceCliError to HttpResult for consistent error responses
|
||||
impl<T> From<VoiceCliError> for HttpResult<T> {
|
||||
fn from(error: VoiceCliError) -> Self {
|
||||
match error {
|
||||
VoiceCliError::Config(msg) => Self::system_error(msg),
|
||||
VoiceCliError::FileTooLarge(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::UnsupportedFormat(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::ModelNotFound(msg) => Self::task_not_found(msg),
|
||||
VoiceCliError::InvalidModelName(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::TranscriptionFailed(msg) => Self::processing_failed(msg),
|
||||
VoiceCliError::TranscriptionTimeout(msg) => Self::processing_failed(msg),
|
||||
VoiceCliError::WorkerPoolError(msg) => Self::processing_failed(msg),
|
||||
VoiceCliError::AudioProcessing(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::AudioConversionFailed(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::AudioProbeError(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::MultipartError(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::MissingField(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::TempFileError(msg) => Self::system_error(msg),
|
||||
VoiceCliError::FileIo(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Http(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Serialization(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Json(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Transcription(msg) => Self::processing_failed(msg),
|
||||
VoiceCliError::Model(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Daemon(msg) => Self::system_error(msg),
|
||||
VoiceCliError::ConfigRs(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Storage(msg) => Self::system_error(msg),
|
||||
VoiceCliError::TaskManagementDisabled(msg) => Self::system_error(msg),
|
||||
VoiceCliError::NotFound(msg) => Self::task_not_found(msg),
|
||||
VoiceCliError::Network(msg) => Self::system_error(msg),
|
||||
VoiceCliError::Initialization(msg) => Self::system_error(msg),
|
||||
VoiceCliError::TtsError(msg) => Self::processing_failed(msg),
|
||||
VoiceCliError::InvalidInput(msg) => Self::unsupported_format(msg),
|
||||
VoiceCliError::Io(msg) => Self::system_error(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
114
qiming-mcp-proxy/voice-cli/src/models/mod.rs
Normal file
114
qiming-mcp-proxy/voice-cli/src/models/mod.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
pub mod config;
|
||||
mod http_result;
|
||||
pub mod request;
|
||||
pub mod stepped_task;
|
||||
pub mod tts;
|
||||
|
||||
// Re-export config types
|
||||
pub use config::*;
|
||||
|
||||
// Re-export HTTP result types
|
||||
pub use http_result::*;
|
||||
|
||||
// Request module exports (for HTTP API)
|
||||
pub use request::{
|
||||
AudioFormat, AudioFormatResult, AudioMetadata, DaemonStatus, DetectionMethod, DownloadStatus,
|
||||
HealthResponse, ModelDownloadStatus, ModelInfo, ModelsResponse, ProcessedAudio, Segment,
|
||||
TranscriptionRequest, TranscriptionResponse,
|
||||
};
|
||||
|
||||
// Stepped task module exports
|
||||
pub use stepped_task::{
|
||||
AsyncTranscriptionTask, AudioProcessedTask, ProcessingStage, ProcessingStageInfo,
|
||||
ProgressDetails, SerializableSegment, SerializableTranscriptionResult, TaskError, TaskPriority,
|
||||
TaskStatus, TranscriptionCompletedTask,
|
||||
};
|
||||
|
||||
// TTS module exports
|
||||
pub use tts::{
|
||||
TaskPriority as TtsTaskPriority, TtsAsyncRequest, TtsProcessingStage, TtsProgressDetails,
|
||||
TtsSyncRequest, TtsTaskError, TtsTaskResponse, TtsTaskStatus,
|
||||
};
|
||||
|
||||
// 简化的任务响应类型
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 异步任务提交响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AsyncTaskResponse {
|
||||
pub task_id: String,
|
||||
pub status: TaskStatus,
|
||||
pub estimated_completion: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 简化任务状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub enum SimpleTaskStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl From<&TaskStatus> for SimpleTaskStatus {
|
||||
fn from(status: &TaskStatus) -> Self {
|
||||
match status {
|
||||
TaskStatus::Pending { .. } => SimpleTaskStatus::Pending,
|
||||
TaskStatus::Processing { .. } => SimpleTaskStatus::Processing,
|
||||
TaskStatus::Completed { .. } => SimpleTaskStatus::Completed,
|
||||
TaskStatus::Failed { .. } => SimpleTaskStatus::Failed,
|
||||
TaskStatus::Cancelled { .. } => SimpleTaskStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务状态查询响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TaskStatusResponse {
|
||||
pub task_id: String,
|
||||
pub status: SimpleTaskStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 任务取消响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CancelResponse {
|
||||
pub task_id: String,
|
||||
pub cancelled: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 任务重试响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RetryResponse {
|
||||
pub task_id: String,
|
||||
pub retried: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 任务删除响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DeleteResponse {
|
||||
pub task_id: String,
|
||||
pub deleted: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 任务统计响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TaskStatsResponse {
|
||||
pub total_tasks: u32,
|
||||
pub pending_tasks: u32,
|
||||
pub processing_tasks: u32,
|
||||
pub completed_tasks: u32,
|
||||
pub failed_tasks: u32,
|
||||
pub cancelled_tasks: u32,
|
||||
pub average_processing_time_ms: Option<f64>,
|
||||
pub failed_task_ids: Vec<String>,
|
||||
}
|
||||
433
qiming-mcp-proxy/voice-cli/src/models/request.rs
Normal file
433
qiming-mcp-proxy/voice-cli/src/models/request.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 音视频元数据信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AudioVideoMetadata {
|
||||
// 基础信息
|
||||
/// 文件格式 (mp3, wav, mp4, etc.)
|
||||
#[schema(example = "mp3")]
|
||||
pub format: String,
|
||||
/// 容器格式
|
||||
#[schema(example = "mp3")]
|
||||
pub container_format: String,
|
||||
/// 时长(秒)
|
||||
#[schema(example = 180.5)]
|
||||
pub duration_seconds: f64,
|
||||
/// 文件大小(字节)
|
||||
#[schema(example = 3640010)]
|
||||
pub file_size_bytes: u64,
|
||||
|
||||
// 音频信息
|
||||
/// 音频编码器
|
||||
#[schema(example = "mp3")]
|
||||
pub audio_codec: String,
|
||||
/// 采样率 (Hz)
|
||||
#[schema(example = 44100)]
|
||||
pub sample_rate: u32,
|
||||
/// 声道数
|
||||
#[schema(example = 2)]
|
||||
pub channels: u8,
|
||||
/// 音频码率 (kbps)
|
||||
#[schema(example = 128)]
|
||||
pub audio_bitrate: u32,
|
||||
|
||||
// 视频信息(如果是视频文件)
|
||||
/// 是否包含视频
|
||||
#[schema(example = false)]
|
||||
pub has_video: bool,
|
||||
/// 视频编码器
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_codec: Option<String>,
|
||||
/// 视频宽度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<u32>,
|
||||
/// 视频高度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<u32>,
|
||||
/// 视频码率 (kbps)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video_bitrate: Option<u32>,
|
||||
/// 帧率
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frame_rate: Option<f64>,
|
||||
|
||||
// 其他元数据
|
||||
/// 总码率 (kbps)
|
||||
#[schema(example = 160)]
|
||||
pub bitrate: u32,
|
||||
/// 创建时间
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub creation_time: Option<String>,
|
||||
}
|
||||
|
||||
/// Request structure for transcription (internal use after extracting from multipart)
|
||||
#[derive(Debug)]
|
||||
pub struct TranscriptionRequest {
|
||||
pub audio_data: Bytes,
|
||||
pub filename: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub response_format: Option<String>,
|
||||
}
|
||||
|
||||
/// Response structure for transcription API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TranscriptionResponse {
|
||||
/// 完整转写结果文本(合并所有分段后的最终文本)
|
||||
#[schema(example = "Hello, this is a test transcription.")]
|
||||
pub text: String,
|
||||
/// 分段级别的转写结果,包含起止时间、文本与置信度
|
||||
#[schema(example = json!([{"start": 0.0, "end": 2.5, "text": "Hello world", "confidence": 0.95}]))]
|
||||
pub segments: Vec<Segment>,
|
||||
/// 自动检测到的语种(如可用)。ISO 639-1 两字母代码
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = "en")]
|
||||
pub language: Option<String>,
|
||||
/// 音频时长(秒),如可获取
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = 2.5)]
|
||||
pub duration: Option<f32>,
|
||||
/// 处理耗时(秒),从请求进入到生成结果的总时长
|
||||
#[schema(example = 0.8)]
|
||||
pub processing_time: f32,
|
||||
/// 音视频元数据信息
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<AudioVideoMetadata>,
|
||||
}
|
||||
|
||||
/// Individual segment in transcription
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Segment {
|
||||
/// Start time of the segment in seconds
|
||||
#[schema(example = 0.0)]
|
||||
pub start: f32,
|
||||
/// End time of the segment in seconds
|
||||
#[schema(example = 2.5)]
|
||||
pub end: f32,
|
||||
/// Text content of this segment
|
||||
#[schema(example = "Hello, this is a test transcription.")]
|
||||
pub text: String,
|
||||
/// Confidence score for this segment (0.0-1.0)
|
||||
#[schema(example = 0.95)]
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Health check response
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// Current service status
|
||||
#[schema(example = "healthy")]
|
||||
pub status: String,
|
||||
/// List of currently loaded models
|
||||
#[schema(example = json!(["base", "small"]))]
|
||||
pub models_loaded: Vec<String>,
|
||||
/// Service uptime in seconds
|
||||
#[schema(example = 3600)]
|
||||
pub uptime: u64,
|
||||
/// Service version
|
||||
#[schema(example = "0.1.0")]
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Models list response
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ModelsResponse {
|
||||
/// All supported model names
|
||||
#[schema(example = json!(["tiny", "base", "small", "medium", "large"]))]
|
||||
pub available_models: Vec<String>,
|
||||
/// Currently loaded models in memory
|
||||
#[schema(example = json!(["base"]))]
|
||||
pub loaded_models: Vec<String>,
|
||||
/// Detailed information about each model
|
||||
pub model_info: std::collections::HashMap<String, ModelInfo>,
|
||||
}
|
||||
|
||||
/// Information about a specific model
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ModelInfo {
|
||||
/// Model file size on disk
|
||||
#[schema(example = "142 MB")]
|
||||
pub size: String,
|
||||
/// Memory usage when loaded
|
||||
#[schema(example = "388 MB")]
|
||||
pub memory_usage: String,
|
||||
/// Current model status
|
||||
#[schema(example = "loaded")]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Processed audio data
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessedAudio {
|
||||
pub data: Bytes,
|
||||
pub converted: bool,
|
||||
pub original_format: Option<String>,
|
||||
}
|
||||
|
||||
/// Audio format detection result
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AudioFormat {
|
||||
// Core audio formats (commonly supported by Symphonia)
|
||||
Wav,
|
||||
Mp3,
|
||||
Flac,
|
||||
M4a,
|
||||
Aac,
|
||||
Ogg,
|
||||
Webm,
|
||||
Opus,
|
||||
|
||||
// Extended audio formats (FFmpeg supported)
|
||||
Amr, // Adaptive Multi-Rate (mobile)
|
||||
Wma, // Windows Media Audio
|
||||
Ra, // RealAudio
|
||||
Au, // Sun/Unix audio
|
||||
Aiff, // Apple's uncompressed format
|
||||
Caf, // Core Audio Format
|
||||
|
||||
// Video formats (audio extraction via FFmpeg)
|
||||
ThreeGp, // 3GP mobile format
|
||||
Mp4, // MPEG-4 container
|
||||
Mov, // QuickTime format
|
||||
Avi, // Audio Video Interleave
|
||||
Mkv, // Matroska container
|
||||
Flv,
|
||||
Wmv,
|
||||
Mpeg,
|
||||
Mxf,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Audio metadata extracted during format detection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioMetadata {
|
||||
pub duration: Option<std::time::Duration>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub channels: Option<u8>,
|
||||
pub bit_depth: Option<u8>,
|
||||
pub bitrate: Option<u32>,
|
||||
pub codec_info: String,
|
||||
}
|
||||
|
||||
/// Enhanced audio format detection result with metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFormatResult {
|
||||
pub format: AudioFormat,
|
||||
pub confidence: f32,
|
||||
pub metadata: Option<AudioMetadata>,
|
||||
pub detection_method: DetectionMethod,
|
||||
}
|
||||
|
||||
/// Method used for format detection
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DetectionMethod {
|
||||
SymphoniaProbe,
|
||||
FileExtension,
|
||||
ContentType,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
/// Enhanced filename-based format detection with support for extended formats
|
||||
pub fn from_filename(filename: &str) -> Self {
|
||||
let extension = std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
match extension.as_str() {
|
||||
// Core audio formats
|
||||
"wav" => AudioFormat::Wav,
|
||||
"mp3" => AudioFormat::Mp3,
|
||||
"flac" => AudioFormat::Flac,
|
||||
"m4a" => AudioFormat::M4a,
|
||||
"aac" => AudioFormat::Aac,
|
||||
"ogg" => AudioFormat::Ogg,
|
||||
"webm" => AudioFormat::Webm,
|
||||
"opus" => AudioFormat::Opus,
|
||||
|
||||
// Extended audio formats
|
||||
"amr" => AudioFormat::Amr,
|
||||
"wma" => AudioFormat::Wma,
|
||||
"ra" | "ram" => AudioFormat::Ra,
|
||||
"au" | "snd" => AudioFormat::Au,
|
||||
"aiff" | "aif" => AudioFormat::Aiff,
|
||||
"caf" => AudioFormat::Caf,
|
||||
|
||||
// Video formats (audio extraction)
|
||||
"3gp" | "3g2" => AudioFormat::ThreeGp,
|
||||
"mp4" => AudioFormat::Mp4,
|
||||
"mov" => AudioFormat::Mov,
|
||||
"avi" => AudioFormat::Avi,
|
||||
"mkv" | "mka" => AudioFormat::Mkv,
|
||||
"flv" => AudioFormat::Flv,
|
||||
"wmv" => AudioFormat::Wmv,
|
||||
"mpeg" | "mpg" => AudioFormat::Mpeg,
|
||||
"mxf" => AudioFormat::Mxf,
|
||||
|
||||
_ => AudioFormat::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if format is supported for transcription
|
||||
pub fn is_supported(&self) -> bool {
|
||||
!matches!(self, AudioFormat::Unknown)
|
||||
}
|
||||
|
||||
/// Check if format requires FFmpeg conversion to WAV
|
||||
pub fn needs_conversion(&self) -> bool {
|
||||
!matches!(self, AudioFormat::Wav)
|
||||
}
|
||||
|
||||
/// Get string representation of the format
|
||||
pub fn to_string(&self) -> &'static str {
|
||||
match self {
|
||||
AudioFormat::Wav => "wav",
|
||||
AudioFormat::Mp3 => "mp3",
|
||||
AudioFormat::Flac => "flac",
|
||||
AudioFormat::M4a => "m4a",
|
||||
AudioFormat::Aac => "aac",
|
||||
AudioFormat::Ogg => "ogg",
|
||||
AudioFormat::Webm => "webm",
|
||||
AudioFormat::Opus => "opus",
|
||||
AudioFormat::Amr => "amr",
|
||||
AudioFormat::Wma => "wma",
|
||||
AudioFormat::Ra => "ra",
|
||||
AudioFormat::Au => "au",
|
||||
AudioFormat::Aiff => "aiff",
|
||||
AudioFormat::Caf => "caf",
|
||||
AudioFormat::ThreeGp => "3gp",
|
||||
AudioFormat::Mp4 => "mp4",
|
||||
AudioFormat::Mov => "mov",
|
||||
AudioFormat::Avi => "avi",
|
||||
AudioFormat::Mkv => "mkv",
|
||||
AudioFormat::Flv => "flv",
|
||||
AudioFormat::Wmv => "wmv",
|
||||
AudioFormat::Mpeg => "mpeg",
|
||||
AudioFormat::Mxf => "mxf",
|
||||
AudioFormat::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get MIME type for the format
|
||||
pub fn get_mime_type(&self) -> &'static str {
|
||||
match self {
|
||||
AudioFormat::Mp3 => "audio/mpeg",
|
||||
AudioFormat::Wav => "audio/wav",
|
||||
AudioFormat::Flac => "audio/flac",
|
||||
AudioFormat::Aac => "audio/aac",
|
||||
AudioFormat::Ogg => "audio/ogg",
|
||||
AudioFormat::M4a => "audio/mp4",
|
||||
AudioFormat::Webm => "audio/webm",
|
||||
AudioFormat::Opus => "audio/opus",
|
||||
AudioFormat::Amr => "audio/amr",
|
||||
AudioFormat::Wma => "audio/x-ms-wma",
|
||||
AudioFormat::Ra => "audio/vnd.rn-realaudio",
|
||||
AudioFormat::Au => "audio/basic",
|
||||
AudioFormat::Aiff => "audio/aiff",
|
||||
AudioFormat::Caf => "audio/x-caf",
|
||||
AudioFormat::ThreeGp => "audio/3gpp",
|
||||
AudioFormat::Mp4 => "video/mp4",
|
||||
AudioFormat::Mov => "video/quicktime",
|
||||
AudioFormat::Avi => "video/x-msvideo",
|
||||
AudioFormat::Mkv => "video/x-matroska",
|
||||
AudioFormat::Flv => "video/x-flv",
|
||||
AudioFormat::Wmv => "video/x-ms-wmv",
|
||||
AudioFormat::Mpeg => "video/mpeg",
|
||||
AudioFormat::Mxf => "application/mxf",
|
||||
AudioFormat::Unknown => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get FFmpeg input format specifier
|
||||
pub fn get_ffmpeg_input_format(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
AudioFormat::Mp3 => Some("mp3"),
|
||||
AudioFormat::Wav => Some("wav"),
|
||||
AudioFormat::Flac => Some("flac"),
|
||||
AudioFormat::Aac => Some("aac"),
|
||||
AudioFormat::M4a => Some("m4a"),
|
||||
AudioFormat::Ogg => Some("ogg"),
|
||||
AudioFormat::Webm => Some("webm"),
|
||||
AudioFormat::Opus => Some("opus"),
|
||||
AudioFormat::Amr => Some("amr"),
|
||||
AudioFormat::Wma => Some("asf"),
|
||||
AudioFormat::Ra => Some("rm"),
|
||||
AudioFormat::Au => Some("au"),
|
||||
AudioFormat::Aiff => Some("aiff"),
|
||||
AudioFormat::Caf => Some("caf"),
|
||||
AudioFormat::ThreeGp => Some("3gp"),
|
||||
AudioFormat::Mp4 => Some("mp4"),
|
||||
AudioFormat::Mov => Some("mov"),
|
||||
AudioFormat::Avi => Some("avi"),
|
||||
AudioFormat::Mkv => Some("matroska"),
|
||||
AudioFormat::Flv => Some("flv"),
|
||||
AudioFormat::Wmv => Some("wmv"),
|
||||
AudioFormat::Mpeg => Some("mpeg"),
|
||||
AudioFormat::Mxf => Some("mxf"),
|
||||
AudioFormat::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if format requires FFmpeg conversion
|
||||
pub fn requires_ffmpeg_conversion(&self) -> bool {
|
||||
!matches!(self, AudioFormat::Wav)
|
||||
}
|
||||
|
||||
/// Convert from Symphonia codec type
|
||||
pub fn from_symphonia_codec(codec_type: symphonia::core::codecs::CodecType) -> Self {
|
||||
// Convert codec type to string for matching since Symphonia 0.5 uses different constants
|
||||
let codec_str = format!("{:?}", codec_type).to_lowercase();
|
||||
|
||||
if codec_str.contains("pcm") || codec_str.contains("wav") {
|
||||
AudioFormat::Wav
|
||||
} else if codec_str.contains("mp3") || codec_str.contains("mpeg") {
|
||||
AudioFormat::Mp3
|
||||
} else if codec_str.contains("flac") {
|
||||
AudioFormat::Flac
|
||||
} else if codec_str.contains("aac") {
|
||||
AudioFormat::Aac
|
||||
} else if codec_str.contains("vorbis") {
|
||||
AudioFormat::Ogg
|
||||
} else if codec_str.contains("opus") {
|
||||
AudioFormat::Opus
|
||||
} else {
|
||||
AudioFormat::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Get corresponding Symphonia codec type (placeholder implementation)
|
||||
pub fn to_symphonia_codec(&self) -> Option<symphonia::core::codecs::CodecType> {
|
||||
// For MVP, return None since we don't need reverse mapping
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Model download status
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ModelDownloadStatus {
|
||||
pub model_name: String,
|
||||
pub status: DownloadStatus,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum DownloadStatus {
|
||||
NotStarted,
|
||||
Downloading,
|
||||
Completed,
|
||||
Failed,
|
||||
Exists,
|
||||
}
|
||||
|
||||
/// Daemon status information
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DaemonStatus {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
pub uptime: Option<u64>,
|
||||
pub memory_usage: Option<String>,
|
||||
pub cpu_usage: Option<f32>,
|
||||
}
|
||||
429
qiming-mcp-proxy/voice-cli/src/models/stepped_task.rs
Normal file
429
qiming-mcp-proxy/voice-cli/src/models/stepped_task.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::models::AudioFormat;
|
||||
|
||||
/// Initial task submitted to the queue
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AsyncTranscriptionTask {
|
||||
pub task_id: String,
|
||||
pub audio_file_path: PathBuf, // Path to the audio file on disk
|
||||
pub original_filename: String, // Original filename from upload
|
||||
pub model: Option<String>,
|
||||
pub response_format: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub priority: TaskPriority,
|
||||
}
|
||||
|
||||
impl AsyncTranscriptionTask {
|
||||
pub fn new(
|
||||
task_id: String,
|
||||
audio_file_path: PathBuf,
|
||||
original_filename: String,
|
||||
model: Option<String>,
|
||||
response_format: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id,
|
||||
audio_file_path,
|
||||
original_filename,
|
||||
model,
|
||||
response_format,
|
||||
created_at: Utc::now(),
|
||||
priority: TaskPriority::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After Step 1: Audio format processing completed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioProcessedTask {
|
||||
pub task_id: String,
|
||||
pub processed_audio_path: PathBuf,
|
||||
pub original_format: AudioFormat,
|
||||
pub model: Option<String>,
|
||||
pub response_format: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub audio_duration: Option<f32>,
|
||||
pub cleanup_files: Vec<PathBuf>, // Files to cleanup after processing
|
||||
}
|
||||
|
||||
/// After Step 2: Whisper transcription completed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranscriptionCompletedTask {
|
||||
pub task_id: String,
|
||||
pub transcription_result: SerializableTranscriptionResult,
|
||||
pub response_format: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub processing_stages: Vec<ProcessingStageInfo>,
|
||||
}
|
||||
|
||||
/// Serializable version of voice_toolkit::stt::TranscriptionResult
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SerializableTranscriptionResult {
|
||||
pub text: String,
|
||||
pub segments: Vec<SerializableSegment>,
|
||||
pub language: Option<String>,
|
||||
pub audio_duration: u64, // milliseconds
|
||||
}
|
||||
|
||||
/// Serializable version of voice_toolkit segment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SerializableSegment {
|
||||
pub start_time: u64, // milliseconds
|
||||
pub end_time: u64, // milliseconds
|
||||
pub text: String,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcessingStageInfo {
|
||||
pub stage: ProcessingStage,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: DateTime<Utc>,
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TaskPriority {
|
||||
Low = 1,
|
||||
Normal = 2,
|
||||
High = 3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum ProcessingStage {
|
||||
AudioFormatDetection,
|
||||
AudioConversion,
|
||||
WhisperTranscription,
|
||||
ResultProcessing,
|
||||
}
|
||||
|
||||
impl ProcessingStage {
|
||||
pub fn step_name(&self) -> &'static str {
|
||||
match self {
|
||||
ProcessingStage::AudioFormatDetection => "audio_format_step",
|
||||
ProcessingStage::AudioConversion => "audio_format_step", // Same step handles both
|
||||
ProcessingStage::WhisperTranscription => "whisper_transcription_step",
|
||||
ProcessingStage::ResultProcessing => "result_formatting_step",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TaskStatus {
|
||||
Pending {
|
||||
queued_at: DateTime<Utc>,
|
||||
},
|
||||
Processing {
|
||||
stage: ProcessingStage,
|
||||
started_at: DateTime<Utc>,
|
||||
progress_details: Option<ProgressDetails>,
|
||||
},
|
||||
Completed {
|
||||
completed_at: DateTime<Utc>,
|
||||
processing_time: Duration,
|
||||
result_summary: Option<String>,
|
||||
},
|
||||
Failed {
|
||||
error: TaskError,
|
||||
failed_at: DateTime<Utc>,
|
||||
retry_count: u32,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
Cancelled {
|
||||
cancelled_at: DateTime<Utc>,
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub struct ProgressDetails {
|
||||
pub current_stage: ProcessingStage,
|
||||
pub stage_progress: Option<f32>, // 0.0 to 1.0
|
||||
pub estimated_remaining: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TaskError {
|
||||
AudioProcessingFailed {
|
||||
stage: ProcessingStage,
|
||||
message: String,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
TranscriptionFailed {
|
||||
model: String,
|
||||
message: String,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
StorageError {
|
||||
operation: String,
|
||||
message: String,
|
||||
},
|
||||
TimeoutError {
|
||||
stage: ProcessingStage,
|
||||
timeout_duration: Duration,
|
||||
},
|
||||
CancellationRequested,
|
||||
}
|
||||
|
||||
impl TaskError {
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
match self {
|
||||
TaskError::AudioProcessingFailed { is_recoverable, .. } => *is_recoverable,
|
||||
TaskError::TranscriptionFailed { is_recoverable, .. } => *is_recoverable,
|
||||
TaskError::StorageError { .. } => true, // Storage errors are usually recoverable
|
||||
TaskError::TimeoutError { .. } => true, // Timeout errors are recoverable
|
||||
TaskError::CancellationRequested => false, // Cancellation is not recoverable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TaskError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TaskError::AudioProcessingFailed { stage, message, .. } => {
|
||||
write!(f, "音频处理失败 ({}): {}", stage.step_name(), message)
|
||||
}
|
||||
TaskError::TranscriptionFailed { model, message, .. } => {
|
||||
write!(f, "转录失败 ({}): {}", model, message)
|
||||
}
|
||||
TaskError::StorageError { operation, message } => {
|
||||
write!(f, "存储错误 ({}): {}", operation, message)
|
||||
}
|
||||
TaskError::TimeoutError {
|
||||
stage,
|
||||
timeout_duration,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"超时错误 ({}): {} 秒",
|
||||
stage.step_name(),
|
||||
timeout_duration.as_secs()
|
||||
)
|
||||
}
|
||||
TaskError::CancellationRequested => {
|
||||
write!(f, "任务已被取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskPriority {
|
||||
fn default() -> Self {
|
||||
TaskPriority::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncTranscriptionTask {
|
||||
pub fn with_priority(mut self, priority: TaskPriority) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioProcessedTask {
|
||||
pub fn from_async_task(
|
||||
task: AsyncTranscriptionTask,
|
||||
processed_audio_path: PathBuf,
|
||||
original_format: AudioFormat,
|
||||
audio_duration: Option<f32>,
|
||||
cleanup_files: Vec<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id: task.task_id,
|
||||
processed_audio_path,
|
||||
original_format,
|
||||
model: task.model,
|
||||
response_format: task.response_format,
|
||||
created_at: task.created_at,
|
||||
audio_duration,
|
||||
cleanup_files,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptionCompletedTask {
|
||||
pub fn from_audio_processed_task(
|
||||
task: AudioProcessedTask,
|
||||
transcription_result: SerializableTranscriptionResult,
|
||||
processing_stages: Vec<ProcessingStageInfo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id: task.task_id,
|
||||
transcription_result,
|
||||
response_format: task.response_format,
|
||||
created_at: task.created_at,
|
||||
processing_stages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversion functions between voice_toolkit types and serializable types
|
||||
impl From<voice_toolkit::stt::TranscriptionResult> for SerializableTranscriptionResult {
|
||||
fn from(result: voice_toolkit::stt::TranscriptionResult) -> Self {
|
||||
Self {
|
||||
text: result.text,
|
||||
segments: result
|
||||
.segments
|
||||
.into_iter()
|
||||
.map(|s| SerializableSegment::from_voice_toolkit_segment(s))
|
||||
.collect(),
|
||||
language: result.language,
|
||||
audio_duration: result.audio_duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::models::Segment> for SerializableSegment {
|
||||
fn from(segment: crate::models::Segment) -> Self {
|
||||
Self {
|
||||
start_time: (segment.start * 1000.0) as u64, // Convert seconds to milliseconds
|
||||
end_time: (segment.end * 1000.0) as u64, // Convert seconds to milliseconds
|
||||
text: segment.text,
|
||||
confidence: segment.confidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SerializableSegment {
|
||||
pub fn from_voice_toolkit_segment(segment: voice_toolkit::stt::TranscriptionSegment) -> Self {
|
||||
Self {
|
||||
start_time: segment.start_time * 1000, // Convert seconds to milliseconds (assuming start_time is in seconds as u64)
|
||||
end_time: segment.end_time * 1000, // Convert seconds to milliseconds (assuming end_time is in seconds as u64)
|
||||
text: segment.text,
|
||||
confidence: segment.confidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SerializableTranscriptionResult> for crate::models::TranscriptionResponse {
|
||||
fn from(result: SerializableTranscriptionResult) -> Self {
|
||||
Self {
|
||||
text: result.text,
|
||||
segments: result
|
||||
.segments
|
||||
.into_iter()
|
||||
.map(|s| crate::models::Segment {
|
||||
start: s.start_time as f32 / 1000.0, // Convert from ms to seconds
|
||||
end: s.end_time as f32 / 1000.0,
|
||||
text: s.text,
|
||||
confidence: s.confidence,
|
||||
})
|
||||
.collect(),
|
||||
language: result.language,
|
||||
duration: Some(result.audio_duration as f32 / 1000.0), // Convert from ms to seconds
|
||||
processing_time: 0.0, // Will be set by the handler
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_task_priority_default() {
|
||||
assert_eq!(TaskPriority::default(), TaskPriority::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_transcription_task_creation() {
|
||||
let task = AsyncTranscriptionTask::new(
|
||||
"test-task-1".to_string(),
|
||||
PathBuf::from("/tmp/test.mp3"),
|
||||
"test.mp3".to_string(),
|
||||
Some("base".to_string()),
|
||||
Some("json".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(task.task_id, "test-task-1");
|
||||
assert_eq!(task.audio_file_path, PathBuf::from("/tmp/test.mp3"));
|
||||
assert_eq!(task.original_filename, "test.mp3");
|
||||
assert_eq!(task.model, Some("base".to_string()));
|
||||
assert_eq!(task.response_format, Some("json".to_string()));
|
||||
assert_eq!(task.priority, TaskPriority::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_with_priority() {
|
||||
let task = AsyncTranscriptionTask::new(
|
||||
"test-task-1".to_string(),
|
||||
PathBuf::from("/tmp/test.mp3"),
|
||||
"test.mp3".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.with_priority(TaskPriority::High);
|
||||
|
||||
assert_eq!(task.priority, TaskPriority::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_processing_stage_step_name() {
|
||||
assert_eq!(
|
||||
ProcessingStage::AudioFormatDetection.step_name(),
|
||||
"audio_format_step"
|
||||
);
|
||||
assert_eq!(
|
||||
ProcessingStage::AudioConversion.step_name(),
|
||||
"audio_format_step"
|
||||
);
|
||||
assert_eq!(
|
||||
ProcessingStage::WhisperTranscription.step_name(),
|
||||
"whisper_transcription_step"
|
||||
);
|
||||
assert_eq!(
|
||||
ProcessingStage::ResultProcessing.step_name(),
|
||||
"result_formatting_step"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_error_is_recoverable() {
|
||||
let recoverable_error = TaskError::AudioProcessingFailed {
|
||||
stage: ProcessingStage::AudioConversion,
|
||||
message: "Conversion failed".to_string(),
|
||||
is_recoverable: true,
|
||||
};
|
||||
assert!(recoverable_error.is_recoverable());
|
||||
|
||||
let non_recoverable_error = TaskError::CancellationRequested;
|
||||
assert!(!non_recoverable_error.is_recoverable());
|
||||
|
||||
let timeout_error = TaskError::TimeoutError {
|
||||
stage: ProcessingStage::WhisperTranscription,
|
||||
timeout_duration: Duration::from_secs(3600),
|
||||
};
|
||||
assert!(timeout_error.is_recoverable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serializable_transcription_result_conversion() {
|
||||
use crate::models::TranscriptionResponse;
|
||||
|
||||
let serializable_result = SerializableTranscriptionResult {
|
||||
text: "Hello world".to_string(),
|
||||
segments: vec![SerializableSegment {
|
||||
start_time: 0,
|
||||
end_time: 2000, // 2 seconds in ms
|
||||
text: "Hello world".to_string(),
|
||||
confidence: 0.95,
|
||||
}],
|
||||
language: Some("en".to_string()),
|
||||
audio_duration: 2000, // 2 seconds in ms
|
||||
};
|
||||
|
||||
let response: TranscriptionResponse = serializable_result.into();
|
||||
assert_eq!(response.text, "Hello world");
|
||||
assert_eq!(response.segments.len(), 1);
|
||||
assert_eq!(response.segments[0].start, 0.0);
|
||||
assert_eq!(response.segments[0].end, 2.0); // Converted to seconds
|
||||
assert_eq!(response.language, Some("en".to_string()));
|
||||
assert_eq!(response.duration, Some(2.0)); // Converted to seconds
|
||||
}
|
||||
}
|
||||
195
qiming-mcp-proxy/voice-cli/src/models/tts.rs
Normal file
195
qiming-mcp-proxy/voice-cli/src/models/tts.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// TTS同步请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TtsSyncRequest {
|
||||
/// 要合成的文本
|
||||
pub text: String,
|
||||
/// 语音模型 (可选)
|
||||
pub model: Option<String>,
|
||||
/// 语速 (0.5-2.0, 默认1.0)
|
||||
pub speed: Option<f32>,
|
||||
/// 音调 (-20到20, 默认0)
|
||||
pub pitch: Option<i32>,
|
||||
/// 音量 (0.5-2.0, 默认1.0)
|
||||
pub volume: Option<f32>,
|
||||
/// 输出音频格式 (mp3, wav, etc.)
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
/// TTS异步请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TtsAsyncRequest {
|
||||
/// 要合成的文本
|
||||
pub text: String,
|
||||
/// 语音模型 (可选)
|
||||
pub model: Option<String>,
|
||||
/// 语速 (0.5-2.0, 默认1.0)
|
||||
pub speed: Option<f32>,
|
||||
/// 音调 (-20到20, 默认0)
|
||||
pub pitch: Option<i32>,
|
||||
/// 音量 (0.5-2.0, 默认1.0)
|
||||
pub volume: Option<f32>,
|
||||
/// 输出音频格式 (mp3, wav, etc.)
|
||||
pub format: Option<String>,
|
||||
/// 任务优先级
|
||||
pub priority: Option<TaskPriority>,
|
||||
}
|
||||
|
||||
/// TTS任务响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TtsTaskResponse {
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
pub estimated_duration: Option<u32>, // 预估处理时间(秒)
|
||||
}
|
||||
|
||||
/// TTS处理阶段
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TtsProcessingStage {
|
||||
TextPreprocessing,
|
||||
VoiceSynthesis,
|
||||
AudioPostProcessing,
|
||||
ResultFormatting,
|
||||
}
|
||||
|
||||
impl TtsProcessingStage {
|
||||
pub fn step_name(&self) -> &'static str {
|
||||
match self {
|
||||
TtsProcessingStage::TextPreprocessing => "text_preprocessing_step",
|
||||
TtsProcessingStage::VoiceSynthesis => "voice_synthesis_step",
|
||||
TtsProcessingStage::AudioPostProcessing => "audio_post_processing_step",
|
||||
TtsProcessingStage::ResultFormatting => "result_formatting_step",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TTS任务状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TtsTaskStatus {
|
||||
Pending {
|
||||
queued_at: DateTime<Utc>,
|
||||
},
|
||||
Processing {
|
||||
stage: TtsProcessingStage,
|
||||
started_at: DateTime<Utc>,
|
||||
progress_details: Option<TtsProgressDetails>,
|
||||
},
|
||||
Completed {
|
||||
completed_at: DateTime<Utc>,
|
||||
processing_time: chrono::Duration,
|
||||
audio_file_path: String,
|
||||
file_size: u64,
|
||||
duration_seconds: f32,
|
||||
},
|
||||
Failed {
|
||||
error: TtsTaskError,
|
||||
failed_at: DateTime<Utc>,
|
||||
retry_count: u32,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
Cancelled {
|
||||
cancelled_at: DateTime<Utc>,
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// TTS进度详情
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub struct TtsProgressDetails {
|
||||
pub current_stage: TtsProcessingStage,
|
||||
pub stage_progress: Option<f32>, // 0.0 to 1.0
|
||||
pub estimated_remaining: Option<chrono::Duration>,
|
||||
pub text_length: usize,
|
||||
pub processed_chars: usize,
|
||||
}
|
||||
|
||||
/// TTS任务错误
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TtsTaskError {
|
||||
TextProcessingFailed {
|
||||
message: String,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
SynthesisFailed {
|
||||
model: String,
|
||||
message: String,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
AudioProcessingFailed {
|
||||
stage: TtsProcessingStage,
|
||||
message: String,
|
||||
is_recoverable: bool,
|
||||
},
|
||||
StorageError {
|
||||
operation: String,
|
||||
message: String,
|
||||
},
|
||||
TimeoutError {
|
||||
stage: TtsProcessingStage,
|
||||
timeout_duration: chrono::Duration,
|
||||
},
|
||||
CancellationRequested,
|
||||
}
|
||||
|
||||
impl TtsTaskError {
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
match self {
|
||||
TtsTaskError::TextProcessingFailed { is_recoverable, .. } => *is_recoverable,
|
||||
TtsTaskError::SynthesisFailed { is_recoverable, .. } => *is_recoverable,
|
||||
TtsTaskError::AudioProcessingFailed { is_recoverable, .. } => *is_recoverable,
|
||||
TtsTaskError::StorageError { .. } => true,
|
||||
TtsTaskError::TimeoutError { .. } => true,
|
||||
TtsTaskError::CancellationRequested => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TtsTaskError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TtsTaskError::TextProcessingFailed { message, .. } => {
|
||||
write!(f, "文本处理失败: {}", message)
|
||||
}
|
||||
TtsTaskError::SynthesisFailed { model, message, .. } => {
|
||||
write!(f, "语音合成失败 ({}): {}", model, message)
|
||||
}
|
||||
TtsTaskError::AudioProcessingFailed { stage, message, .. } => {
|
||||
write!(f, "音频处理失败 ({}): {}", stage.step_name(), message)
|
||||
}
|
||||
TtsTaskError::StorageError { operation, message } => {
|
||||
write!(f, "存储错误 ({}): {}", operation, message)
|
||||
}
|
||||
TtsTaskError::TimeoutError {
|
||||
stage,
|
||||
timeout_duration,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"超时错误 ({}): {} 秒",
|
||||
stage.step_name(),
|
||||
timeout_duration.num_seconds()
|
||||
)
|
||||
}
|
||||
TtsTaskError::CancellationRequested => {
|
||||
write!(f, "任务已被取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TTS任务优先级 (复用现有的TaskPriority)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
pub enum TaskPriority {
|
||||
Low = 1,
|
||||
Normal = 2,
|
||||
High = 3,
|
||||
}
|
||||
|
||||
impl Default for TaskPriority {
|
||||
fn default() -> Self {
|
||||
TaskPriority::Normal
|
||||
}
|
||||
}
|
||||
84
qiming-mcp-proxy/voice-cli/src/openapi.rs
Normal file
84
qiming-mcp-proxy/voice-cli/src/openapi.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use crate::models::{
|
||||
AsyncTaskResponse, CancelResponse, DeleteResponse, HealthResponse, ModelInfo, ModelsResponse,
|
||||
RetryResponse, Segment, TaskPriority, TaskStatsResponse, TaskStatus, TaskStatusResponse,
|
||||
TranscriptionResponse,
|
||||
};
|
||||
use crate::server::handlers;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
/// OpenAPI specification for Voice CLI API
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Voice CLI API",
|
||||
version = "0.1.0",
|
||||
description = "Speech-to-text HTTP service with Whisper model support",
|
||||
license(
|
||||
name = "MIT",
|
||||
),
|
||||
contact(
|
||||
name = "Voice CLI Support",
|
||||
email = "support@voice-cli.dev"
|
||||
)
|
||||
),
|
||||
servers(
|
||||
(url = "http://localhost:8080", description = "Local development server"),
|
||||
(url = "https://api.voice-cli.dev", description = "Production server")
|
||||
),
|
||||
paths(
|
||||
handlers::health_handler,
|
||||
handlers::models_list_handler,
|
||||
handlers::transcribe_handler,
|
||||
handlers::transcribe_from_url_handler,
|
||||
handlers::async_transcribe_handler,
|
||||
handlers::get_task_handler,
|
||||
handlers::cancel_task_handler,
|
||||
handlers::get_task_result_handler,
|
||||
handlers::delete_task_handler,
|
||||
handlers::retry_task_handler,
|
||||
handlers::get_tasks_stats_handler,
|
||||
handlers::tts_sync_handler,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
TranscriptionResponse,
|
||||
Segment,
|
||||
HealthResponse,
|
||||
ModelsResponse,
|
||||
ModelInfo,
|
||||
AsyncTaskResponse,
|
||||
TaskStatusResponse,
|
||||
TaskStatus,
|
||||
TaskPriority,
|
||||
CancelResponse,
|
||||
DeleteResponse,
|
||||
RetryResponse,
|
||||
TaskStatsResponse
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "Health", description = "Service health and status endpoints"),
|
||||
(name = "Models", description = "Whisper model management endpoints"),
|
||||
(name = "Transcription", description = "Speech-to-text transcription endpoints"),
|
||||
(name = "Async Transcription", description = "Asynchronous transcription task management"),
|
||||
(name = "Task Management", description = "Task lifecycle and monitoring endpoints")
|
||||
),
|
||||
external_docs(
|
||||
url = "https://github.com/your-org/voice-cli",
|
||||
description = "Voice CLI GitHub Repository"
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// Create Swagger UI service
|
||||
pub fn create_swagger_ui() -> SwaggerUi {
|
||||
SwaggerUi::new("/api/docs")
|
||||
.url("/api/docs/openapi.json", ApiDoc::openapi())
|
||||
.config(utoipa_swagger_ui::Config::new(["/api/docs/openapi.json"]))
|
||||
}
|
||||
|
||||
/// Get OpenAPI JSON specification
|
||||
pub fn get_openapi_json() -> utoipa::openapi::OpenApi {
|
||||
ApiDoc::openapi()
|
||||
}
|
||||
51
qiming-mcp-proxy/voice-cli/src/server/app_state.rs
Normal file
51
qiming-mcp-proxy/voice-cli/src/server/app_state.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::Config;
|
||||
use crate::services::TranscriptionTask;
|
||||
use crate::services::{LockFreeApalisManager, ModelService};
|
||||
use apalis_sql::sqlite::SqliteStorage;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use tracing::info;
|
||||
|
||||
/// 简化的应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub model_service: Arc<ModelService>,
|
||||
pub apalis_storage: Option<SqliteStorage<TranscriptionTask>>,
|
||||
pub start_time: SystemTime,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub async fn new(config: Arc<Config>) -> Result<Self, VoiceCliError> {
|
||||
let model_service = Arc::new(ModelService::new((*config).clone()));
|
||||
|
||||
// 初始化无锁 Apalis 管理器
|
||||
info!("Initializing the Lock-Free Apalis Task Manager");
|
||||
let (manager, storage) =
|
||||
LockFreeApalisManager::new(config.task_management.clone(), model_service.clone())
|
||||
.await?;
|
||||
|
||||
// 启动 worker
|
||||
manager
|
||||
.start_worker(storage.clone(), model_service.clone())
|
||||
.await?;
|
||||
|
||||
let apalis_storage = Some(storage);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
model_service,
|
||||
apalis_storage,
|
||||
start_time: SystemTime::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 优雅关闭
|
||||
pub async fn shutdown(self) {
|
||||
info!("Close application state");
|
||||
// Apalis 管理器会在 Drop 时自动清理
|
||||
info!("Application status closed completed");
|
||||
}
|
||||
}
|
||||
1060
qiming-mcp-proxy/voice-cli/src/server/handlers.rs
Normal file
1060
qiming-mcp-proxy/voice-cli/src/server/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
128
qiming-mcp-proxy/voice-cli/src/server/http_tracing.rs
Normal file
128
qiming-mcp-proxy/voice-cli/src/server/http_tracing.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use axum::body::Body;
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
use tracing::{Instrument, info_span};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 基本追踪中间件
|
||||
/// 为每个请求生成唯一的追踪ID,并在响应中添加tid字段
|
||||
pub async fn basic_tracing_middleware(request: Request, next: Next) -> Response {
|
||||
// 获取或生成追踪ID
|
||||
let tid = get_or_generate_trace_id(&request);
|
||||
|
||||
// 在移动 request 之前先获取 URI 信息
|
||||
let is_health_check = request.uri().path() == "/health";
|
||||
|
||||
// 创建请求span
|
||||
let span = info_span!(
|
||||
"http_request",
|
||||
tid = %tid,
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
version = ?request.version(),
|
||||
);
|
||||
|
||||
// 在span中执行请求处理
|
||||
let response = next.run(request).instrument(span).await;
|
||||
|
||||
// 健康检查不处理
|
||||
if is_health_check {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 仅当响应是 HttpResult(通过扩展标记判断)才注入 tid
|
||||
if response
|
||||
.extensions()
|
||||
.get::<crate::models::HttpResultMarker>()
|
||||
.is_some()
|
||||
{
|
||||
return add_tid_to_response(response, tid).await;
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// 获取或生成追踪ID
|
||||
fn get_or_generate_trace_id(request: &Request) -> String {
|
||||
if let Some(traceparent) = request.headers().get("traceparent") {
|
||||
if let Ok(traceparent_str) = traceparent.to_str() {
|
||||
if let Some(trace_id) = traceparent_str.split('-').nth(1) {
|
||||
if trace_id.len() == 32 {
|
||||
return trace_id.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// 向响应中添加追踪ID
|
||||
async fn add_tid_to_response(response: Response, tid: String) -> Response {
|
||||
// 分解响应以获取body和parts
|
||||
let (parts, body) = response.into_parts();
|
||||
|
||||
// 尝试从body中提取JSON内容
|
||||
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => {
|
||||
// 如果无法读取body,直接返回原响应
|
||||
return Response::from_parts(parts, Body::from(Vec::<u8>::new()));
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试解析为JSON
|
||||
if let Ok(mut json_value) = serde_json::from_slice::<serde_json::Value>(&body_bytes) {
|
||||
// 仅当是 HttpResult 结构(至少包含 code 与 data)时才注入 tid
|
||||
if let Some(obj) = json_value.as_object_mut() {
|
||||
let is_http_result_like = obj.contains_key("code") && obj.contains_key("data");
|
||||
if is_http_result_like {
|
||||
obj.insert("tid".to_string(), serde_json::Value::String(tid));
|
||||
if let Ok(new_body) = serde_json::to_vec(&json_value) {
|
||||
return Response::from_parts(parts, Body::from(new_body));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是 HttpResult 或无法修改,返回原响应
|
||||
Response::from_parts(parts, Body::from(body_bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
|
||||
#[test]
|
||||
fn test_get_or_generate_trace_id() {
|
||||
// 测试有效的 traceparent header
|
||||
let request = Request::builder()
|
||||
.header(
|
||||
"traceparent",
|
||||
"00-12345678901234567890123456789012-1234567890123456-01",
|
||||
)
|
||||
.uri("/some/path")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_or_generate_trace_id(&request),
|
||||
"12345678901234567890123456789012"
|
||||
);
|
||||
|
||||
// 测试没有 traceparent header 的请求 - 应该生成一个新的 UUID
|
||||
let request = Request::builder()
|
||||
.uri("/health")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap();
|
||||
let trace_id = get_or_generate_trace_id(&request);
|
||||
// 验证生成的是有效的 UUID 格式 (不带连字符的 UUID 长度为 32)
|
||||
assert!(trace_id.len() == 36 || trace_id.len() == 32);
|
||||
|
||||
// 测试另一个没有 traceparent header 的请求
|
||||
let request = Request::builder()
|
||||
.uri("/some/path")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap();
|
||||
let trace_id = get_or_generate_trace_id(&request);
|
||||
assert!(trace_id.len() == 36 || trace_id.len() == 32);
|
||||
}
|
||||
}
|
||||
419
qiming-mcp-proxy/voice-cli/src/server/middleware.rs
Normal file
419
qiming-mcp-proxy/voice-cli/src/server/middleware.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
http::{HeaderMap, HeaderValue, Method, Uri, header::CONNECTION},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::time::Instant;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Connection: close 中间件
|
||||
/// 为所有HTTP响应添加 Connection: close 头,禁用长连接
|
||||
pub async fn connection_close_middleware(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
// 设置 Connection: close 响应头(使用框架常量)
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONNECTION, HeaderValue::from_static("close"));
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// HTTP请求日志中间件
|
||||
/// 记录HTTP请求的详细信息,包括方法、路径、查询参数、headers等
|
||||
/// 对于Multipart请求,不记录请求体内容以避免日志过大
|
||||
/// 对于其他请求,记录请求参数(body和query params)
|
||||
pub async fn request_logging_middleware(request: Request, next: Next) -> Response {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// 提取请求信息
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
let headers = request.headers().clone();
|
||||
let version = request.version();
|
||||
|
||||
// 检查是否为Multipart请求
|
||||
let is_multipart = is_multipart_request(&method, &uri, &headers);
|
||||
|
||||
// 获取用户IP (从headers中提取)
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
|
||||
// 获取用户代理
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// 获取内容类型
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// 获取内容长度
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// 获取查询参数
|
||||
let query_params = extract_query_params(&uri);
|
||||
|
||||
// 记录请求开始
|
||||
let (request, _body_params) = if is_multipart {
|
||||
info!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
version = ?version,
|
||||
client_ip = %client_ip,
|
||||
user_agent = %user_agent,
|
||||
content_type = %content_type,
|
||||
content_length = content_length,
|
||||
query_params = ?query_params,
|
||||
is_multipart = true,
|
||||
"HTTP request started (Multipart - body not logged)"
|
||||
);
|
||||
(request, Value::Null)
|
||||
} else {
|
||||
// 对于非Multipart请求,提取请求体参数
|
||||
let (body_params, rebuilt_request) = extract_body_params(request).await;
|
||||
|
||||
info!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
version = ?version,
|
||||
client_ip = %client_ip,
|
||||
user_agent = %user_agent,
|
||||
content_type = %content_type,
|
||||
content_length = content_length,
|
||||
query_params = ?query_params,
|
||||
body_params = ?body_params,
|
||||
is_multipart = false,
|
||||
"HTTP request started (body params extracted)"
|
||||
);
|
||||
(rebuilt_request, body_params)
|
||||
};
|
||||
|
||||
// 处理请求
|
||||
let response = next.run(request).await;
|
||||
|
||||
// 计算处理时间
|
||||
let duration = start_time.elapsed();
|
||||
let duration_ms = duration.as_millis() as u64;
|
||||
|
||||
// 获取响应信息
|
||||
let status = response.status();
|
||||
let response_headers = response.headers();
|
||||
|
||||
// 获取响应内容长度
|
||||
let response_content_length = response_headers
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// 记录请求完成
|
||||
match status.as_u16() {
|
||||
200..=299 => {
|
||||
info!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status,
|
||||
duration_ms = duration_ms,
|
||||
response_content_length = response_content_length,
|
||||
client_ip = %client_ip,
|
||||
"HTTP request completed successfully"
|
||||
);
|
||||
}
|
||||
400..=499 => {
|
||||
warn!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status,
|
||||
duration_ms = duration_ms,
|
||||
response_content_length = response_content_length,
|
||||
client_ip = %client_ip,
|
||||
"HTTP request completed with client error"
|
||||
);
|
||||
}
|
||||
500..=599 => {
|
||||
error!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status,
|
||||
duration_ms = duration_ms,
|
||||
response_content_length = response_content_length,
|
||||
client_ip = %client_ip,
|
||||
"HTTP request completed with server error"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status,
|
||||
duration_ms = duration_ms,
|
||||
response_content_length = response_content_length,
|
||||
client_ip = %client_ip,
|
||||
"HTTP request completed with unexpected status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// 检查是否为Multipart请求
|
||||
fn is_multipart_request(method: &Method, uri: &Uri, headers: &HeaderMap) -> bool {
|
||||
let _ = method;
|
||||
let _ = uri;
|
||||
// 检查Content-Type是否包含multipart
|
||||
if let Some(content_type) = headers.get("content-type") {
|
||||
if let Ok(content_type_str) = content_type.to_str() {
|
||||
return content_type_str.contains("multipart/form-data");
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 提取请求体参数(仅适用于非Multipart请求)
|
||||
/// 注意:此函数会消费请求,调用者需要重新构建请求
|
||||
async fn extract_body_params(request: Request) -> (Value, Request) {
|
||||
// 只处理JSON请求体
|
||||
let content_type = request
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if content_type.contains("application/json") {
|
||||
// 提取请求体
|
||||
let (parts, body) = request.into_parts();
|
||||
|
||||
// 尝试读取请求体
|
||||
match axum::body::to_bytes(body, usize::MAX).await {
|
||||
Ok(bytes) => {
|
||||
// 克隆字节数据以便重新构建请求
|
||||
let bytes_clone = bytes.clone();
|
||||
|
||||
// 尝试解析JSON
|
||||
match serde_json::from_slice::<Value>(&bytes) {
|
||||
Ok(json_value) => {
|
||||
// 重新构建请求体
|
||||
let new_body = Body::from(bytes_clone);
|
||||
let new_request = Request::from_parts(parts, new_body);
|
||||
(json_value, new_request)
|
||||
}
|
||||
Err(_) => {
|
||||
// JSON解析失败,重新构建请求并返回原始内容
|
||||
let raw_content =
|
||||
Value::String(String::from_utf8_lossy(&bytes).to_string());
|
||||
let new_body = Body::from(bytes);
|
||||
let new_request = Request::from_parts(parts, new_body);
|
||||
(raw_content, new_request)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// 无法读取请求体,重新构建空请求
|
||||
let new_body = Body::empty();
|
||||
let new_request = Request::from_parts(parts, new_body);
|
||||
(Value::Null, new_request)
|
||||
}
|
||||
}
|
||||
} else if content_type.contains("application/x-www-form-urlencoded") {
|
||||
// 处理表单数据
|
||||
let (parts, body) = request.into_parts();
|
||||
|
||||
match axum::body::to_bytes(body, usize::MAX).await {
|
||||
Ok(bytes) => {
|
||||
let bytes_clone = bytes.clone();
|
||||
let form_data = String::from_utf8_lossy(&bytes);
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
for (key, value) in url::form_urlencoded::parse(form_data.as_bytes()) {
|
||||
params.insert(key.to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
|
||||
// 重新构建请求体
|
||||
let new_body = Body::from(bytes_clone);
|
||||
let new_request = Request::from_parts(parts, new_body);
|
||||
(Value::Object(params), new_request)
|
||||
}
|
||||
Err(_) => {
|
||||
// 无法读取请求体,重新构建空请求
|
||||
let new_body = Body::empty();
|
||||
let new_request = Request::from_parts(parts, new_body);
|
||||
(Value::Null, new_request)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不支持的Content-Type,返回Null
|
||||
(Value::Null, request)
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否为文件上传请求(保留原有逻辑以兼容)
|
||||
#[allow(dead_code)]
|
||||
fn is_file_upload_request(method: &Method, uri: &Uri, headers: &HeaderMap) -> bool {
|
||||
// 检查是否为POST方法
|
||||
if method != Method::POST {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查路径是否为转录端点
|
||||
if uri.path() == "/transcribe" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查Content-Type是否包含multipart或媒体文件
|
||||
if let Some(content_type) = headers.get("content-type") {
|
||||
if let Ok(content_type_str) = content_type.to_str() {
|
||||
return content_type_str.contains("multipart/form-data")
|
||||
|| content_type_str.contains("audio/")
|
||||
|| content_type_str.contains("video/");
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 提取查询参数
|
||||
fn extract_query_params(uri: &Uri) -> Value {
|
||||
if let Some(query) = uri.query() {
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
params.insert(key.to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
|
||||
Value::Object(params)
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求头中提取客户端IP地址
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
// 按优先级检查不同的IP头
|
||||
let ip_headers = [
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
"x-client-ip",
|
||||
"cf-connecting-ip",
|
||||
"true-client-ip",
|
||||
];
|
||||
|
||||
for header_name in &ip_headers {
|
||||
if let Some(header_value) = headers.get(*header_name) {
|
||||
if let Ok(ip_str) = header_value.to_str() {
|
||||
// x-forwarded-for 可能包含多个IP,取第一个
|
||||
let ip = ip_str.split(',').next().unwrap_or(ip_str).trim();
|
||||
if !ip.is_empty() && ip != "unknown" {
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
|
||||
#[test]
|
||||
fn test_middleware_module_exists() {
|
||||
// Simple test to verify the module compiles
|
||||
// Actual middleware testing would require more complex setup
|
||||
assert!(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_multipart_request() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static(
|
||||
"multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW",
|
||||
),
|
||||
);
|
||||
|
||||
let method = Method::POST;
|
||||
let uri: Uri = "/api/v1/tasks/transcribe".parse().unwrap();
|
||||
|
||||
assert!(is_multipart_request(&method, &uri, &headers));
|
||||
|
||||
// Test with non-multipart content type
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
assert!(!is_multipart_request(&method, &uri, &headers));
|
||||
|
||||
// Test with GET request
|
||||
let method = Method::GET;
|
||||
let uri: Uri = "/health".parse().unwrap();
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert!(!is_multipart_request(&method, &uri, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_params() {
|
||||
let uri: Uri = "http://localhost:8080/api/v1/tasks?status=completed&limit=10"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let params = extract_query_params(&uri);
|
||||
|
||||
if let Value::Object(map) = params {
|
||||
assert_eq!(
|
||||
map.get("status"),
|
||||
Some(&Value::String("completed".to_string()))
|
||||
);
|
||||
assert_eq!(map.get("limit"), Some(&Value::String("10".to_string())));
|
||||
} else {
|
||||
panic!("Expected object but got: {:?}", params);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_params_empty() {
|
||||
let uri: Uri = "http://localhost:8080/health".parse().unwrap();
|
||||
let params = extract_query_params(&uri);
|
||||
|
||||
assert_eq!(params, Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-forwarded-for"),
|
||||
HeaderValue::from_static("192.168.1.1, 10.0.0.1"),
|
||||
);
|
||||
|
||||
assert_eq!(extract_client_ip(&headers), "192.168.1.1");
|
||||
|
||||
// Test with x-real-ip
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-real-ip"),
|
||||
HeaderValue::from_static("203.0.113.1"),
|
||||
);
|
||||
|
||||
assert_eq!(extract_client_ip(&headers), "203.0.113.1");
|
||||
|
||||
// Test with no IP headers
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_client_ip(&headers), "unknown");
|
||||
}
|
||||
}
|
||||
37
qiming-mcp-proxy/voice-cli/src/server/middleware_config.rs
Normal file
37
qiming-mcp-proxy/voice-cli/src/server/middleware_config.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::middleware::from_fn;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
||||
use crate::server::http_tracing::basic_tracing_middleware;
|
||||
use crate::server::middleware::{connection_close_middleware, request_logging_middleware};
|
||||
|
||||
/// 与 mcp-proxy 风格一致的统一挂载接口
|
||||
/// 建议路由构建完成后统一调用该函数挂载层
|
||||
pub fn set_layer<T>(app: Router, _state: T, max_file_size: usize, cors_enabled: bool) -> Router
|
||||
where
|
||||
T: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let app = app.layer(RequestBodyLimitLayer::new(max_file_size));
|
||||
|
||||
let app = if cors_enabled {
|
||||
use tower_http::cors::CorsLayer;
|
||||
app.layer(CorsLayer::permissive())
|
||||
} else {
|
||||
app
|
||||
};
|
||||
|
||||
info!("Maximum file size limit: {}MB", max_file_size / 1024 / 1024);
|
||||
|
||||
app.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(from_fn(connection_close_middleware))
|
||||
.layer(from_fn(basic_tracing_middleware))
|
||||
.layer(from_fn(request_logging_middleware))
|
||||
.layer(DefaultBodyLimit::max(max_file_size))
|
||||
.layer(TraceLayer::new_for_http()),
|
||||
)
|
||||
}
|
||||
366
qiming-mcp-proxy/voice-cli/src/server/mod.rs
Normal file
366
qiming-mcp-proxy/voice-cli/src/server/mod.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
pub mod app_state;
|
||||
pub mod handlers;
|
||||
pub mod http_tracing;
|
||||
pub mod middleware;
|
||||
pub mod middleware_config;
|
||||
pub mod routes;
|
||||
|
||||
use crate::models::Config;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
|
||||
async fn shutdown_signal_with_broadcast(shutdown_tx: broadcast::Sender<()>) {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
info!("Signal received, starting graceful shutdown");
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
|
||||
/// Initialize server configuration
|
||||
pub async fn handle_server_init(config_path: Option<PathBuf>, force: bool) -> crate::Result<()> {
|
||||
let output_path = config_path.unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("server-config.yml")
|
||||
});
|
||||
|
||||
// 检查文件是否已存在
|
||||
if output_path.exists() && !force {
|
||||
println!("❌ Configuration file already exists: {:?}", output_path);
|
||||
println!("💡 Use --force to overwrite, or specify a different path with --config");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 生成配置文件
|
||||
crate::config::ConfigTemplateGenerator::generate_config_file(
|
||||
crate::config::ServiceType::Server,
|
||||
&output_path,
|
||||
)?;
|
||||
|
||||
println!("✅ Server configuration initialized: {:?}", output_path);
|
||||
|
||||
// 检查并创建 tts_service.py 文件
|
||||
if let Err(e) = create_tts_service_file().await {
|
||||
warn!("Failed to create tts_service.py: {}", e);
|
||||
println!("⚠️ TTS service file creation failed: {}", e);
|
||||
} else {
|
||||
println!("✅ TTS service file created successfully");
|
||||
}
|
||||
|
||||
// 初始化 Python 虚拟环境和 TTS 依赖
|
||||
if let Err(e) = init_python_tts_environment().await {
|
||||
warn!("Failed to initialize Python TTS environment: {}", e);
|
||||
println!("⚠️ Python TTS environment initialization failed: {}", e);
|
||||
println!(
|
||||
"💡 You can manually initialize it later with: uv venv && uv add index-tts torch torchaudio numpy soundfile"
|
||||
);
|
||||
} else {
|
||||
println!("✅ Python TTS environment initialized successfully");
|
||||
}
|
||||
|
||||
println!("📝 Edit the configuration file and run:");
|
||||
println!(" voice-cli server run --config {:?}", output_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create tts_service.py file if it doesn't exist
|
||||
pub async fn create_tts_service_file() -> crate::Result<()> {
|
||||
use std::fs;
|
||||
|
||||
let current_dir = std::env::current_dir().map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to get current directory: {}", e))
|
||||
})?;
|
||||
|
||||
let tts_service_path = current_dir.join("tts_service.py");
|
||||
|
||||
// 如果文件已存在,跳过创建
|
||||
if tts_service_path.exists() {
|
||||
info!("tts_service.py already exists, skipping creation");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 从模板文件加载 tts_service.py 内容
|
||||
let tts_service_content = include_str!("../../templates/tts_service.py.template");
|
||||
|
||||
// 写入文件
|
||||
fs::write(&tts_service_path, tts_service_content).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to create tts_service.py: {}", e))
|
||||
})?;
|
||||
|
||||
// 设置文件权限为可执行
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = tts_service_path
|
||||
.metadata()
|
||||
.map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to get file permissions: {}", e))
|
||||
})?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&tts_service_path, perms).map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to set file permissions: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"tts_service.py created successfully: {:?}",
|
||||
tts_service_path
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize Python virtual environment and TTS dependencies using uv
|
||||
pub async fn init_python_tts_environment() -> crate::Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
println!("🐍 Initializing Python TTS environment with uv...");
|
||||
|
||||
// Check if uv is available
|
||||
let uv_check = Command::new("uv").arg("--version").output();
|
||||
match uv_check {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
println!("✅ Found uv: {}", version.trim());
|
||||
}
|
||||
Ok(_) => {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"uv command found but failed to get version".to_string(),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(crate::VoiceCliError::Config(format!(
|
||||
"uv not found. Please install uv first: https://docs.astral.sh/uv/getting-started/installation/ - Error: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the path to the pyproject.toml file (in the voice-cli crate directory)
|
||||
let project_dir = std::env::current_dir().map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to get current directory: {}", e))
|
||||
})?;
|
||||
|
||||
// Check if pyproject.toml exists in current directory
|
||||
let pyproject_path = project_dir.join("pyproject.toml");
|
||||
let work_dir = if pyproject_path.exists() {
|
||||
project_dir.clone()
|
||||
} else {
|
||||
// Try to find it in the crate directory
|
||||
let crate_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let crate_pyproject = crate_path.join("pyproject.toml");
|
||||
if crate_pyproject.exists() {
|
||||
crate_path
|
||||
} else {
|
||||
return Err(crate::VoiceCliError::Config(
|
||||
"pyproject.toml not found in current directory or crate directory".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
println!(" Using project directory: {:?}", work_dir);
|
||||
|
||||
// Create virtual environment if it doesn't exist
|
||||
let venv_path = work_dir.join(".venv");
|
||||
if !venv_path.exists() {
|
||||
println!("📦 Creating Python virtual environment...");
|
||||
let mut cmd = Command::new("uv");
|
||||
cmd.arg("venv").current_dir(&work_dir);
|
||||
|
||||
let output = cmd.output().map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to create virtual environment: {}", e))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(crate::VoiceCliError::Config(format!(
|
||||
"Failed to create virtual environment: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
println!("✅ Virtual environment created successfully");
|
||||
} else {
|
||||
println!("✅ Virtual environment already exists");
|
||||
}
|
||||
|
||||
// Install TTS dependencies
|
||||
println!("📚 Installing TTS dependencies...");
|
||||
|
||||
// Install audio processing dependencies
|
||||
let dependencies = ["torch", "torchaudio", "numpy", "soundfile"];
|
||||
for dep in &dependencies {
|
||||
println!(" Installing {}...", dep);
|
||||
let mut cmd = Command::new("uv");
|
||||
cmd.arg("add").arg(dep).current_dir(&work_dir);
|
||||
|
||||
let output = cmd.output().map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to install {}: {}", dep, e))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("⚠️ {} installation warning: {}", dep, stderr);
|
||||
} else {
|
||||
println!("✅ {} installed successfully", dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Test the installation (check if audio libraries are available)
|
||||
println!("🧪 Testing TTS installation...");
|
||||
let test_script = r#"
|
||||
import sys
|
||||
try:
|
||||
import torch
|
||||
import torchaudio
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
print("Audio processing libraries imported successfully")
|
||||
print(f"PyTorch version: {torch.__version__}")
|
||||
print(f"Torchaudio version: {torchaudio.__version__}")
|
||||
print(f"NumPy version: {np.__version__}")
|
||||
print(f"SoundFile version: {sf.__version__}")
|
||||
|
||||
# Test if index-tts is available (optional)
|
||||
try:
|
||||
import index_tts
|
||||
print("index-tts is available - using real TTS")
|
||||
HAS_REAL_TTS = True
|
||||
except ImportError:
|
||||
print("index-tts not available - using mock TTS implementation")
|
||||
HAS_REAL_TTS = False
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Failed to import audio libraries: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error testing audio libraries: {e}")
|
||||
sys.exit(1)
|
||||
"#;
|
||||
|
||||
let mut cmd = Command::new("uv");
|
||||
cmd.arg("run")
|
||||
.arg("python")
|
||||
.arg("-c")
|
||||
.arg(test_script)
|
||||
.current_dir(&work_dir);
|
||||
|
||||
let output = cmd.output().map_err(|e| {
|
||||
crate::VoiceCliError::Config(format!("Failed to test TTS installation: {}", e))
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("✅ TTS installation test passed:");
|
||||
for line in stdout.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("⚠️ TTS installation test failed:");
|
||||
println!(" stderr: {}", stderr);
|
||||
println!(" stdout: {}", stdout);
|
||||
return Err(crate::VoiceCliError::Config(format!(
|
||||
"TTS installation test failed"
|
||||
)));
|
||||
}
|
||||
|
||||
println!("✅ Python TTS environment setup complete!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run server in foreground mode (direct HTTP server)
|
||||
pub async fn handle_server_run(config: &Config) -> crate::Result<()> {
|
||||
info!("Starting voice-cli server in foreground mode...");
|
||||
|
||||
// Initialize logging - keep the guard alive for the duration of the process
|
||||
info!("About to initialize logging...");
|
||||
crate::utils::init_logging(config)?;
|
||||
info!("Logging initialized successfully");
|
||||
|
||||
let config_arc = Arc::new(config.clone());
|
||||
let app_state = handlers::AppState::new(config_arc.clone()).await?;
|
||||
let mut app = routes::create_routes_with_state(app_state.clone()).await?;
|
||||
|
||||
// Clone app_state for use in monitor
|
||||
let app_state_for_monitor = app_state.clone();
|
||||
|
||||
// Create shutdown channel for monitor task
|
||||
let (shutdown_tx, _) = broadcast::channel(1);
|
||||
let mut shutdown_rx = shutdown_tx.subscribe();
|
||||
|
||||
// 添加 storage 作为 Extension
|
||||
app = app.layer(axum::Extension(app_state.apalis_storage.clone()));
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.server.port));
|
||||
info!("Server listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| crate::VoiceCliError::Config(format!("Failed to bind to address: {}", e)))?;
|
||||
|
||||
info!(
|
||||
"TCP listener created successfully: {:?}",
|
||||
listener.local_addr()
|
||||
);
|
||||
info!("Starting axum server...");
|
||||
|
||||
let http = async {
|
||||
let result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal_with_broadcast(shutdown_tx))
|
||||
.await
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
|
||||
info!("Axum server completed, performing graceful shutdown...");
|
||||
|
||||
// Perform graceful shutdown of application state
|
||||
app_state.shutdown().await;
|
||||
|
||||
// Perform global cleanup operations
|
||||
crate::utils::perform_shutdown_cleanup().await;
|
||||
|
||||
info!("Graceful shutdown completed with result: {:?}", result);
|
||||
result
|
||||
};
|
||||
|
||||
let monitor = async {
|
||||
// Wait for shutdown signal
|
||||
let _ = shutdown_rx.recv().await;
|
||||
info!("Monitor task received shutdown signal, stopping Apalis manager...");
|
||||
|
||||
// Gracefully shutdown the Apalis manager
|
||||
if let Err(e) = app_state_for_monitor
|
||||
.lock_free_apalis_manager
|
||||
.shutdown()
|
||||
.await
|
||||
{
|
||||
warn!("Failed to shutdown Apalis manager gracefully: {}", e);
|
||||
}
|
||||
|
||||
Ok::<(), std::io::Error>(())
|
||||
};
|
||||
|
||||
let _res = tokio::join!(http, monitor);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
qiming-mcp-proxy/voice-cli/src/server/routes.rs
Normal file
81
qiming-mcp-proxy/voice-cli/src/server/routes.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use crate::models::Config;
|
||||
use crate::openapi;
|
||||
use crate::server::handlers;
|
||||
use crate::server::middleware_config::set_layer;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Create routes for the server
|
||||
pub async fn create_routes(config: Arc<Config>) -> crate::Result<Router> {
|
||||
let shared_state = handlers::AppState::new(config.clone()).await?;
|
||||
create_routes_with_state(shared_state).await
|
||||
}
|
||||
|
||||
/// Create routes with pre-created AppState
|
||||
pub async fn create_routes_with_state(shared_state: handlers::AppState) -> crate::Result<Router> {
|
||||
let config = shared_state.config.clone();
|
||||
|
||||
let app = Router::new()
|
||||
// Health check endpoint
|
||||
.route("/health", get(handlers::health_handler))
|
||||
// Models management endpoints
|
||||
.route("/models", get(handlers::models_list_handler))
|
||||
// Transcription endpoint (synchronous)
|
||||
.route("/transcribe", post(handlers::transcribe_handler))
|
||||
// TTS endpoints
|
||||
.route("/tts/sync", post(handlers::tts_sync_handler))
|
||||
// Task management endpoints under /api/v1/tasks
|
||||
.nest("/api/v1/tasks", task_routes())
|
||||
// Add shared state
|
||||
.with_state(shared_state.clone())
|
||||
// Merge Swagger UI routes
|
||||
.merge(openapi::create_swagger_ui());
|
||||
|
||||
// 统一中间件挂载
|
||||
let app = set_layer(
|
||||
app,
|
||||
shared_state,
|
||||
config.server.max_file_size,
|
||||
config.server.cors_enabled,
|
||||
);
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Create task management routes
|
||||
fn task_routes() -> Router<handlers::AppState> {
|
||||
Router::new()
|
||||
// Task submission
|
||||
.route("/transcribe", post(handlers::async_transcribe_handler))
|
||||
// URL-based task submission
|
||||
.route(
|
||||
"/transcribeFromUrl",
|
||||
post(handlers::transcribe_from_url_handler),
|
||||
)
|
||||
// TTS task submission
|
||||
.route("/tts", post(handlers::tts_async_handler))
|
||||
// Task status and management
|
||||
.route("/{task_id}", get(handlers::get_task_handler))
|
||||
.route("/{task_id}", delete(handlers::delete_task_handler))
|
||||
.route("/{task_id}/result", get(handlers::get_task_result_handler))
|
||||
.route("/{task_id}/cancel", post(handlers::cancel_task_handler))
|
||||
.route("/{task_id}/retry", post(handlers::retry_task_handler))
|
||||
// Task statistics
|
||||
.route("/stats", get(handlers::get_tasks_stats_handler))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::Config;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_routes() {
|
||||
let config = Arc::new(Config::default());
|
||||
let app = create_routes(config).await;
|
||||
assert!(app.is_ok());
|
||||
}
|
||||
}
|
||||
1863
qiming-mcp-proxy/voice-cli/src/services/apalis_manager.rs
Normal file
1863
qiming-mcp-proxy/voice-cli/src/services/apalis_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
392
qiming-mcp-proxy/voice-cli/src/services/audio_file_manager.rs
Normal file
392
qiming-mcp-proxy/voice-cli/src/services/audio_file_manager.rs
Normal file
@@ -0,0 +1,392 @@
|
||||
use crate::VoiceCliError;
|
||||
use axum::extract::multipart::Field;
|
||||
use bytes::Bytes;
|
||||
use futures::TryStreamExt; // StreamExt 未使用,移除
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Service for managing audio files on disk
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFileManager {
|
||||
pub storage_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AudioFileManager {
|
||||
/// Create a new AudioFileManager
|
||||
pub fn new<P: AsRef<Path>>(storage_dir: P) -> Result<Self, VoiceCliError> {
|
||||
let storage_dir = storage_dir.as_ref().to_path_buf();
|
||||
|
||||
// Create storage directory if it doesn't exist
|
||||
if !storage_dir.exists() {
|
||||
fs::create_dir_all(&storage_dir).map_err(|e| {
|
||||
VoiceCliError::Storage(format!(
|
||||
"Failed to create audio storage directory '{}': {}",
|
||||
storage_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"AudioFileManager initialized with storage directory: {}",
|
||||
storage_dir.display()
|
||||
);
|
||||
|
||||
Ok(Self { storage_dir })
|
||||
}
|
||||
|
||||
/// Save audio data to disk and return the file path
|
||||
pub async fn save_audio_file(
|
||||
&self,
|
||||
task_id: &str,
|
||||
audio_data: &Bytes,
|
||||
original_filename: &str,
|
||||
) -> Result<PathBuf, VoiceCliError> {
|
||||
// Extract file extension from original filename
|
||||
let extension = Path::new(original_filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("bin");
|
||||
|
||||
// Create a unique filename using task_id
|
||||
let filename = format!("{}_{}.{}", task_id, uuid::Uuid::new_v4(), extension);
|
||||
let file_path = self.storage_dir.join(&filename);
|
||||
|
||||
// Write audio data to file
|
||||
tokio::fs::write(&file_path, audio_data)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
VoiceCliError::Storage(format!(
|
||||
"Failed to write audio file '{}': {}",
|
||||
file_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Saved audio file: {} ({} bytes) -> {}",
|
||||
original_filename,
|
||||
audio_data.len(),
|
||||
file_path.display()
|
||||
);
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Save audio data from multipart field stream directly to disk
|
||||
pub async fn save_audio_file_streaming(
|
||||
&self,
|
||||
task_id: &str,
|
||||
field: Field<'_>,
|
||||
temp_file_name: &str,
|
||||
) -> Result<String, VoiceCliError> {
|
||||
// 获取原始文件名(如果有)用于日志记录
|
||||
let original_filename = field
|
||||
.file_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
info!(
|
||||
"[Task {}] Start receiving audio file stream: {}, target temporary file name: {}",
|
||||
task_id, original_filename, temp_file_name
|
||||
);
|
||||
|
||||
let file_path = self.storage_dir.join(&temp_file_name);
|
||||
|
||||
// 确保存储目录存在
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
error!(
|
||||
"[Task {}] Unable to create storage directory '{}': {}",
|
||||
task_id,
|
||||
parent.display(),
|
||||
e
|
||||
);
|
||||
VoiceCliError::Storage(format!(
|
||||
"无法创建存储目录 '{}': {}",
|
||||
parent.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
let file = tokio::fs::File::create(&file_path).await.map_err(|e| {
|
||||
error!(
|
||||
"[Task {}] Unable to create audio file '{}': {}",
|
||||
task_id,
|
||||
file_path.display(),
|
||||
e
|
||||
);
|
||||
VoiceCliError::Storage(format!("无法创建音频文件 '{}': {}", file_path.display(), e))
|
||||
})?;
|
||||
|
||||
// 创建缓冲写入器以提高性能
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
|
||||
// 将 field 转换为 StreamReader (实现 AsyncRead trait)
|
||||
let mut reader = tokio_util::io::StreamReader::new(
|
||||
field.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)),
|
||||
);
|
||||
|
||||
// 使用 tokio::io::copy 进行高效的流式复制
|
||||
let total_bytes = tokio::io::copy(&mut reader, &mut writer)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"[Task {}] Failed to stream audio file data ({} -> {}): {}",
|
||||
task_id,
|
||||
original_filename,
|
||||
file_path.display(),
|
||||
e
|
||||
);
|
||||
VoiceCliError::Storage(format!(
|
||||
"流式复制音频文件数据失败 ({} -> {}): {}",
|
||||
original_filename,
|
||||
file_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// 确保所有数据都写入磁盘
|
||||
writer.flush().await.map_err(|e| {
|
||||
error!(
|
||||
"[Task {}] Unable to refresh data to file '{}': {}",
|
||||
task_id,
|
||||
file_path.display(),
|
||||
e
|
||||
);
|
||||
VoiceCliError::Storage(format!(
|
||||
"无法刷新数据到文件 '{}': {}",
|
||||
file_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"[Task {}] Successfully received and saved audio file: {} ({} bytes) -> {}",
|
||||
task_id,
|
||||
original_filename,
|
||||
total_bytes,
|
||||
file_path.display()
|
||||
);
|
||||
|
||||
Ok(file_path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Delete an audio file from disk
|
||||
pub async fn delete_audio_file<P: AsRef<Path>>(
|
||||
&self,
|
||||
file_path: P,
|
||||
) -> Result<(), VoiceCliError> {
|
||||
let file_path = file_path.as_ref();
|
||||
|
||||
if file_path.exists() {
|
||||
tokio::fs::remove_file(file_path).await.map_err(|e| {
|
||||
VoiceCliError::Storage(format!(
|
||||
"Failed to delete audio file '{}': {}",
|
||||
file_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
info!("Deleted audio file: {}", file_path.display());
|
||||
} else {
|
||||
warn!("Audio file not found for deletion: {}", file_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete multiple audio files
|
||||
pub async fn delete_audio_files<P: AsRef<Path>>(
|
||||
&self,
|
||||
file_paths: &[P],
|
||||
) -> Result<(), VoiceCliError> {
|
||||
for file_path in file_paths {
|
||||
if let Err(e) = self.delete_audio_file(file_path).await {
|
||||
// Log error but continue with other files
|
||||
error!("Failed to delete audio file: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up old audio files based on age
|
||||
pub async fn cleanup_old_files(&self, max_age_hours: u64) -> Result<u32, VoiceCliError> {
|
||||
let mut cleaned_count = 0u32;
|
||||
let cutoff_time =
|
||||
std::time::SystemTime::now() - std::time::Duration::from_secs(max_age_hours * 3600);
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&self.storage_dir).await.map_err(|e| {
|
||||
VoiceCliError::Storage(format!(
|
||||
"Failed to read storage directory '{}': {}",
|
||||
self.storage_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("Failed to read directory entry: {}", e)))?
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
if let Ok(metadata) = entry.metadata().await {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if modified < cutoff_time {
|
||||
if let Err(e) = self.delete_audio_file(&path).await {
|
||||
error!("Failed to cleanup old file '{}': {}", path.display(), e);
|
||||
} else {
|
||||
cleaned_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned_count > 0 {
|
||||
info!("Cleaned up {} old audio files", cleaned_count);
|
||||
}
|
||||
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
|
||||
/// Get the size of a file
|
||||
pub async fn get_file_size<P: AsRef<Path>>(&self, file_path: P) -> Result<u64, VoiceCliError> {
|
||||
let metadata = tokio::fs::metadata(file_path.as_ref()).await.map_err(|e| {
|
||||
VoiceCliError::Storage(format!(
|
||||
"Failed to get file metadata for '{}': {}",
|
||||
file_path.as_ref().display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(metadata.len())
|
||||
}
|
||||
|
||||
/// Get total storage usage
|
||||
pub async fn get_storage_usage(&self) -> Result<u64, VoiceCliError> {
|
||||
let mut total_size = 0u64;
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&self.storage_dir).await.map_err(|e| {
|
||||
VoiceCliError::Storage(format!(
|
||||
"Failed to read storage directory '{}': {}",
|
||||
self.storage_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("Failed to read directory entry: {}", e)))?
|
||||
{
|
||||
if entry.path().is_file() {
|
||||
if let Ok(metadata) = entry.metadata().await {
|
||||
total_size += metadata.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
/// Check if a file exists
|
||||
pub async fn file_exists<P: AsRef<Path>>(&self, file_path: P) -> bool {
|
||||
tokio::fs::metadata(file_path.as_ref()).await.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audio_file_manager_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = AudioFileManager::new(temp_dir.path()).unwrap();
|
||||
|
||||
assert_eq!(manager.storage_dir, temp_dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_delete_audio_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = AudioFileManager::new(temp_dir.path()).unwrap();
|
||||
|
||||
let audio_data = Bytes::from(vec![1, 2, 3, 4, 5]);
|
||||
let task_id = "test-task-123";
|
||||
let original_filename = "test.mp3";
|
||||
|
||||
// Save file
|
||||
let file_path = manager
|
||||
.save_audio_file(task_id, &audio_data, original_filename)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Check file exists
|
||||
assert!(manager.file_exists(&file_path).await);
|
||||
|
||||
// Check file size
|
||||
let size = manager.get_file_size(&file_path).await.unwrap();
|
||||
assert_eq!(size, 5);
|
||||
|
||||
// Delete file
|
||||
manager.delete_audio_file(&file_path).await.unwrap();
|
||||
|
||||
// Check file no longer exists
|
||||
assert!(!manager.file_exists(&file_path).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_old_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = AudioFileManager::new(temp_dir.path()).unwrap();
|
||||
|
||||
let audio_data = Bytes::from(vec![1, 2, 3, 4, 5]);
|
||||
|
||||
// Save a file
|
||||
let file_path = manager
|
||||
.save_audio_file("test-task", &audio_data, "test.mp3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// File should exist
|
||||
assert!(manager.file_exists(&file_path).await);
|
||||
|
||||
// Cleanup files older than 0 hours (should clean everything)
|
||||
let cleaned = manager.cleanup_old_files(0).await.unwrap();
|
||||
|
||||
// Should have cleaned at least 1 file
|
||||
assert!(cleaned >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_usage() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = AudioFileManager::new(temp_dir.path()).unwrap();
|
||||
|
||||
// Initially should be 0
|
||||
let initial_usage = manager.get_storage_usage().await.unwrap();
|
||||
assert_eq!(initial_usage, 0);
|
||||
|
||||
// Save a file
|
||||
let audio_data = Bytes::from(vec![1, 2, 3, 4, 5]);
|
||||
let _file_path = manager
|
||||
.save_audio_file("test-task", &audio_data, "test.mp3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Usage should increase
|
||||
let usage_after = manager.get_storage_usage().await.unwrap();
|
||||
assert_eq!(usage_after, 5);
|
||||
}
|
||||
}
|
||||
267
qiming-mcp-proxy/voice-cli/src/services/audio_format_detector.rs
Normal file
267
qiming-mcp-proxy/voice-cli/src/services/audio_format_detector.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use bytes::Bytes;
|
||||
use infer::{self, Type};
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
use symphonia::core::formats::{FormatReader, Track};
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::core::probe::ProbeResult;
|
||||
use symphonia::default::get_probe;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::error::VoiceCliError;
|
||||
use crate::models::request::{AudioFormat, AudioFormatResult, AudioMetadata, DetectionMethod};
|
||||
|
||||
/// Service for intelligent audio format detection using Symphonia
|
||||
pub struct AudioFormatDetector;
|
||||
|
||||
impl AudioFormatDetector {
|
||||
/// Detect audio format using infer library (magic number detection)
|
||||
pub fn detect_format_from_path(path: &Path) -> anyhow::Result<Option<Type>> {
|
||||
let kind = infer::get_from_path(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file for format detection: {}", e))?;
|
||||
Ok(kind)
|
||||
}
|
||||
|
||||
/// Detect audio format using Symphonia probe with fallback to infer (magic number detection) and filename extension
|
||||
pub fn detect_format(
|
||||
audio_data: &Bytes,
|
||||
filename: Option<&str>,
|
||||
) -> Result<AudioFormatResult, VoiceCliError> {
|
||||
info!(
|
||||
"Starting audio format detection for {} byte audio data",
|
||||
audio_data.len()
|
||||
);
|
||||
|
||||
// Try Symphonia probe first (primary method)
|
||||
if let Ok(result) = Self::symphonia_probe(audio_data, filename) {
|
||||
info!(
|
||||
"Successfully detected format using Symphonia probe: {:?}",
|
||||
result.format
|
||||
);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Fallback to filename extension if provided
|
||||
if let Some(filename) = filename {
|
||||
let format = AudioFormat::from_filename(filename);
|
||||
if format.is_supported() {
|
||||
info!("Format detected from filename extension: {:?}", format);
|
||||
return Ok(AudioFormatResult {
|
||||
format,
|
||||
confidence: 0.5, // Lower confidence for filename-based detection
|
||||
metadata: None,
|
||||
detection_method: DetectionMethod::FileExtension,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// All detection methods failed
|
||||
error!("All audio format detection methods failed");
|
||||
Err(VoiceCliError::UnsupportedFormat(
|
||||
"Unable to detect audio format using any available method".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Primary detection method using Symphonia
|
||||
fn symphonia_probe(
|
||||
audio_data: &Bytes,
|
||||
filename: Option<&str>,
|
||||
) -> Result<AudioFormatResult, VoiceCliError> {
|
||||
// Create a cursor from copied audio data to avoid lifetime issues
|
||||
let data_copy = audio_data.to_vec();
|
||||
let cursor = Cursor::new(data_copy);
|
||||
let media_source = MediaSourceStream::new(Box::new(cursor), Default::default());
|
||||
|
||||
// Create a hint based on filename if available
|
||||
let mut hint = Hint::new();
|
||||
if let Some(filename) = filename {
|
||||
if let Some(extension) = std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
{
|
||||
hint.with_extension(extension);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the default probe
|
||||
let probe = get_probe();
|
||||
|
||||
// Attempt to probe the media source
|
||||
let probe_result = probe
|
||||
.format(
|
||||
&hint,
|
||||
media_source,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
warn!("Symphonia probe failed: {}", e);
|
||||
VoiceCliError::UnsupportedFormat(format!("Symphonia probe error: {}", e))
|
||||
})?;
|
||||
|
||||
// Extract format information
|
||||
let format_info = Self::extract_format_info(&probe_result)?;
|
||||
|
||||
Ok(format_info)
|
||||
}
|
||||
|
||||
/// Extract format and metadata information from Symphonia probe result
|
||||
fn extract_format_info(probe_result: &ProbeResult) -> Result<AudioFormatResult, VoiceCliError> {
|
||||
let reader = &probe_result.format;
|
||||
let tracks = reader.tracks();
|
||||
|
||||
// Find the first audio track (any track with codec parameters)
|
||||
let track = tracks
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| {
|
||||
VoiceCliError::UnsupportedFormat("No audio tracks found in file".to_string())
|
||||
})?;
|
||||
|
||||
// Convert codec type to our AudioFormat
|
||||
let format = AudioFormat::from_symphonia_codec(track.codec_params.codec);
|
||||
|
||||
// Extract metadata
|
||||
let metadata = Self::extract_metadata(track, reader);
|
||||
|
||||
// Calculate confidence based on detection success
|
||||
let confidence = if format != AudioFormat::Unknown {
|
||||
0.95
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if format == AudioFormat::Unknown {
|
||||
return Err(VoiceCliError::UnsupportedFormat(format!(
|
||||
"Unsupported codec type: {:?}",
|
||||
track.codec_params.codec
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(AudioFormatResult {
|
||||
format,
|
||||
confidence,
|
||||
metadata: Some(metadata),
|
||||
detection_method: DetectionMethod::SymphoniaProbe,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract detailed audio metadata from track and format reader
|
||||
fn extract_metadata(track: &Track, _reader: &Box<dyn FormatReader>) -> AudioMetadata {
|
||||
let codec_params = &track.codec_params;
|
||||
|
||||
// Extract basic parameters
|
||||
let sample_rate = codec_params.sample_rate;
|
||||
let channels = codec_params.channels.map(|ch| ch.count() as u8);
|
||||
let bit_depth = codec_params.bits_per_sample.map(|bits| bits as u8);
|
||||
|
||||
// Calculate duration if time base and n_frames are available
|
||||
let duration = if let (Some(time_base), Some(n_frames)) =
|
||||
(codec_params.time_base, codec_params.n_frames)
|
||||
{
|
||||
let duration_secs = n_frames as f64 * time_base.numer as f64 / time_base.denom as f64;
|
||||
Some(std::time::Duration::from_secs_f64(duration_secs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Estimate bitrate if possible
|
||||
let bitrate = if let (Some(sample_rate), Some(channels), Some(bit_depth)) =
|
||||
(sample_rate, channels, bit_depth)
|
||||
{
|
||||
Some(sample_rate * channels as u32 * bit_depth as u32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create codec info string
|
||||
let codec_info = format!("Codec: {:?}", codec_params.codec);
|
||||
|
||||
AudioMetadata {
|
||||
duration,
|
||||
sample_rate,
|
||||
channels,
|
||||
bit_depth,
|
||||
bitrate,
|
||||
codec_info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that the detected format is supported for transcription
|
||||
pub fn validate_format_support(format_result: &AudioFormatResult) -> Result<(), VoiceCliError> {
|
||||
if !format_result.format.is_supported() {
|
||||
return Err(VoiceCliError::UnsupportedFormat(format!(
|
||||
"Format {} is not supported for transcription",
|
||||
format_result.format.to_string()
|
||||
)));
|
||||
}
|
||||
|
||||
// Check confidence threshold
|
||||
if format_result.confidence < 0.3 {
|
||||
warn!(
|
||||
"Low confidence format detection: {}",
|
||||
format_result.confidence
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Import FormatOptions for compilation
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_detection_with_filename() {
|
||||
let test_data = Bytes::from(vec![0u8; 1024]); // Dummy data
|
||||
|
||||
// Test filename-based fallback
|
||||
let result = AudioFormatDetector::detect_format(&test_data, Some("test.mp3"));
|
||||
match result {
|
||||
Ok(format_result) => {
|
||||
assert_eq!(
|
||||
format_result.detection_method,
|
||||
DetectionMethod::FileExtension
|
||||
);
|
||||
assert_eq!(format_result.format, AudioFormat::Mp3);
|
||||
}
|
||||
Err(_) => {
|
||||
// Expected for dummy data, but we should get filename-based detection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_format() {
|
||||
let test_data = Bytes::from(vec![0u8; 1024]);
|
||||
let result = AudioFormatDetector::detect_format(&test_data, Some("test.xyz"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_validation() {
|
||||
let format_result = AudioFormatResult {
|
||||
format: AudioFormat::Mp3,
|
||||
confidence: 0.9,
|
||||
metadata: None,
|
||||
detection_method: DetectionMethod::SymphoniaProbe,
|
||||
};
|
||||
|
||||
assert!(AudioFormatDetector::validate_format_support(&format_result).is_ok());
|
||||
|
||||
let unsupported_result = AudioFormatResult {
|
||||
format: AudioFormat::Unknown,
|
||||
confidence: 0.9,
|
||||
metadata: None,
|
||||
detection_method: DetectionMethod::SymphoniaProbe,
|
||||
};
|
||||
|
||||
assert!(AudioFormatDetector::validate_format_support(&unsupported_result).is_err());
|
||||
}
|
||||
}
|
||||
314
qiming-mcp-proxy/voice-cli/src/services/audio_processor.rs
Normal file
314
qiming-mcp-proxy/voice-cli/src/services/audio_processor.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::AudioFormat;
|
||||
use crate::models::request::ProcessedAudio;
|
||||
use bytes::Bytes;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AudioProcessor {
|
||||
temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn new(temp_dir: Option<PathBuf>) -> Self {
|
||||
let temp_dir = temp_dir.unwrap_or_else(|| std::env::temp_dir().join("voice-cli"));
|
||||
|
||||
// Ensure temp directory exists
|
||||
if let Err(e) = std::fs::create_dir_all(&temp_dir) {
|
||||
warn!("Failed to create temp directory {:?}: {}", temp_dir, e);
|
||||
}
|
||||
|
||||
Self { temp_dir }
|
||||
}
|
||||
|
||||
/// Process audio data and convert to whisper-compatible format if needed
|
||||
pub async fn process_audio(
|
||||
&self,
|
||||
audio_data: Bytes,
|
||||
filename: Option<&str>,
|
||||
) -> Result<ProcessedAudio, VoiceCliError> {
|
||||
debug!(
|
||||
"Processing audio data: {} bytes, filename: {:?}",
|
||||
audio_data.len(),
|
||||
filename
|
||||
);
|
||||
|
||||
// Detect audio format
|
||||
let format = self.detect_audio_format(&audio_data, filename)?;
|
||||
debug!("Detected audio format: {:?}", format);
|
||||
|
||||
// For WAV format, validate the content since it doesn't need conversion
|
||||
if format == AudioFormat::Wav {
|
||||
self.validate_whisper_format(&audio_data)?;
|
||||
}
|
||||
|
||||
// Check if conversion is needed
|
||||
if !format.needs_conversion() {
|
||||
debug!("Audio format is already compatible, no conversion needed");
|
||||
return Ok(ProcessedAudio {
|
||||
data: audio_data,
|
||||
converted: false,
|
||||
original_format: Some(format.to_string().to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to Whisper-compatible format
|
||||
info!("Converting audio from {} to WAV format", format.to_string());
|
||||
let converted_data = self.convert_to_whisper_format(audio_data, format).await?;
|
||||
|
||||
Ok(ProcessedAudio {
|
||||
data: converted_data,
|
||||
converted: true,
|
||||
original_format: Some(format.to_string().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect audio format from data and filename
|
||||
fn detect_audio_format(
|
||||
&self,
|
||||
audio_data: &Bytes,
|
||||
filename: Option<&str>,
|
||||
) -> Result<AudioFormat, VoiceCliError> {
|
||||
// Use AudioFormatDetector for enhanced detection
|
||||
use crate::services::AudioFormatDetector;
|
||||
|
||||
match AudioFormatDetector::detect_format(audio_data, filename) {
|
||||
Ok(format_result) => {
|
||||
// Validate format support
|
||||
AudioFormatDetector::validate_format_support(&format_result)?;
|
||||
Ok(format_result.format)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert audio to Whisper-compatible format (16kHz, mono, 16-bit PCM WAV)
|
||||
async fn convert_to_whisper_format(
|
||||
&self,
|
||||
audio_data: Bytes,
|
||||
source_format: AudioFormat,
|
||||
) -> Result<Bytes, VoiceCliError> {
|
||||
debug!("Converting {} to WAV format", source_format.to_string());
|
||||
|
||||
// Create temporary files for input and output
|
||||
let input_file = self.create_temp_file(&audio_data, &source_format)?;
|
||||
let output_file = NamedTempFile::new_in(&self.temp_dir).map_err(|e| {
|
||||
VoiceCliError::AudioProcessing(format!("Failed to create temp output file: {}", e))
|
||||
})?;
|
||||
|
||||
let input_path = input_file.path();
|
||||
let output_path = output_file.path();
|
||||
|
||||
// Try to use rs-voice-toolkit for conversion
|
||||
match self
|
||||
.convert_with_rs_voice_toolkit(input_path, output_path)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Read converted file
|
||||
let converted_data = std::fs::read(output_path).map_err(|e| {
|
||||
VoiceCliError::AudioProcessing(format!("Failed to read converted file: {}", e))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Successfully converted audio: {} -> {} bytes",
|
||||
audio_data.len(),
|
||||
converted_data.len()
|
||||
);
|
||||
Ok(Bytes::from(converted_data))
|
||||
}
|
||||
Err(toolkit_error) => {
|
||||
warn!(
|
||||
"rs-voice-toolkit conversion failed: {}, trying fallback",
|
||||
toolkit_error
|
||||
);
|
||||
|
||||
return Err(toolkit_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert using rs-voice-toolkit
|
||||
async fn convert_with_rs_voice_toolkit(
|
||||
&self,
|
||||
input_path: &std::path::Path,
|
||||
output_path: &std::path::Path,
|
||||
) -> Result<(), VoiceCliError> {
|
||||
debug!(
|
||||
"Converting audio using voice-toolkit: {:?} -> {:?}",
|
||||
input_path, output_path
|
||||
);
|
||||
|
||||
// Use voice_toolkit::audio::ensure_whisper_compatible for real audio conversion
|
||||
match voice_toolkit::audio::ensure_whisper_compatible(
|
||||
input_path,
|
||||
Some(output_path.to_path_buf()),
|
||||
) {
|
||||
Ok(compatible_wav) => {
|
||||
debug!(
|
||||
"Successfully converted audio to whisper-compatible format: {:?}",
|
||||
compatible_wav.path
|
||||
);
|
||||
|
||||
// Verify the output file exists and is at the expected location
|
||||
if compatible_wav.path != output_path {
|
||||
// If the output is in a different location, move it to the expected location
|
||||
if let Err(e) = std::fs::rename(&compatible_wav.path, output_path) {
|
||||
warn!("Failed to move converted file to expected location: {}", e);
|
||||
// Try copying instead
|
||||
std::fs::copy(&compatible_wav.path, output_path).map_err(|e| {
|
||||
VoiceCliError::AudioProcessing(format!(
|
||||
"Failed to copy converted file: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
// Clean up the original if copy succeeded
|
||||
let _ = std::fs::remove_file(&compatible_wav.path);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Audio conversion completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("voice-toolkit conversion failed: {}", e);
|
||||
Err(VoiceCliError::AudioProcessing(format!(
|
||||
"Audio conversion failed: {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create temporary file with audio data
|
||||
fn create_temp_file(
|
||||
&self,
|
||||
audio_data: &Bytes,
|
||||
format: &AudioFormat,
|
||||
) -> Result<NamedTempFile, VoiceCliError> {
|
||||
let extension = format.to_string();
|
||||
let mut temp_file =
|
||||
NamedTempFile::with_suffix_in(&format!(".{}", extension), &self.temp_dir).map_err(
|
||||
|e| VoiceCliError::AudioProcessing(format!("Failed to create temp file: {}", e)),
|
||||
)?;
|
||||
|
||||
temp_file.write_all(audio_data).map_err(|e| {
|
||||
VoiceCliError::AudioProcessing(format!("Failed to write temp file: {}", e))
|
||||
})?;
|
||||
|
||||
temp_file.flush().map_err(|e| {
|
||||
VoiceCliError::AudioProcessing(format!("Failed to flush temp file: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(temp_file)
|
||||
}
|
||||
|
||||
/// Validate that the processed audio is in the correct format for Whisper
|
||||
pub fn validate_whisper_format(&self, audio_data: &Bytes) -> Result<(), VoiceCliError> {
|
||||
// Basic WAV header validation
|
||||
if audio_data.len() < 44 {
|
||||
return Err(VoiceCliError::AudioProcessing(
|
||||
"Audio file too small to be valid WAV".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let header = &audio_data[0..44];
|
||||
|
||||
// Check RIFF header
|
||||
if &header[0..4] != b"RIFF" {
|
||||
return Err(VoiceCliError::AudioProcessing(
|
||||
"Invalid WAV file: missing RIFF header".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check WAVE format
|
||||
if &header[8..12] != b"WAVE" {
|
||||
return Err(VoiceCliError::AudioProcessing(
|
||||
"Invalid WAV file: missing WAVE format".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check fmt chunk
|
||||
if &header[12..16] != b"fmt " {
|
||||
return Err(VoiceCliError::AudioProcessing(
|
||||
"Invalid WAV file: missing fmt chunk".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Extract audio parameters
|
||||
let sample_rate = u32::from_le_bytes([header[24], header[25], header[26], header[27]]);
|
||||
let channels = u16::from_le_bytes([header[22], header[23]]);
|
||||
let bits_per_sample = u16::from_le_bytes([header[34], header[35]]);
|
||||
|
||||
debug!(
|
||||
"WAV format - Sample rate: {}Hz, Channels: {}, Bits: {}",
|
||||
sample_rate, channels, bits_per_sample
|
||||
);
|
||||
|
||||
// Whisper prefers 16kHz, mono, 16-bit, but can handle other formats too
|
||||
// We'll just warn for non-optimal formats rather than error
|
||||
if sample_rate != 16000 {
|
||||
warn!(
|
||||
"Non-optimal sample rate: {}Hz (Whisper prefers 16kHz)",
|
||||
sample_rate
|
||||
);
|
||||
}
|
||||
|
||||
if channels != 1 {
|
||||
warn!(
|
||||
"Non-optimal channel count: {} (Whisper prefers mono)",
|
||||
channels
|
||||
);
|
||||
}
|
||||
|
||||
if bits_per_sample != 16 {
|
||||
warn!(
|
||||
"Non-optimal bit depth: {} (Whisper prefers 16-bit)",
|
||||
bits_per_sample
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audio_processor_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let processor = AudioProcessor::new(Some(temp_dir.path().to_path_buf()));
|
||||
|
||||
// Test with mock WAV data (basic WAV header)
|
||||
let wav_header = b"RIFF\x24\x00\x00\x00WAVE";
|
||||
let audio_data = Bytes::from_static(wav_header);
|
||||
|
||||
let format = processor.detect_audio_format(&audio_data, Some("test.wav"));
|
||||
assert!(matches!(format, Ok(AudioFormat::Wav)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_format_detection() {
|
||||
let processor = AudioProcessor::new(None);
|
||||
|
||||
// Test WAV detection from filename
|
||||
let wav_data = Bytes::from_static(b"RIFF\x24\x00\x00\x00WAVE");
|
||||
assert!(matches!(
|
||||
processor.detect_audio_format(&wav_data, Some("test.wav")),
|
||||
Ok(AudioFormat::Wav)
|
||||
));
|
||||
|
||||
// Test MP3 detection from filename
|
||||
let mp3_data = Bytes::from_static(b"\xFF\xFB\x90\x00");
|
||||
assert!(matches!(
|
||||
processor.detect_audio_format(&mp3_data, Some("test.mp3")),
|
||||
Ok(AudioFormat::Mp3)
|
||||
));
|
||||
}
|
||||
}
|
||||
367
qiming-mcp-proxy/voice-cli/src/services/metadata_extractor.rs
Normal file
367
qiming-mcp-proxy/voice-cli/src/services/metadata_extractor.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
use crate::VoiceCliError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::Metadata;
|
||||
use std::path::Path;
|
||||
use tokio::{fs, task};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// 音视频元数据信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioVideoMetadata {
|
||||
// 基础信息
|
||||
pub format: String, // 文件格式 (mp3, wav, mp4, etc.)
|
||||
pub container_format: String, // 容器格式
|
||||
pub duration_seconds: f64, // 时长(秒)
|
||||
pub file_size_bytes: u64, // 文件大小
|
||||
|
||||
// 音频信息
|
||||
pub audio_codec: String, // 音频编码器
|
||||
pub sample_rate: u32, // 采样率 (Hz)
|
||||
pub channels: u8, // 声道数
|
||||
pub audio_bitrate: u32, // 音频码率 (kbps)
|
||||
|
||||
// 视频信息(如果是视频文件)
|
||||
pub has_video: bool, // 是否包含视频
|
||||
pub video_codec: Option<String>, // 视频编码器
|
||||
pub width: Option<u32>, // 视频宽度
|
||||
pub height: Option<u32>, // 视频高度
|
||||
pub video_bitrate: Option<u32>, // 视频码率 (kbps)
|
||||
pub frame_rate: Option<f64>, // 帧率
|
||||
|
||||
// 其他元数据
|
||||
pub bitrate: u32, // 总码率 (kbps)
|
||||
pub creation_time: Option<String>, // 创建时间
|
||||
}
|
||||
|
||||
impl Default for AudioVideoMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: "unknown".to_string(),
|
||||
container_format: "unknown".to_string(),
|
||||
duration_seconds: 0.0,
|
||||
file_size_bytes: 0,
|
||||
audio_codec: "unknown".to_string(),
|
||||
sample_rate: 0,
|
||||
channels: 1,
|
||||
audio_bitrate: 0,
|
||||
has_video: false,
|
||||
video_codec: None,
|
||||
width: None,
|
||||
height: None,
|
||||
video_bitrate: None,
|
||||
frame_rate: None,
|
||||
bitrate: 0,
|
||||
creation_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 音视频元数据提取器
|
||||
pub struct MetadataExtractor;
|
||||
|
||||
impl MetadataExtractor {
|
||||
/// 从文件路径提取音视频元数据
|
||||
pub async fn extract_metadata(file_path: &Path) -> Result<AudioVideoMetadata, VoiceCliError> {
|
||||
info!("Start extracting audio and video metadata: {:?}", file_path);
|
||||
|
||||
// 首先获取文件基本信息
|
||||
let file_metadata = fs::metadata(file_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("无法访问文件: {}", e)))?;
|
||||
|
||||
let _file_size = file_metadata.len();
|
||||
let file_extension = file_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_lowercase();
|
||||
|
||||
// 尝试使用 FFmpeg 提取详细元数据
|
||||
if let Ok(ffmpeg_metadata) = Self::extract_with_ffmpeg(file_path).await {
|
||||
info!("FFmpeg metadata extraction successful");
|
||||
return Ok(ffmpeg_metadata);
|
||||
}
|
||||
|
||||
// 如果 FFmpeg 不可用或失败,使用基础方法
|
||||
warn!("FFmpeg is unavailable or failed, use base metadata extraction method");
|
||||
Self::extract_basic_metadata(file_path, &file_metadata, &file_extension).await
|
||||
}
|
||||
|
||||
/// 使用 FFmpeg 提取详细元数据
|
||||
async fn extract_with_ffmpeg(file_path: &Path) -> Result<AudioVideoMetadata, VoiceCliError> {
|
||||
use ffmpeg_sidecar::command::FfmpegCommand;
|
||||
|
||||
debug!("Extract metadata using FFmpeg: {:?}", file_path);
|
||||
|
||||
let file_path_buf = file_path.to_path_buf();
|
||||
|
||||
let metadata =
|
||||
task::spawn_blocking(move || -> Result<AudioVideoMetadata, VoiceCliError> {
|
||||
let mut metadata = AudioVideoMetadata::default();
|
||||
let file_path_str = file_path_buf.to_string_lossy().to_string();
|
||||
|
||||
// 使用 FfmpegCommand 获取文件信息
|
||||
let mut child = FfmpegCommand::new()
|
||||
.arg("-i")
|
||||
.arg(&file_path_str)
|
||||
.arg("-hide_banner")
|
||||
.spawn()
|
||||
.map_err(|e| VoiceCliError::Storage(format!("FFmpeg 执行失败: {}", e)))?;
|
||||
|
||||
// 等待命令完成(在阻塞线程中执行)
|
||||
let _exit_status = child
|
||||
.wait()
|
||||
.map_err(|e| VoiceCliError::Storage(format!("FFmpeg 执行失败: {}", e)))?;
|
||||
|
||||
// 使用传统方法获取输出(因为 ffmpeg-sidecar 主要用于处理媒体流,不是元数据提取)
|
||||
let output = std::process::Command::new("ffmpeg")
|
||||
.args(["-i", &file_path_str, "-hide_banner", "-f", "null", "-"])
|
||||
.output()
|
||||
.map_err(|e| VoiceCliError::Storage(format!("FFmpeg 执行失败: {}", e)))?;
|
||||
|
||||
// 解析 stderr 输出中的元数据信息
|
||||
let stderr_output = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// 解析输出中的元数据信息
|
||||
for line in stderr_output.lines() {
|
||||
if line.contains("Duration:") {
|
||||
// 解析时长: Duration: 00:00:01.60, start: 0.000000, bitrate: 705 kb/s
|
||||
if let Some(duration_part) = line.split("Duration: ").nth(1) {
|
||||
if let Some(duration_str) = duration_part.split(',').next() {
|
||||
let parts: Vec<&str> = duration_str.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let hours: f64 = parts[0].parse().unwrap_or(0.0);
|
||||
let minutes: f64 = parts[1].parse().unwrap_or(0.0);
|
||||
let seconds: f64 = parts[2].parse().unwrap_or(0.0);
|
||||
metadata.duration_seconds =
|
||||
hours * 3600.0 + minutes * 60.0 + seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if line.contains("Audio:") {
|
||||
// 解析音频信息: Stream #0:0: Audio: pcm_f32le, 22050 Hz, mono, fltp, 705 kb/s
|
||||
let audio_info = line.split("Audio: ").nth(1).unwrap_or("");
|
||||
let parts: Vec<&str> = audio_info.split(',').collect();
|
||||
|
||||
if let Some(codec) = parts.first() {
|
||||
metadata.audio_codec = codec.trim().to_string();
|
||||
}
|
||||
|
||||
for part in parts {
|
||||
if part.contains("Hz") {
|
||||
if let Some(rate_str) = part.split("Hz").next() {
|
||||
metadata.sample_rate = rate_str.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
if part.contains("mono") {
|
||||
metadata.channels = 1;
|
||||
}
|
||||
if part.contains("stereo") {
|
||||
metadata.channels = 2;
|
||||
}
|
||||
if part.contains("kb/s") {
|
||||
if let Some(bitrate_str) = part.split("kb/s").next() {
|
||||
metadata.audio_bitrate =
|
||||
bitrate_str.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if line.contains("Video:") {
|
||||
// 解析视频信息: Stream #0:1: Video: h264, yuv420p, 1280x720, 24 fps, 1992 kb/s
|
||||
metadata.has_video = true;
|
||||
let video_info = line.split("Video: ").nth(1).unwrap_or("");
|
||||
let parts: Vec<&str> = video_info.split(',').collect();
|
||||
|
||||
if let Some(codec) = parts.first() {
|
||||
metadata.video_codec = Some(codec.trim().to_string());
|
||||
}
|
||||
|
||||
for part in parts {
|
||||
if part.contains('x') {
|
||||
let resolution_parts: Vec<&str> = part.trim().split('x').collect();
|
||||
if resolution_parts.len() == 2 {
|
||||
metadata.width = resolution_parts[0].trim().parse().ok();
|
||||
metadata.height = resolution_parts[1].trim().parse().ok();
|
||||
}
|
||||
}
|
||||
if part.contains("fps") {
|
||||
if let Some(fps_str) = part.split("fps").next() {
|
||||
metadata.frame_rate = fps_str.trim().parse().ok();
|
||||
}
|
||||
}
|
||||
if part.contains("kb/s") {
|
||||
if let Some(bitrate_str) = part.split("kb/s").next() {
|
||||
metadata.video_bitrate =
|
||||
Some(bitrate_str.trim().parse().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件大小
|
||||
if let Ok(file_meta) = std::fs::metadata(&file_path_buf) {
|
||||
metadata.file_size_bytes = file_meta.len();
|
||||
}
|
||||
|
||||
// 如果没有从输出中获取到码率,计算总码率
|
||||
if metadata.bitrate == 0
|
||||
&& metadata.duration_seconds > 0.0
|
||||
&& metadata.file_size_bytes > 0
|
||||
{
|
||||
let total_bits = metadata.file_size_bytes as f64 * 8.0;
|
||||
metadata.bitrate = (total_bits / metadata.duration_seconds / 1000.0) as u32;
|
||||
}
|
||||
|
||||
// 根据文件扩展名设置格式
|
||||
if let Some(extension) = file_path_buf.extension().and_then(|ext| ext.to_str()) {
|
||||
metadata.format = extension.to_lowercase();
|
||||
metadata.container_format = extension.to_lowercase();
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("FFmpeg 阻塞任务失败: {}", e)))??;
|
||||
|
||||
info!("FFmpeg metadata extraction completed: {:?}", metadata);
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// 基础元数据提取(不依赖 FFmpeg)
|
||||
async fn extract_basic_metadata(
|
||||
file_path: &Path,
|
||||
file_metadata: &Metadata,
|
||||
file_extension: &str,
|
||||
) -> Result<AudioVideoMetadata, VoiceCliError> {
|
||||
debug!("Extract metadata using basic method: {:?}", file_path);
|
||||
|
||||
let mut metadata = AudioVideoMetadata {
|
||||
file_size_bytes: file_metadata.len(),
|
||||
format: file_extension.to_string(),
|
||||
container_format: file_extension.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 尝试使用现有的 AudioFormatDetector 获取音频信息
|
||||
if let Ok(Some(format_type)) =
|
||||
crate::services::AudioFormatDetector::detect_format_from_path(file_path)
|
||||
{
|
||||
metadata.format = format_type.extension().to_string();
|
||||
metadata.audio_codec = format_type.mime_type().to_string();
|
||||
}
|
||||
|
||||
// 判断是否为视频文件
|
||||
metadata.has_video = Self::is_video_format(file_extension);
|
||||
|
||||
// 如果是视频文件,设置默认值
|
||||
if metadata.has_video {
|
||||
metadata.video_codec = Some("unknown".to_string());
|
||||
}
|
||||
|
||||
// 计算码率
|
||||
if metadata.duration_seconds > 0.0 && metadata.file_size_bytes > 0 {
|
||||
let total_bits = metadata.file_size_bytes as f64 * 8.0;
|
||||
metadata.bitrate = (total_bits / metadata.duration_seconds / 1000.0) as u32;
|
||||
}
|
||||
|
||||
info!("Basic metadata extraction completed: {:?}", metadata);
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// 判断是否为视频格式
|
||||
fn is_video_format(extension: &str) -> bool {
|
||||
matches!(
|
||||
extension,
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" | "3gp" | "mpg" | "mpeg"
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取文件格式描述
|
||||
pub fn get_format_description(metadata: &AudioVideoMetadata) -> String {
|
||||
if metadata.has_video {
|
||||
format!(
|
||||
"视频文件 - 格式: {}, 分辨率: {}x{}, 时长: {:.2}s",
|
||||
metadata.format,
|
||||
metadata.width.unwrap_or(0),
|
||||
metadata.height.unwrap_or(0),
|
||||
metadata.duration_seconds
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"音频文件 - 格式: {}, 采样率: {}Hz, 声道: {}, 时长: {:.2}s",
|
||||
metadata.format, metadata.sample_rate, metadata.channels, metadata.duration_seconds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_basic_metadata() {
|
||||
// 创建一个临时文件进行测试
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
temp_file.write_all(b"dummy audio data").unwrap();
|
||||
temp_file.flush().unwrap();
|
||||
|
||||
let metadata = MetadataExtractor::extract_metadata(temp_file.path()).await;
|
||||
|
||||
// 验证基本结构
|
||||
match metadata {
|
||||
Ok(meta) => {
|
||||
assert!(!meta.format.is_empty());
|
||||
assert_eq!(meta.file_size_bytes, 16); // "dummy audio data" 的长度
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to extract: {}", e);
|
||||
// 对于测试环境,FFmpeg 可能不可用,这是可以接受的
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_video_format() {
|
||||
assert!(MetadataExtractor::is_video_format("mp4"));
|
||||
assert!(MetadataExtractor::is_video_format("avi"));
|
||||
assert!(!MetadataExtractor::is_video_format("mp3"));
|
||||
assert!(!MetadataExtractor::is_video_format("wav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_format_description() {
|
||||
let audio_meta = AudioVideoMetadata {
|
||||
format: "mp3".to_string(),
|
||||
sample_rate: 44100,
|
||||
channels: 2,
|
||||
duration_seconds: 180.5,
|
||||
has_video: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let video_meta = AudioVideoMetadata {
|
||||
format: "mp4".to_string(),
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
duration_seconds: 120.0,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let audio_desc = MetadataExtractor::get_format_description(&audio_meta);
|
||||
let video_desc = MetadataExtractor::get_format_description(&video_meta);
|
||||
|
||||
assert!(audio_desc.contains("音频文件"));
|
||||
assert!(audio_desc.contains("44100Hz"));
|
||||
assert!(video_desc.contains("视频文件"));
|
||||
assert!(video_desc.contains("1920x1080"));
|
||||
}
|
||||
}
|
||||
24
qiming-mcp-proxy/voice-cli/src/services/mod.rs
Normal file
24
qiming-mcp-proxy/voice-cli/src/services/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
pub mod apalis_manager;
|
||||
pub mod audio_file_manager;
|
||||
pub mod audio_format_detector;
|
||||
pub mod audio_processor;
|
||||
pub mod metadata_extractor;
|
||||
pub mod model_service;
|
||||
pub mod transcription_engine;
|
||||
pub mod tts_service;
|
||||
pub mod tts_task_manager;
|
||||
|
||||
// 重新导出核心服务
|
||||
pub use apalis_manager::{
|
||||
ApalisManager, LockFreeApalisManager, StepContext, TaskStatusUpdate, TranscriptionTask,
|
||||
init_global_apalis_manager, init_global_lock_free_apalis_manager,
|
||||
transcription_pipeline_worker,
|
||||
};
|
||||
pub use audio_file_manager::AudioFileManager;
|
||||
pub use audio_format_detector::AudioFormatDetector;
|
||||
pub use audio_processor::AudioProcessor;
|
||||
pub use metadata_extractor::{AudioVideoMetadata, MetadataExtractor};
|
||||
pub use model_service::ModelService;
|
||||
pub use transcription_engine::TranscriptionEngine;
|
||||
pub use tts_service::TtsService;
|
||||
pub use tts_task_manager::{TtsTaskManager, TtsTaskStats};
|
||||
524
qiming-mcp-proxy/voice-cli/src/services/model_service.rs
Normal file
524
qiming-mcp-proxy/voice-cli/src/services/model_service.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::{Config, DownloadStatus, ModelDownloadStatus, ModelInfo};
|
||||
use reqwest::Client;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModelService {
|
||||
config: Config,
|
||||
client: Client,
|
||||
models_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ModelService {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
models_dir: config.models_dir_path(),
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default model name from configuration
|
||||
pub fn default_model(&self) -> &str {
|
||||
&self.config.whisper.default_model
|
||||
}
|
||||
|
||||
/// Get the worker timeout from configuration
|
||||
pub fn worker_timeout(&self) -> u64 {
|
||||
self.config.whisper.workers.worker_timeout as u64
|
||||
}
|
||||
|
||||
/// Ensure a model is available (download if necessary)
|
||||
pub async fn ensure_model(&self, model_name: &str) -> Result<(), VoiceCliError> {
|
||||
if self.is_model_downloaded(model_name).await? {
|
||||
debug!("Model '{}' already exists", model_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.config.whisper.auto_download {
|
||||
info!("Auto-downloading model: {}", model_name);
|
||||
self.download_model(model_name).await?;
|
||||
} else {
|
||||
return Err(VoiceCliError::ModelNotFound(format!(
|
||||
"Model '{}' not found and auto_download is disabled",
|
||||
model_name
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a whisper model from the official repository
|
||||
pub async fn download_model(&self, model_name: &str) -> Result<(), VoiceCliError> {
|
||||
if !self
|
||||
.config
|
||||
.whisper
|
||||
.supported_models
|
||||
.contains(&model_name.to_string())
|
||||
{
|
||||
return Err(VoiceCliError::InvalidModelName(format!(
|
||||
"Model '{}' is not supported",
|
||||
model_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Create models directory if it doesn't exist
|
||||
fs::create_dir_all(&self.models_dir).await?;
|
||||
|
||||
let model_path = self.get_model_path(model_name)?;
|
||||
|
||||
if model_path.exists() {
|
||||
info!("Model '{}' already exists at {:?}", model_name, model_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Downloading model '{}' from whisper.cpp repository...",
|
||||
model_name
|
||||
);
|
||||
|
||||
// Download from Hugging Face (official whisper.cpp models)
|
||||
let download_url = self.get_model_download_url(model_name)?;
|
||||
|
||||
debug!("Download URL: {}", download_url);
|
||||
|
||||
// Download with progress tracking
|
||||
let response = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to start download: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VoiceCliError::Model(format!(
|
||||
"Failed to download model: HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
info!("Downloading {} ({} bytes)...", model_name, total_size);
|
||||
|
||||
// Create temporary file
|
||||
let temp_path = model_path.with_extension("tmp");
|
||||
let mut file = fs::File::create(&temp_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to create file: {}", e)))?;
|
||||
|
||||
let mut downloaded = 0u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk =
|
||||
chunk.map_err(|e| VoiceCliError::Model(format!("Download error: {}", e)))?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
if total_size > 0 {
|
||||
let progress = (downloaded as f32 / total_size as f32) * 100.0;
|
||||
if downloaded % (1024 * 1024) == 0 {
|
||||
// Log every MB
|
||||
debug!(
|
||||
"Downloaded {:.1}% ({} / {} bytes)",
|
||||
progress, downloaded, total_size
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to flush file: {}", e)))?;
|
||||
|
||||
// Move temporary file to final location
|
||||
fs::rename(&temp_path, &model_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to finalize download: {}", e)))?;
|
||||
|
||||
info!(
|
||||
"Successfully downloaded model '{}' to {:?}",
|
||||
model_name, model_path
|
||||
);
|
||||
|
||||
// Basic validation: just check file exists and has reasonable size
|
||||
let metadata = fs::metadata(&model_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to check downloaded file: {}", e)))?;
|
||||
|
||||
if metadata.len() < 1024 {
|
||||
// Clean up the invalid file
|
||||
let _ = fs::remove_file(&model_path).await;
|
||||
return Err(VoiceCliError::Model(format!(
|
||||
"Downloaded model '{}' is too small ({} bytes), likely corrupted",
|
||||
model_name,
|
||||
metadata.len()
|
||||
)));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Model '{}' downloaded successfully - {} bytes",
|
||||
model_name,
|
||||
metadata.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the download URL for a specific model
|
||||
fn get_model_download_url(&self, model_name: &str) -> Result<String, VoiceCliError> {
|
||||
// Whisper.cpp models are hosted on Hugging Face under ggerganov organization
|
||||
let base_url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
|
||||
let model_filename = format!("ggml-{}.bin", model_name);
|
||||
Ok(format!("{}/{}", base_url, model_filename))
|
||||
}
|
||||
|
||||
/// Get the local path for a model file
|
||||
pub fn get_model_path(&self, model_name: &str) -> Result<PathBuf, VoiceCliError> {
|
||||
let filename = format!("ggml-{}.bin", model_name);
|
||||
Ok(self.models_dir.join(filename))
|
||||
}
|
||||
|
||||
/// Check if a model is downloaded locally
|
||||
pub async fn is_model_downloaded(&self, model_name: &str) -> Result<bool, VoiceCliError> {
|
||||
let model_path = self.get_model_path(model_name)?;
|
||||
Ok(model_path.exists())
|
||||
}
|
||||
|
||||
/// List all downloaded models
|
||||
pub async fn list_downloaded_models(&self) -> Result<Vec<String>, VoiceCliError> {
|
||||
if !self.models_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut models = Vec::new();
|
||||
let mut entries = fs::read_dir(&self.models_dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
|
||||
// Parse model name from filename (ggml-{model_name}.bin)
|
||||
if filename.starts_with("ggml-") && filename.ends_with(".bin") {
|
||||
let model_name = &filename[5..filename.len() - 4]; // Remove "ggml-" and ".bin"
|
||||
if self
|
||||
.config
|
||||
.whisper
|
||||
.supported_models
|
||||
.contains(&model_name.to_string())
|
||||
{
|
||||
models.push(model_name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
models.sort();
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// Get information about a downloaded model
|
||||
pub async fn get_model_info(&self, model_name: &str) -> Result<ModelInfo, VoiceCliError> {
|
||||
let model_path = self.get_model_path(model_name)?;
|
||||
|
||||
if !model_path.exists() {
|
||||
return Err(VoiceCliError::ModelNotFound(format!(
|
||||
"Model '{}' not found",
|
||||
model_name
|
||||
)));
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&model_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to get model info: {}", e)))?;
|
||||
|
||||
let size = Self::format_size(metadata.len());
|
||||
|
||||
// TODO: Get actual memory usage if model is loaded
|
||||
// This is a placeholder implementation - real memory tracking would require
|
||||
// integration with the transcription service to monitor loaded models
|
||||
let memory_usage = "Not tracked".to_string();
|
||||
|
||||
let status = if self.is_model_valid(&model_path).await? {
|
||||
"Valid"
|
||||
} else {
|
||||
"Invalid"
|
||||
}
|
||||
.to_string();
|
||||
|
||||
Ok(ModelInfo {
|
||||
size,
|
||||
memory_usage,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a downloaded model
|
||||
pub async fn validate_model(&self, model_name: &str) -> Result<(), VoiceCliError> {
|
||||
let model_path = self.get_model_path(model_name)?;
|
||||
|
||||
if !model_path.exists() {
|
||||
return Err(VoiceCliError::ModelNotFound(format!(
|
||||
"Model '{}' not found",
|
||||
model_name
|
||||
)));
|
||||
}
|
||||
|
||||
if !self.is_model_valid(&model_path).await? {
|
||||
return Err(VoiceCliError::Model(format!(
|
||||
"Model '{}' validation failed",
|
||||
model_name
|
||||
)));
|
||||
}
|
||||
|
||||
debug!("Model '{}' validation passed", model_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a model file is valid
|
||||
async fn is_model_valid(&self, model_path: &Path) -> Result<bool, VoiceCliError> {
|
||||
let metadata = fs::metadata(model_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to read model file: {}", e)))?;
|
||||
|
||||
// Basic validation: check if file is not empty and has reasonable size
|
||||
if metadata.len() < 1024 {
|
||||
warn!("Model file too small: {} bytes", metadata.len());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if file size is reasonable for the model type
|
||||
if let Some(expected_size) = self.get_expected_model_size(
|
||||
&model_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.strip_prefix("ggml-").unwrap_or(s))
|
||||
.unwrap_or("unknown"),
|
||||
) {
|
||||
let actual_size = metadata.len();
|
||||
let size_diff_percent = if actual_size > expected_size {
|
||||
((actual_size as f64 - expected_size as f64) / expected_size as f64) * 100.0
|
||||
} else {
|
||||
((expected_size as f64 - actual_size as f64) / expected_size as f64) * 100.0
|
||||
};
|
||||
|
||||
// Allow 20% size difference to accommodate different versions
|
||||
if size_diff_percent > 20.0 {
|
||||
warn!(
|
||||
"Model file size differs significantly from expected: actual={} bytes, expected={} bytes, diff={:.1}%",
|
||||
actual_size, expected_size, size_diff_percent
|
||||
);
|
||||
// Don't fail validation, just warn - the file might still be valid
|
||||
}
|
||||
}
|
||||
|
||||
// File exists and has reasonable size - assume it's valid
|
||||
// Let whisper.cpp handle format validation during actual loading
|
||||
debug!("Model file appears to be valid: {} bytes", metadata.len());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove a downloaded model
|
||||
pub async fn remove_model(&self, model_name: &str) -> Result<(), VoiceCliError> {
|
||||
let model_path = self.get_model_path(model_name)?;
|
||||
|
||||
if !model_path.exists() {
|
||||
return Ok(()); // Already removed
|
||||
}
|
||||
|
||||
fs::remove_file(&model_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to remove model: {}", e)))?;
|
||||
|
||||
info!("Removed model '{}' from {:?}", model_name, model_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get download status for a model
|
||||
pub async fn get_download_status(
|
||||
&self,
|
||||
model_name: &str,
|
||||
) -> Result<ModelDownloadStatus, VoiceCliError> {
|
||||
let status = if self.is_model_downloaded(model_name).await? {
|
||||
DownloadStatus::Exists
|
||||
} else {
|
||||
DownloadStatus::NotStarted
|
||||
};
|
||||
|
||||
Ok(ModelDownloadStatus {
|
||||
model_name: model_name.to_string(),
|
||||
status,
|
||||
progress: None,
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// List models that are currently loaded in memory
|
||||
pub async fn list_loaded_models(&self) -> Result<Vec<String>, VoiceCliError> {
|
||||
// TODO: This should track actually loaded models in transcription service
|
||||
// For now, return empty list as this is not a core business feature
|
||||
// Real implementation would require:
|
||||
// 1. Integration with voice-toolkit to track loaded models
|
||||
// 2. Memory usage monitoring of loaded model instances
|
||||
// 3. Reference counting for multiple concurrent uses
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Format file size in human-readable format
|
||||
fn format_size(size: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = size as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.1} {}", size, UNITS[unit_index])
|
||||
}
|
||||
|
||||
/// Get the expected model size for download progress
|
||||
pub fn get_expected_model_size(&self, model_name: &str) -> Option<u64> {
|
||||
// Approximate sizes for whisper models (in bytes)
|
||||
match model_name {
|
||||
"tiny" | "tiny.en" => Some(39 * 1024 * 1024), // ~39MB
|
||||
"base" | "base.en" => Some(142 * 1024 * 1024), // ~142MB
|
||||
"small" | "small.en" => Some(244 * 1024 * 1024), // ~244MB
|
||||
"medium" | "medium.en" => Some(769 * 1024 * 1024), // ~769MB
|
||||
"large-v1" | "large-v2" | "large-v3" => Some(1550 * 1024 * 1024), // ~1.5GB
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnose a corrupted model file and provide suggestions
|
||||
pub async fn diagnose_model(&self, model_name: &str) -> Result<String, VoiceCliError> {
|
||||
let model_path = self.get_model_path(model_name)?;
|
||||
|
||||
if !model_path.exists() {
|
||||
return Ok(format!(
|
||||
"Model '{}' file does not exist at {:?}",
|
||||
model_name, model_path
|
||||
));
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&model_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Failed to read model metadata: {}", e)))?;
|
||||
|
||||
let mut diagnosis = Vec::new();
|
||||
|
||||
// Check file size
|
||||
let actual_size = metadata.len();
|
||||
diagnosis.push(format!(
|
||||
"File size: {} bytes ({})",
|
||||
actual_size,
|
||||
Self::format_size(actual_size)
|
||||
));
|
||||
|
||||
if let Some(expected_size) = self.get_expected_model_size(model_name) {
|
||||
let size_diff = if actual_size > expected_size {
|
||||
actual_size - expected_size
|
||||
} else {
|
||||
expected_size - actual_size
|
||||
};
|
||||
let size_diff_percent = (size_diff as f64 / expected_size as f64) * 100.0;
|
||||
|
||||
diagnosis.push(format!(
|
||||
"Expected size: {} bytes ({})",
|
||||
expected_size,
|
||||
Self::format_size(expected_size)
|
||||
));
|
||||
diagnosis.push(format!("Size difference: {:.1}%", size_diff_percent));
|
||||
|
||||
if size_diff_percent > 20.0 {
|
||||
diagnosis.push(
|
||||
"⚠️ File size differs significantly from expected - may be corrupted"
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
diagnosis.push("✅ File size is within expected range".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Basic file accessibility check
|
||||
match fs::File::open(&model_path).await {
|
||||
Ok(_) => {
|
||||
diagnosis.push("✅ File is readable".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
diagnosis.push(format!("❌ File is not readable: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file is completely empty or too small
|
||||
if actual_size == 0 {
|
||||
diagnosis.push("❌ File is empty".to_string());
|
||||
} else if actual_size < 1024 {
|
||||
diagnosis.push("❌ File is too small to be a valid model".to_string());
|
||||
} else {
|
||||
diagnosis.push("✅ File has reasonable size".to_string());
|
||||
}
|
||||
|
||||
Ok(diagnosis.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_service_creation() {
|
||||
let config = Config::default();
|
||||
let service = ModelService::new(config);
|
||||
assert!(!service.models_dir.as_os_str().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_path_generation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.whisper.models_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let service = ModelService::new(config);
|
||||
let path = service.get_model_path("base").unwrap();
|
||||
|
||||
assert!(path.to_string_lossy().contains("ggml-base.bin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_downloaded_models_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.whisper.models_dir = temp_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let service = ModelService::new(config);
|
||||
let models = service.list_downloaded_models().await.unwrap();
|
||||
|
||||
assert!(models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_size() {
|
||||
assert_eq!(ModelService::format_size(1024), "1.0 KB");
|
||||
assert_eq!(ModelService::format_size(1024 * 1024), "1.0 MB");
|
||||
assert_eq!(ModelService::format_size(1536 * 1024 * 1024), "1.5 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_expected_model_size() {
|
||||
let service = ModelService::new(Config::default());
|
||||
|
||||
assert!(service.get_expected_model_size("base").is_some());
|
||||
assert!(service.get_expected_model_size("unknown").is_none());
|
||||
}
|
||||
}
|
||||
155
qiming-mcp-proxy/voice-cli/src/services/transcription_engine.rs
Normal file
155
qiming-mcp-proxy/voice-cli/src/services/transcription_engine.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::services::ModelService;
|
||||
use dashmap::DashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Reuse an already-loaded WhisperTranscriber to avoid reloading the model
|
||||
use voice_toolkit::stt::{self, TranscriptionResult, WhisperConfig, WhisperTranscriber};
|
||||
|
||||
/// Shared transcription engine to unify model resolution, audio conversion and transcription
|
||||
pub struct TranscriptionEngine {
|
||||
model_service: Arc<ModelService>,
|
||||
// Cache transcribers per model to avoid reloading model/VRAM each time
|
||||
// Using DashMap for better concurrent performance
|
||||
transcribers: DashMap<String, Arc<WhisperTranscriber>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TranscriptionEngine {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TranscriptionEngine")
|
||||
.field("model_service", &self.model_service)
|
||||
.field("transcribers_count", &self.transcribers.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptionEngine {
|
||||
/// Create a new transcription engine
|
||||
pub fn new(model_service: Arc<ModelService>) -> Self {
|
||||
Self {
|
||||
model_service,
|
||||
transcribers: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_create_transcriber(
|
||||
&self,
|
||||
model_name: &str,
|
||||
) -> Result<Arc<WhisperTranscriber>, VoiceCliError> {
|
||||
// Fast path: try get from cache
|
||||
if let Some(existing) = self.transcribers.get(model_name) {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
|
||||
// Resolve model path
|
||||
let model_path = self.model_service.get_model_path(model_name)?;
|
||||
if !model_path.exists() {
|
||||
return Err(VoiceCliError::ModelNotFound(model_name.to_string()));
|
||||
}
|
||||
|
||||
// Create transcriber (assume construction might be CPU-heavy)
|
||||
let created_res = tokio::task::spawn_blocking(move || {
|
||||
let cfg = WhisperConfig::new(model_path);
|
||||
WhisperTranscriber::new(cfg)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Model(format!("Transcriber create join error: {}", e)))?;
|
||||
|
||||
let created = created_res.map_err(|e| VoiceCliError::Model(e.to_string()))?;
|
||||
let transcriber = Arc::new(created);
|
||||
|
||||
// Insert into cache using DashMap's atomic operations
|
||||
// Use entry API to handle race conditions where another thread might have inserted the same key
|
||||
match self.transcribers.entry(model_name.to_string()) {
|
||||
dashmap::mapref::entry::Entry::Occupied(entry) => {
|
||||
// Another thread already inserted this transcriber, use the existing one
|
||||
Ok(entry.get().clone())
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
// We're the first to insert, use our transcriber
|
||||
entry.insert(transcriber.clone());
|
||||
Ok(transcriber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcribe an audio file that is already Whisper-compatible (wav, correct params)
|
||||
pub async fn transcribe_compatible_audio(
|
||||
&self,
|
||||
model_name: &str,
|
||||
audio_path: &Path,
|
||||
timeout_secs: u64,
|
||||
) -> Result<TranscriptionResult, VoiceCliError> {
|
||||
let transcriber = self.get_or_create_transcriber(model_name).await?;
|
||||
let audio_path = audio_path.to_path_buf();
|
||||
|
||||
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
|
||||
let result = tokio::time::timeout(
|
||||
timeout_duration,
|
||||
// Use spawn_blocking for CPU-intensive Whisper transcription
|
||||
// This moves the blocking operation to a separate thread pool
|
||||
tokio::task::spawn_blocking(move || -> Result<TranscriptionResult, String> {
|
||||
// Create a new runtime within the blocking thread
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
format!("Failed to create runtime for Whisper transcription: {}", e)
|
||||
})?;
|
||||
|
||||
rt.block_on(async {
|
||||
stt::transcribe_file_with_transcriber(&transcriber, &audio_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| VoiceCliError::transcription_timeout(timeout_secs))?
|
||||
.map_err(|e| {
|
||||
if e.is_panic() {
|
||||
VoiceCliError::transcription_failed("Whisper transcription panicked")
|
||||
} else if e.is_cancelled() {
|
||||
VoiceCliError::transcription_failed("Whisper transcription was cancelled")
|
||||
} else {
|
||||
VoiceCliError::transcription_failed(format!(
|
||||
"Whisper transcription join error: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(result.map_err(|e| VoiceCliError::TranscriptionFailed(e.to_string()))?)
|
||||
}
|
||||
|
||||
/// Get the default model name from configuration
|
||||
pub fn default_model(&self) -> &str {
|
||||
self.model_service.default_model()
|
||||
}
|
||||
|
||||
/// Get the worker timeout from configuration
|
||||
pub fn worker_timeout(&self) -> u64 {
|
||||
self.model_service.worker_timeout()
|
||||
}
|
||||
|
||||
/// Transcribe an input audio file, converting to Whisper-compatible format if necessary
|
||||
pub async fn transcribe_with_conversion(
|
||||
&self,
|
||||
model_name: &str,
|
||||
input_audio_path: &Path,
|
||||
timeout_secs: u64,
|
||||
) -> Result<TranscriptionResult, VoiceCliError> {
|
||||
// Convert to Whisper-compatible format in blocking thread
|
||||
let input_path = input_audio_path.to_path_buf();
|
||||
let compatible = tokio::task::spawn_blocking(move || {
|
||||
voice_toolkit::audio::ensure_whisper_compatible(&input_path, None::<PathBuf>)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::AudioConversionFailed(format!("Task join error: {}", e)))?
|
||||
.map_err(|e| VoiceCliError::AudioConversionFailed(e.to_string()))?;
|
||||
|
||||
self.transcribe_compatible_audio(model_name, &compatible.path, timeout_secs)
|
||||
.await
|
||||
}
|
||||
}
|
||||
339
qiming-mcp-proxy/voice-cli/src/services/tts_service.rs
Normal file
339
qiming-mcp-proxy/voice-cli/src/services/tts_service.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::{TtsAsyncRequest, TtsSyncRequest, TtsTaskResponse};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
use tracing::{debug, error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// TTS服务 - 处理文本到语音转换
|
||||
#[derive(Debug)]
|
||||
pub struct TtsService {
|
||||
python_path: PathBuf,
|
||||
script_path: PathBuf,
|
||||
model_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TtsService {
|
||||
/// 创建新的TTS服务实例
|
||||
pub fn new(
|
||||
python_path: Option<PathBuf>,
|
||||
model_path: Option<PathBuf>,
|
||||
) -> Result<Self, VoiceCliError> {
|
||||
let python_path = python_path.unwrap_or_else(|| {
|
||||
// 尝试在多个位置查找虚拟环境中的 Python
|
||||
let possible_venv_paths = vec![
|
||||
// 当前目录下的虚拟环境
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(".venv/Scripts/python.exe")
|
||||
} else {
|
||||
PathBuf::from(".venv/bin/python")
|
||||
},
|
||||
// crate 目录下的虚拟环境
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv/Scripts/python.exe")
|
||||
} else {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv/bin/python")
|
||||
},
|
||||
];
|
||||
|
||||
// 查找第一个存在的 Python 解释器
|
||||
for venv_python in possible_venv_paths {
|
||||
if venv_python.exists() {
|
||||
return venv_python;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到系统 Python
|
||||
if let Ok(_output) = Command::new("python3").arg("--version").output() {
|
||||
PathBuf::from("python3")
|
||||
} else if let Ok(_output) = Command::new("python").arg("--version").output() {
|
||||
PathBuf::from("python")
|
||||
} else {
|
||||
PathBuf::from("python3") // 默认使用python3
|
||||
}
|
||||
});
|
||||
|
||||
// 获取脚本路径(首先尝试当前目录,然后尝试 crate 目录)
|
||||
let current_dir = std::env::current_dir()
|
||||
.map_err(|e| VoiceCliError::Config(format!("获取当前目录失败: {}", e)))?;
|
||||
|
||||
let script_path = current_dir.join("tts_service.py");
|
||||
|
||||
let final_script_path = if script_path.exists() {
|
||||
script_path
|
||||
} else {
|
||||
// 尝试在 crate 目录中查找
|
||||
let crate_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let crate_script_path = crate_path.join("tts_service.py");
|
||||
if crate_script_path.exists() {
|
||||
crate_script_path
|
||||
} else {
|
||||
return Err(VoiceCliError::Config(format!(
|
||||
"TTS脚本不存在: 在 {:?} 或 {:?} 中都未找到",
|
||||
script_path, crate_script_path
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Use TTS script: {:?}", final_script_path);
|
||||
|
||||
info!(
|
||||
"Initialize TTS service - Python: {:?}, script: {:?}",
|
||||
python_path, final_script_path
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
python_path,
|
||||
script_path: final_script_path,
|
||||
model_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// 同步TTS合成
|
||||
pub async fn synthesize_sync(&self, request: TtsSyncRequest) -> Result<PathBuf, VoiceCliError> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// 验证输入
|
||||
if request.text.trim().is_empty() {
|
||||
return Err(VoiceCliError::InvalidInput("文本不能为空".to_string()));
|
||||
}
|
||||
|
||||
if let Some(speed) = request.speed {
|
||||
if !(0.5..=2.0).contains(&speed) {
|
||||
return Err(VoiceCliError::InvalidInput(
|
||||
"语速必须在0.5-2.0之间".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pitch) = request.pitch {
|
||||
if !(-20..=20).contains(&pitch) {
|
||||
return Err(VoiceCliError::InvalidInput(
|
||||
"音调必须在-20到20之间".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(volume) = request.volume {
|
||||
if !(0.5..=2.0).contains(&volume) {
|
||||
return Err(VoiceCliError::InvalidInput(
|
||||
"音量必须在0.5-2.0之间".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 创建临时输出文件
|
||||
let output_format = request.format.as_deref().unwrap_or("mp3");
|
||||
let temp_file = NamedTempFile::new()
|
||||
.map_err(|e| VoiceCliError::Io(format!("创建临时文件失败: {}", e)))?;
|
||||
|
||||
let output_path = temp_file.into_temp_path();
|
||||
let output_path_str = output_path
|
||||
.to_str()
|
||||
.ok_or_else(|| VoiceCliError::Io("临时文件路径无效".to_string()))?;
|
||||
|
||||
info!(
|
||||
"Start TTS synthesis - text length: {}, format: {}",
|
||||
request.text.len(),
|
||||
output_format
|
||||
);
|
||||
|
||||
// 使用 uv run 来确保在正确的虚拟环境中运行
|
||||
let mut cmd = Command::new("uv");
|
||||
cmd.arg("run")
|
||||
.arg(&self.script_path)
|
||||
.arg(&request.text)
|
||||
.arg("--output")
|
||||
.arg(output_path_str)
|
||||
.arg("--speed")
|
||||
.arg(request.speed.unwrap_or(1.0).to_string())
|
||||
.arg("--pitch")
|
||||
.arg(request.pitch.unwrap_or(0).to_string())
|
||||
.arg("--volume")
|
||||
.arg(request.volume.unwrap_or(1.0).to_string())
|
||||
.arg("--format")
|
||||
.arg(output_format)
|
||||
// 设置工作目录为脚本所在的目录
|
||||
.current_dir(
|
||||
self.script_path
|
||||
.parent()
|
||||
.unwrap_or(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))),
|
||||
);
|
||||
|
||||
// 添加模型参数
|
||||
if let Some(model) = &request.model {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
if let Some(ref model_path) = self.model_path {
|
||||
cmd.env("TTS_MODEL_PATH", model_path.to_string_lossy().as_ref());
|
||||
}
|
||||
cmd.env(
|
||||
"TTS_PYTHON_PATH",
|
||||
self.python_path.to_string_lossy().as_ref(),
|
||||
);
|
||||
|
||||
debug!("Execute TTS command: {:?}", cmd);
|
||||
|
||||
// 执行命令
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| VoiceCliError::TtsError(format!("执行TTS命令失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
error!(
|
||||
"TTS synthesis failed - stderr: {}, stdout: {}",
|
||||
stderr, stdout
|
||||
);
|
||||
return Err(VoiceCliError::TtsError(format!("TTS合成失败: {}", stderr)));
|
||||
}
|
||||
|
||||
// 验证输出文件
|
||||
if !output_path.exists() {
|
||||
return Err(VoiceCliError::TtsError(
|
||||
"TTS合成失败:输出文件未创建".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let file_size = output_path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
if file_size == 0 {
|
||||
return Err(VoiceCliError::TtsError(
|
||||
"TTS合成失败:输出文件为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let processing_time = start_time.elapsed();
|
||||
info!(
|
||||
"TTS synthesis completed - file size: {} bytes, time taken: {:?}",
|
||||
file_size, processing_time
|
||||
);
|
||||
|
||||
// 将临时文件持久化到正式位置
|
||||
let final_output_path = self
|
||||
.persist_output_file(&output_path, output_format)
|
||||
.await?;
|
||||
|
||||
Ok(final_output_path)
|
||||
}
|
||||
|
||||
/// 创建异步TTS任务
|
||||
pub async fn create_async_task(
|
||||
&self,
|
||||
request: TtsAsyncRequest,
|
||||
) -> Result<TtsTaskResponse, VoiceCliError> {
|
||||
// 验证输入
|
||||
if request.text.trim().is_empty() {
|
||||
return Err(VoiceCliError::InvalidInput("文本不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 预估处理时间(基于文本长度)
|
||||
let estimated_duration = self.estimate_processing_time(&request.text);
|
||||
|
||||
info!(
|
||||
"Create a TTS asynchronous task - text length: {}, estimated time: {}s",
|
||||
request.text.len(),
|
||||
estimated_duration
|
||||
);
|
||||
|
||||
// TODO: 将任务提交到TTS任务管理器
|
||||
// 这里暂时返回模拟的任务ID,实际实现需要集成TtsTaskManager
|
||||
let task_id = Uuid::new_v4().to_string();
|
||||
|
||||
Ok(TtsTaskResponse {
|
||||
task_id: task_id.clone(),
|
||||
message: "TTS任务已提交".to_string(),
|
||||
estimated_duration: Some(estimated_duration),
|
||||
})
|
||||
}
|
||||
|
||||
/// 预估处理时间
|
||||
fn estimate_processing_time(&self, text: &str) -> u32 {
|
||||
// 简单的预估:基于文本长度
|
||||
// 假设每秒处理10个字符
|
||||
let chars_per_second = 10;
|
||||
let estimated_seconds = (text.len() as f32 / chars_per_second as f32).ceil() as u32;
|
||||
|
||||
// 最少3秒,最多300秒(5分钟)
|
||||
estimated_seconds.max(3).min(300)
|
||||
}
|
||||
|
||||
/// 持久化输出文件
|
||||
async fn persist_output_file(
|
||||
&self,
|
||||
temp_path: &Path,
|
||||
format: &str,
|
||||
) -> Result<PathBuf, VoiceCliError> {
|
||||
// 创建输出目录
|
||||
let output_dir = PathBuf::from("./data/tts");
|
||||
tokio::fs::create_dir_all(&output_dir)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Io(format!("创建输出目录失败: {}", e)))?;
|
||||
|
||||
// 生成唯一文件名
|
||||
let filename = format!("tts_{}.{}", Uuid::new_v4(), format);
|
||||
let final_path = output_dir.join(filename);
|
||||
|
||||
// 复制文件
|
||||
tokio::fs::copy(temp_path, &final_path)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Io(format!("复制文件失败: {}", e)))?;
|
||||
|
||||
Ok(final_path)
|
||||
}
|
||||
|
||||
/// 清理资源
|
||||
pub async fn cleanup(&self) -> Result<(), VoiceCliError> {
|
||||
// 清理临时文件等
|
||||
info!("TTS service cleanup completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_estimate_processing_time() {
|
||||
let service = TtsService::new(None, None).unwrap();
|
||||
|
||||
// 测试短文本
|
||||
let short_time = service.estimate_processing_time("Hello");
|
||||
assert!(short_time >= 3);
|
||||
|
||||
// 测试长文本
|
||||
let long_text = "A".repeat(1000);
|
||||
let long_time = service.estimate_processing_time(&long_text);
|
||||
assert!(long_time > 50);
|
||||
|
||||
// 测试最大限制
|
||||
let very_long_text = "A".repeat(10000);
|
||||
let max_time = service.estimate_processing_time(&very_long_text);
|
||||
assert_eq!(max_time, 300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_async_task() {
|
||||
let service = TtsService::new(None, None).unwrap();
|
||||
|
||||
let request = TtsAsyncRequest {
|
||||
text: "Hello, world!".to_string(),
|
||||
model: None,
|
||||
speed: Some(1.0),
|
||||
pitch: Some(0),
|
||||
volume: Some(1.0),
|
||||
format: Some("mp3".to_string()),
|
||||
priority: None,
|
||||
};
|
||||
|
||||
let response = service.create_async_task(request).await.unwrap();
|
||||
|
||||
assert!(!response.task_id.is_empty());
|
||||
assert_eq!(response.message, "TTS任务已提交");
|
||||
assert!(response.estimated_duration.unwrap() >= 3);
|
||||
}
|
||||
}
|
||||
387
qiming-mcp-proxy/voice-cli/src/services/tts_task_manager.rs
Normal file
387
qiming-mcp-proxy/voice-cli/src/services/tts_task_manager.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::{
|
||||
TtsAsyncRequest, TtsProcessingStage, TtsProgressDetails, TtsTaskError, TtsTaskStatus,
|
||||
};
|
||||
use apalis_sql::sqlite::SqliteStorage;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// TTS任务
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TtsTask {
|
||||
pub task_id: String,
|
||||
pub text: String,
|
||||
pub model: Option<String>,
|
||||
pub speed: f32,
|
||||
pub pitch: i32,
|
||||
pub volume: f32,
|
||||
pub format: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
/// TTS任务状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TtsTaskState {
|
||||
pub task_id: String,
|
||||
pub status: TtsTaskStatus,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// TTS任务管理器
|
||||
pub struct TtsTaskManager {
|
||||
storage: Arc<RwLock<SqliteStorage<TtsTask>>>,
|
||||
max_concurrent_tasks: usize,
|
||||
}
|
||||
|
||||
impl TtsTaskManager {
|
||||
/// 创建新的TTS任务管理器
|
||||
pub async fn new(
|
||||
database_url: &str,
|
||||
max_concurrent_tasks: usize,
|
||||
) -> Result<Self, VoiceCliError> {
|
||||
info!("Initialize TTS Task Manager - Database: {}", database_url);
|
||||
|
||||
// 创建SQLite存储
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("连接SQLite失败: {}", e)))?;
|
||||
|
||||
let storage = Arc::new(RwLock::new(SqliteStorage::new(pool)));
|
||||
|
||||
// 创建任务表
|
||||
Self::create_tables_if_not_exists(&storage).await?;
|
||||
|
||||
Ok(Self {
|
||||
storage,
|
||||
max_concurrent_tasks,
|
||||
})
|
||||
}
|
||||
|
||||
/// 创建必要的表
|
||||
async fn create_tables_if_not_exists(
|
||||
storage: &Arc<RwLock<SqliteStorage<TtsTask>>>,
|
||||
) -> Result<(), VoiceCliError> {
|
||||
let guard = storage.read().await;
|
||||
let pool = guard.pool();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS tts_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
text TEXT NOT NULL,
|
||||
model TEXT,
|
||||
speed REAL NOT NULL,
|
||||
pitch INTEGER NOT NULL,
|
||||
volume REAL NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
result_path TEXT,
|
||||
file_size INTEGER,
|
||||
duration_seconds REAL,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("创建TTS任务表失败: {}", e)))?;
|
||||
|
||||
info!("TTS task list created successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提交TTS任务
|
||||
pub async fn submit_task(&self, request: TtsAsyncRequest) -> Result<String, VoiceCliError> {
|
||||
let task_id = Uuid::new_v4().to_string();
|
||||
let created_at = Utc::now();
|
||||
|
||||
let task = TtsTask {
|
||||
task_id: task_id.clone(),
|
||||
text: request.text.clone(),
|
||||
model: request.model.clone(),
|
||||
speed: request.speed.unwrap_or(1.0),
|
||||
pitch: request.pitch.unwrap_or(0),
|
||||
volume: request.volume.unwrap_or(1.0),
|
||||
format: request.format.unwrap_or_else(|| "mp3".to_string()),
|
||||
created_at,
|
||||
priority: request.priority.map_or(2, |p| match p {
|
||||
crate::models::tts::TaskPriority::Low => 1,
|
||||
crate::models::tts::TaskPriority::Normal => 2,
|
||||
crate::models::tts::TaskPriority::High => 3,
|
||||
}),
|
||||
};
|
||||
|
||||
// 保存任务到数据库
|
||||
let guard = self.storage.read().await;
|
||||
let pool = guard.pool();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO tts_tasks (
|
||||
task_id, text, model, speed, pitch, volume, format,
|
||||
created_at, priority, status, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&task.task_id)
|
||||
.bind(&task.text)
|
||||
.bind(&task.model)
|
||||
.bind(task.speed)
|
||||
.bind(task.pitch)
|
||||
.bind(task.volume)
|
||||
.bind(&task.format)
|
||||
.bind(task.created_at)
|
||||
.bind(task.priority)
|
||||
.bind("pending")
|
||||
.bind(task.created_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("保存TTS任务失败: {}", e)))?;
|
||||
|
||||
info!("TTS task has been submitted - ID: {}", task_id);
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
/// 获取任务状态
|
||||
pub async fn get_task_status(
|
||||
&self,
|
||||
task_id: &str,
|
||||
) -> Result<Option<TtsTaskStatus>, VoiceCliError> {
|
||||
let guard = self.storage.read().await;
|
||||
let pool = guard.pool();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT status, updated_at, result_path, file_size, duration_seconds, error_message, retry_count FROM tts_tasks WHERE task_id = ?"
|
||||
)
|
||||
.bind(task_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("查询任务状态失败: {}", e)))?;
|
||||
|
||||
match row {
|
||||
Some(row) => {
|
||||
let status_str: String = row.get("status");
|
||||
let updated_at: DateTime<Utc> = row.get("updated_at");
|
||||
let result_path: Option<String> = row.get("result_path");
|
||||
let file_size: Option<i64> = row.get("file_size");
|
||||
let duration_seconds: Option<f64> = row.get("duration_seconds");
|
||||
let error_message: Option<String> = row.get("error_message");
|
||||
let retry_count: i32 = row.get("retry_count");
|
||||
|
||||
let status = match status_str.as_str() {
|
||||
"pending" => TtsTaskStatus::Pending {
|
||||
queued_at: updated_at,
|
||||
},
|
||||
"processing" => TtsTaskStatus::Processing {
|
||||
stage: TtsProcessingStage::VoiceSynthesis,
|
||||
started_at: updated_at,
|
||||
progress_details: Some(TtsProgressDetails {
|
||||
current_stage: TtsProcessingStage::VoiceSynthesis,
|
||||
stage_progress: Some(0.5),
|
||||
estimated_remaining: Some(chrono::Duration::seconds(30)),
|
||||
text_length: 100,
|
||||
processed_chars: 50,
|
||||
}),
|
||||
},
|
||||
"completed" => {
|
||||
if let (Some(path), Some(size), Some(duration)) =
|
||||
(result_path, file_size, duration_seconds)
|
||||
{
|
||||
TtsTaskStatus::Completed {
|
||||
completed_at: updated_at,
|
||||
processing_time: updated_at.signed_duration_since(updated_at), // 这里应该用创建时间
|
||||
audio_file_path: path,
|
||||
file_size: size as u64,
|
||||
duration_seconds: duration as f32,
|
||||
}
|
||||
} else {
|
||||
return Err(VoiceCliError::Storage(
|
||||
"完成的任务缺少结果信息".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"failed" => {
|
||||
let error = error_message.unwrap_or_else(|| "未知错误".to_string());
|
||||
TtsTaskStatus::Failed {
|
||||
error: TtsTaskError::SynthesisFailed {
|
||||
model: "default".to_string(),
|
||||
message: error,
|
||||
is_recoverable: retry_count < 3,
|
||||
},
|
||||
failed_at: updated_at,
|
||||
retry_count: retry_count as u32,
|
||||
is_recoverable: retry_count < 3,
|
||||
}
|
||||
}
|
||||
"cancelled" => TtsTaskStatus::Cancelled {
|
||||
cancelled_at: updated_at,
|
||||
reason: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(VoiceCliError::Storage(format!(
|
||||
"未知的任务状态: {}",
|
||||
status_str
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(status))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新任务状态
|
||||
pub async fn update_task_status(
|
||||
&self,
|
||||
task_id: &str,
|
||||
status: TtsTaskStatus,
|
||||
) -> Result<(), VoiceCliError> {
|
||||
let guard = self.storage.read().await;
|
||||
let pool = guard.pool();
|
||||
let updated_at = Utc::now();
|
||||
|
||||
let (status_str, result_path, file_size, duration_seconds, error_message) = match status {
|
||||
TtsTaskStatus::Pending { .. } => ("pending", None, None, None, None),
|
||||
TtsTaskStatus::Processing { .. } => ("processing", None, None, None, None),
|
||||
TtsTaskStatus::Completed {
|
||||
audio_file_path,
|
||||
file_size,
|
||||
duration_seconds,
|
||||
..
|
||||
} => (
|
||||
"completed",
|
||||
Some(audio_file_path),
|
||||
Some(file_size as i64),
|
||||
Some(duration_seconds as f64),
|
||||
None,
|
||||
),
|
||||
TtsTaskStatus::Failed { error, .. } => {
|
||||
("failed", None, None, None, Some(error.to_string()))
|
||||
}
|
||||
TtsTaskStatus::Cancelled { .. } => ("cancelled", None, None, None, None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE tts_tasks SET status = ?, updated_at = ?, result_path = ?, file_size = ?, duration_seconds = ?, error_message = ? WHERE task_id = ?"
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(updated_at)
|
||||
.bind(result_path)
|
||||
.bind(file_size)
|
||||
.bind(duration_seconds)
|
||||
.bind(error_message)
|
||||
.bind(task_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("更新任务状态失败: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动任务处理器
|
||||
pub async fn start_worker(&self) -> Result<(), VoiceCliError> {
|
||||
info!(
|
||||
"Start the TTS task processor, the maximum number of concurrent tasks: {}",
|
||||
self.max_concurrent_tasks
|
||||
);
|
||||
|
||||
// TODO: 实现实际的任务处理逻辑
|
||||
// 这里应该启动一个后台worker来处理TTS任务队列
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取任务统计
|
||||
pub async fn get_stats(&self) -> Result<TtsTaskStats, VoiceCliError> {
|
||||
let guard = self.storage.read().await;
|
||||
let pool = guard.pool();
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
||||
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled
|
||||
FROM tts_tasks
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| VoiceCliError::Storage(format!("获取任务统计失败: {}", e)))?;
|
||||
|
||||
Ok(TtsTaskStats {
|
||||
total_tasks: row.get("total"),
|
||||
pending_tasks: row.get("pending"),
|
||||
processing_tasks: row.get("processing"),
|
||||
completed_tasks: row.get("completed"),
|
||||
failed_tasks: row.get("failed"),
|
||||
cancelled_tasks: row.get("cancelled"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// TTS任务统计
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TtsTaskStats {
|
||||
pub total_tasks: i64,
|
||||
pub pending_tasks: i64,
|
||||
pub processing_tasks: i64,
|
||||
pub completed_tasks: i64,
|
||||
pub failed_tasks: i64,
|
||||
pub cancelled_tasks: i64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tts_task_manager_creation() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let db_path = temp_file.path().to_string_lossy().to_string();
|
||||
let db_url = format!("sqlite://{}", db_path);
|
||||
|
||||
let manager = TtsTaskManager::new(&db_url, 2).await.unwrap();
|
||||
assert_eq!(manager.max_concurrent_tasks, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_submission() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let db_path = temp_file.path().to_string_lossy().to_string();
|
||||
let db_url = format!("sqlite://{}", db_path);
|
||||
|
||||
let manager = TtsTaskManager::new(&db_url, 2).await.unwrap();
|
||||
|
||||
let request = TtsAsyncRequest {
|
||||
text: "Hello, world!".to_string(),
|
||||
model: None,
|
||||
speed: Some(1.0),
|
||||
pitch: Some(0),
|
||||
volume: Some(1.0),
|
||||
format: Some("mp3".to_string()),
|
||||
priority: None,
|
||||
};
|
||||
|
||||
let task_id = manager.submit_task(request).await.unwrap();
|
||||
assert!(!task_id.is_empty());
|
||||
}
|
||||
}
|
||||
226
qiming-mcp-proxy/voice-cli/src/tests/config_env_tests.rs
Normal file
226
qiming-mcp-proxy/voice-cli/src/tests/config_env_tests.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
#[cfg(test)]
|
||||
mod config_env_tests {
|
||||
use crate::models::Config;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Safe environment variable testing using static state
|
||||
static TEST_ENV_LOCK: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
|
||||
|
||||
fn get_test_env_lock() -> &'static Mutex<HashMap<String, Option<String>>> {
|
||||
TEST_ENV_LOCK.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
// Safe helper to set environment variable for testing
|
||||
fn safe_set_env_var(key: &str, value: &str) {
|
||||
let lock = get_test_env_lock();
|
||||
let mut env_state = lock.lock().unwrap();
|
||||
|
||||
// Store the original value if this is the first time setting this var
|
||||
if !env_state.contains_key(key) {
|
||||
env_state.insert(key.to_string(), env::var(key).ok());
|
||||
}
|
||||
|
||||
// This is still technically unsafe, but we'll wrap it in unsafe block
|
||||
// and document that tests should run serially to avoid race conditions
|
||||
unsafe {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe helper to remove environment variable for testing
|
||||
fn safe_remove_env_var(key: &str) {
|
||||
let lock = get_test_env_lock();
|
||||
let mut env_state = lock.lock().unwrap();
|
||||
|
||||
// Store the original value if this is the first time touching this var
|
||||
if !env_state.contains_key(key) {
|
||||
env_state.insert(key.to_string(), env::var(key).ok());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
env::remove_var(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to clear all voice-cli environment variables safely
|
||||
fn clear_voice_cli_env_vars() {
|
||||
let env_vars = [
|
||||
"VOICE_CLI_HOST",
|
||||
"VOICE_CLI_PORT",
|
||||
"VOICE_CLI_LOG_LEVEL",
|
||||
"VOICE_CLI_LOG_DIR",
|
||||
"VOICE_CLI_LOG_MAX_FILES",
|
||||
"VOICE_CLI_DEFAULT_MODEL",
|
||||
"VOICE_CLI_MODELS_DIR",
|
||||
"VOICE_CLI_AUTO_DOWNLOAD",
|
||||
"VOICE_CLI_TRANSCRIPTION_WORKERS",
|
||||
"VOICE_CLI_WORK_DIR",
|
||||
"VOICE_CLI_PID_FILE",
|
||||
"VOICE_CLI_MAX_FILE_SIZE",
|
||||
"VOICE_CLI_CORS_ENABLED",
|
||||
];
|
||||
|
||||
for var in &env_vars {
|
||||
safe_remove_env_var(var);
|
||||
}
|
||||
|
||||
// Add a small delay to ensure environment changes propagate
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
// Cleanup function to restore original environment state
|
||||
fn restore_original_env_vars() {
|
||||
let lock = get_test_env_lock();
|
||||
let env_state = lock.lock().unwrap();
|
||||
|
||||
for (key, original_value) in env_state.iter() {
|
||||
match original_value {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(key, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(key);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Modifies global environment variables, causes race conditions with parallel tests"]
|
||||
fn test_http_port_environment_override() {
|
||||
clear_voice_cli_env_vars();
|
||||
|
||||
// Set environment variable for HTTP port
|
||||
safe_set_env_var("VOICE_CLI_PORT", "9090");
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
let config = Config::load_with_env_overrides(&config_path).unwrap();
|
||||
|
||||
// Verify HTTP port was overridden
|
||||
assert_eq!(config.server.port, 9090);
|
||||
|
||||
// Clean up
|
||||
safe_remove_env_var("VOICE_CLI_PORT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Modifies global environment variables, causes race conditions with parallel tests"]
|
||||
fn test_invalid_port_environment_variable() {
|
||||
clear_voice_cli_env_vars();
|
||||
|
||||
// Set invalid environment variable
|
||||
safe_set_env_var("VOICE_CLI_PORT", "invalid_port");
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
let result = Config::load_with_env_overrides(&config_path);
|
||||
|
||||
// Should fail with proper error message
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("Invalid VOICE_CLI_PORT value 'invalid_port'"));
|
||||
|
||||
// Clean up
|
||||
safe_remove_env_var("VOICE_CLI_PORT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Modifies global environment variables, causes race conditions with parallel tests"]
|
||||
fn test_log_level_environment_override() {
|
||||
clear_voice_cli_env_vars();
|
||||
|
||||
// Set environment variable for log level
|
||||
safe_set_env_var("VOICE_CLI_LOG_LEVEL", "DEBUG");
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
let config = Config::load_with_env_overrides(&config_path).unwrap();
|
||||
|
||||
// Verify log level was overridden and normalized to lowercase
|
||||
assert_eq!(config.logging.level, "debug");
|
||||
|
||||
// Clean up
|
||||
safe_remove_env_var("VOICE_CLI_LOG_LEVEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Modifies global environment variables, causes race conditions with parallel tests"]
|
||||
fn test_invalid_log_level_environment_variable() {
|
||||
clear_voice_cli_env_vars();
|
||||
|
||||
// Set invalid environment variable
|
||||
safe_set_env_var("VOICE_CLI_LOG_LEVEL", "invalid_level");
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
let result = Config::load_with_env_overrides(&config_path);
|
||||
|
||||
// Should fail with proper error message
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("Invalid VOICE_CLI_LOG_LEVEL"));
|
||||
assert!(error_msg.contains("invalid_level"));
|
||||
|
||||
// Clean up
|
||||
safe_remove_env_var("VOICE_CLI_LOG_LEVEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Modifies global environment variables, causes race conditions with parallel tests"]
|
||||
fn test_comprehensive_validation() {
|
||||
clear_voice_cli_env_vars();
|
||||
|
||||
// Set multiple environment variables
|
||||
// 注意:使用有效的模型名称(large-v3 而不是 large)
|
||||
safe_set_env_var("VOICE_CLI_PORT", "8081");
|
||||
safe_set_env_var("VOICE_CLI_LOG_LEVEL", "warn");
|
||||
safe_set_env_var("VOICE_CLI_DEFAULT_MODEL", "large-v3");
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
let config = Config::load_with_env_overrides(&config_path).unwrap();
|
||||
|
||||
// Verify all overrides were applied
|
||||
assert_eq!(config.server.port, 8081);
|
||||
assert_eq!(config.logging.level, "warn");
|
||||
assert_eq!(config.whisper.default_model, "large-v3");
|
||||
|
||||
// Verify validation passes
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Clean up
|
||||
safe_remove_env_var("VOICE_CLI_PORT");
|
||||
safe_remove_env_var("VOICE_CLI_LOG_LEVEL");
|
||||
safe_remove_env_var("VOICE_CLI_DEFAULT_MODEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_environment_variable_validation() {
|
||||
clear_voice_cli_env_vars();
|
||||
|
||||
// Set empty environment variable
|
||||
safe_set_env_var("VOICE_CLI_HOST", " "); // Use spaces instead of empty string
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
let result = Config::load_with_env_overrides(&config_path);
|
||||
|
||||
// Should fail with proper error message
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("VOICE_CLI_HOST environment variable cannot be empty"));
|
||||
|
||||
// Clean up
|
||||
safe_remove_env_var("VOICE_CLI_HOST");
|
||||
}
|
||||
}
|
||||
336
qiming-mcp-proxy/voice-cli/src/tests/config_validation_tests.rs
Normal file
336
qiming-mcp-proxy/voice-cli/src/tests/config_validation_tests.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
#[cfg(test)]
|
||||
mod config_validation_tests {
|
||||
use crate::models::{
|
||||
AudioProcessingConfig, Config, DaemonConfig, LoggingConfig, ServerConfig,
|
||||
TaskManagementConfig, TtsConfig, WhisperConfig, WorkersConfig,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper function to create a valid base configuration
|
||||
fn create_valid_config() -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
max_file_size: 25 * 1024 * 1024, // 25MB
|
||||
cors_enabled: true,
|
||||
},
|
||||
whisper: WhisperConfig {
|
||||
default_model: "base".to_string(),
|
||||
models_dir: "./models".to_string(),
|
||||
auto_download: true,
|
||||
supported_models: vec!["base".to_string()],
|
||||
audio_processing: AudioProcessingConfig::default(),
|
||||
workers: WorkersConfig {
|
||||
transcription_workers: 2,
|
||||
channel_buffer_size: 100,
|
||||
worker_timeout: 3600,
|
||||
},
|
||||
},
|
||||
logging: LoggingConfig {
|
||||
level: "info".to_string(),
|
||||
log_dir: "./logs".to_string(),
|
||||
max_file_size: "10MB".to_string(),
|
||||
max_files: 10,
|
||||
},
|
||||
daemon: DaemonConfig {
|
||||
pid_file: "./voice_cli.pid".to_string(),
|
||||
log_file: "./logs/daemon.log".to_string(),
|
||||
work_dir: "./work".to_string(),
|
||||
},
|
||||
task_management: TaskManagementConfig::default(),
|
||||
tts: TtsConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_config_validation() {
|
||||
let config = create_valid_config();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_host_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.server.host = "".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("Server host cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_port_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.server.port = 0;
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Server port must be between 1 and 65535")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_too_high_validation() {
|
||||
// Note: We can't test with 70000 as it doesn't fit in u16
|
||||
// This test verifies the validation logic exists, but the type system
|
||||
// already prevents invalid port values at compile time
|
||||
let mut config = create_valid_config();
|
||||
config.server.port = 65535; // Maximum valid port
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_ok()); // Should be valid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_max_file_size_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.server.max_file_size = 0;
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Max file size must be greater than 0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_default_model_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.whisper.default_model = "".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("Default model cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_models_dir_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.whisper.models_dir = "".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Models directory cannot be empty")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_transcription_workers_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.whisper.workers.transcription_workers = 0;
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Transcription workers must be greater than 0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_log_level_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.logging.level = "invalid".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("Invalid log level"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_log_levels() {
|
||||
let valid_levels = ["trace", "debug", "info", "warn", "error"];
|
||||
|
||||
for level in &valid_levels {
|
||||
let mut config = create_valid_config();
|
||||
config.logging.level = level.to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_ok(), "Log level '{}' should be valid", level);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_log_levels() {
|
||||
let levels = ["TRACE", "Debug", "INFO", "Warn", "ERROR"];
|
||||
|
||||
for level in &levels {
|
||||
let mut config = create_valid_config();
|
||||
config.logging.level = level.to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Log level '{}' should be valid (case insensitive)",
|
||||
level
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_log_dir_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.logging.log_dir = "".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("Log directory cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_max_files_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.logging.max_files = 0;
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Max log files must be greater than 0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_work_dir_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.daemon.work_dir = "".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("Work directory cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_pid_file_validation() {
|
||||
let mut config = create_valid_config();
|
||||
config.daemon.pid_file = "".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("PID file path cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_validation_errors() {
|
||||
let mut config = create_valid_config();
|
||||
config.server.host = "".to_string();
|
||||
config.server.port = 0;
|
||||
config.whisper.default_model = "".to_string();
|
||||
config.logging.level = "invalid".to_string();
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
// Should report the first error encountered
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.to_string().contains("Server host cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_file_loading_and_validation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
// Write invalid config
|
||||
let invalid_config_yaml = r#"
|
||||
server:
|
||||
host: ""
|
||||
port: 0
|
||||
whisper:
|
||||
default_model: ""
|
||||
"#;
|
||||
std::fs::write(&config_path, invalid_config_yaml).unwrap();
|
||||
|
||||
// Loading should fail due to validation
|
||||
let result = Config::load_or_create(&config_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_path_helpers() {
|
||||
let config = create_valid_config();
|
||||
|
||||
// Test path helper methods
|
||||
let models_path = config.models_dir_path();
|
||||
assert_eq!(models_path, PathBuf::from("./models"));
|
||||
|
||||
let log_path = config.log_dir_path();
|
||||
assert_eq!(log_path, PathBuf::from("./logs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_port_values() {
|
||||
let mut config = create_valid_config();
|
||||
|
||||
// Test minimum valid port
|
||||
config.server.port = 1;
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test maximum valid port
|
||||
config.server.port = 65535;
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Note: Can't test 65536 as it doesn't fit in u16
|
||||
// The type system prevents invalid port values at compile time
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcription_workers_boundary() {
|
||||
let mut config = create_valid_config();
|
||||
|
||||
// Test minimum valid workers
|
||||
config.whisper.workers.transcription_workers = 1;
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test high number of workers
|
||||
config.whisper.workers.transcription_workers = 100;
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_files_boundary() {
|
||||
let mut config = create_valid_config();
|
||||
|
||||
// Test minimum valid max files
|
||||
config.logging.max_files = 1;
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test high number of files
|
||||
config.logging.max_files = 1000;
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#[cfg(test)]
|
||||
mod graceful_shutdown_tests {
|
||||
use crate::models::Config;
|
||||
use crate::server;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graceful_shutdown_mechanism() {
|
||||
// Test that the broadcast channel shutdown mechanism works
|
||||
let (shutdown_tx, _) = broadcast::channel(1);
|
||||
let mut shutdown_rx = shutdown_tx.subscribe();
|
||||
|
||||
// Test sending and receiving shutdown signals
|
||||
let send_handle = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
info!("Sending shutdown signal...");
|
||||
shutdown_tx
|
||||
.send(())
|
||||
.expect("Failed to send shutdown signal");
|
||||
});
|
||||
|
||||
let receive_handle = tokio::spawn(async move {
|
||||
info!("Waiting for shutdown signal...");
|
||||
let result = shutdown_rx.recv().await;
|
||||
assert!(result.is_ok(), "Should receive shutdown signal");
|
||||
info!("Received shutdown signal successfully");
|
||||
});
|
||||
|
||||
// Wait for both tasks to complete
|
||||
let (send_result, receive_result) = tokio::join!(send_handle, receive_handle);
|
||||
|
||||
assert!(
|
||||
send_result.is_ok(),
|
||||
"Send task should complete successfully"
|
||||
);
|
||||
assert!(
|
||||
receive_result.is_ok(),
|
||||
"Receive task should complete successfully"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_app_state_shutdown() {
|
||||
// Test that AppState can be created and shut down gracefully
|
||||
let config = Config::default();
|
||||
let config_arc = Arc::new(config);
|
||||
|
||||
let app_state = server::handlers::AppState::new(config_arc.clone())
|
||||
.await
|
||||
.expect("Failed to create app state");
|
||||
|
||||
// Test that shutdown works
|
||||
app_state.shutdown().await;
|
||||
|
||||
info!("AppState shutdown completed successfully");
|
||||
}
|
||||
}
|
||||
11
qiming-mcp-proxy/voice-cli/src/tests/mod.rs
Normal file
11
qiming-mcp-proxy/voice-cli/src/tests/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#[cfg(test)]
|
||||
pub mod config_env_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod config_validation_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod task_management_integration_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod graceful_shutdown_test;
|
||||
@@ -0,0 +1,55 @@
|
||||
#[cfg(test)]
|
||||
mod task_management_integration_tests {
|
||||
use crate::models::{AsyncTranscriptionTask, Config, TaskManagementConfig, TaskStatus};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn create_test_config() -> (Arc<Config>, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test_tasks.db");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.task_management = TaskManagementConfig {
|
||||
max_concurrent_tasks: 2,
|
||||
retry_attempts: 2,
|
||||
task_timeout_seconds: 30,
|
||||
catch_panic: true,
|
||||
sqlite_db_path: db_path.to_string_lossy().to_string(),
|
||||
task_retention_minutes: 1440, // 24 hours in minutes
|
||||
sled_db_path: "./data/sled".to_string(),
|
||||
};
|
||||
|
||||
(Arc::new(config), temp_dir)
|
||||
}
|
||||
|
||||
// #[tokio::test]
|
||||
// async fn test_task_store_crud_operations() {
|
||||
// let (config, _temp_dir) = create_test_config().await;
|
||||
// let task_store = Arc::new(TaskStore::from_config(&config.task_management).await.unwrap());
|
||||
|
||||
// // Create test task
|
||||
// let task = AsyncTranscriptionTask::new(
|
||||
// "test-crud-task".to_string(),
|
||||
// PathBuf::from("test.mp3"),
|
||||
// "test.mp3".to_string(),
|
||||
// Some("base".to_string()),
|
||||
// Some("json".to_string()),
|
||||
// );
|
||||
|
||||
// // Test save task
|
||||
// task_store.save_task("test-crud-task", &task).await.unwrap();
|
||||
|
||||
// // Test get task
|
||||
// let retrieved_task = task_store.get_task("test-crud-task").await.unwrap();
|
||||
// assert!(retrieved_task.is_some());
|
||||
// let retrieved_task = retrieved_task.unwrap();
|
||||
// assert_eq!(retrieved_task.task_id, "test-crud-task");
|
||||
// assert_eq!(retrieved_task.original_filename, "test.mp3");
|
||||
|
||||
// // Test get status
|
||||
// let status = task_store.get_status("test-crud-task").await.unwrap();
|
||||
// assert!(status.is_some());
|
||||
// assert!(matches!(status.unwrap(), TaskStatus::Pending { .. }));
|
||||
// }
|
||||
}
|
||||
147
qiming-mcp-proxy/voice-cli/src/utils/cleanup.rs
Normal file
147
qiming-mcp-proxy/voice-cli/src/utils/cleanup.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use std::path::Path;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Perform global cleanup operations during shutdown
|
||||
pub async fn perform_shutdown_cleanup() {
|
||||
info!("Starting shutdown cleanup operations");
|
||||
|
||||
// Clean up any remaining temporary files
|
||||
cleanup_temp_directories().await;
|
||||
|
||||
// Flush any remaining logs
|
||||
flush_logs().await;
|
||||
|
||||
info!("Shutdown cleanup completed");
|
||||
}
|
||||
|
||||
/// Clean up temporary directories and files
|
||||
async fn cleanup_temp_directories() {
|
||||
let temp_patterns = [
|
||||
"/tmp/voice-cli-*",
|
||||
"./temp/voice-cli-*",
|
||||
"./tmp/voice-cli-*",
|
||||
];
|
||||
|
||||
for pattern in &temp_patterns {
|
||||
if let Some(parent) = Path::new(pattern).parent() {
|
||||
if parent.exists() {
|
||||
match std::fs::read_dir(parent) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name() {
|
||||
if name.to_string_lossy().starts_with("voice-cli-") {
|
||||
if path.is_file() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to cleanup temp file {:?}: {}", path, e);
|
||||
} else {
|
||||
info!("Cleaned up temp file: {:?}", path);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&path) {
|
||||
warn!(
|
||||
"Failed to cleanup temp directory {:?}: {}",
|
||||
path, e
|
||||
);
|
||||
} else {
|
||||
info!("Cleaned up temp directory: {:?}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to read temp directory {:?}: {}", parent, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush any remaining logs
|
||||
async fn flush_logs() {
|
||||
// Give the logging system a moment to flush any remaining logs
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Force flush tracing subscriber if possible
|
||||
// Note: This is a best-effort operation
|
||||
info!("Log flush completed");
|
||||
}
|
||||
|
||||
/// Clean up specific temporary files
|
||||
pub fn cleanup_files(files: &[std::path::PathBuf]) {
|
||||
for file in files {
|
||||
if file.exists() {
|
||||
if let Err(e) = std::fs::remove_file(file) {
|
||||
warn!("Failed to cleanup file {:?}: {}", file, e);
|
||||
} else {
|
||||
info!("Cleaned up file: {:?}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up a specific directory and all its contents
|
||||
pub fn cleanup_directory<P: AsRef<Path>>(dir: P) -> Result<(), std::io::Error> {
|
||||
let dir = dir.as_ref();
|
||||
if dir.exists() && dir.is_dir() {
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
info!("Cleaned up directory: {:?}", dir);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file1 = temp_dir.path().join("test1.txt");
|
||||
let file2 = temp_dir.path().join("test2.txt");
|
||||
|
||||
// Create test files
|
||||
File::create(&file1).unwrap();
|
||||
File::create(&file2).unwrap();
|
||||
|
||||
assert!(file1.exists());
|
||||
assert!(file2.exists());
|
||||
|
||||
// Cleanup files
|
||||
cleanup_files(&[file1.clone(), file2.clone()]);
|
||||
|
||||
assert!(!file1.exists());
|
||||
assert!(!file2.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let test_dir = temp_dir.path().join("test_cleanup");
|
||||
std::fs::create_dir(&test_dir).unwrap();
|
||||
|
||||
// Create a file in the directory
|
||||
let test_file = test_dir.join("test.txt");
|
||||
File::create(&test_file).unwrap();
|
||||
|
||||
assert!(test_dir.exists());
|
||||
assert!(test_file.exists());
|
||||
|
||||
// Cleanup directory
|
||||
cleanup_directory(&test_dir).unwrap();
|
||||
|
||||
assert!(!test_dir.exists());
|
||||
assert!(!test_file.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_perform_shutdown_cleanup() {
|
||||
// This test just ensures the function runs without panicking
|
||||
perform_shutdown_cleanup().await;
|
||||
}
|
||||
}
|
||||
253
qiming-mcp-proxy/voice-cli/src/utils/mime_types.rs
Normal file
253
qiming-mcp-proxy/voice-cli/src/utils/mime_types.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use std::path::Path;
|
||||
use url::Url;
|
||||
|
||||
/// MIME 类型到文件扩展名的映射
|
||||
/// 专注于音视频格式,其他类型统一使用 bin 扩展名
|
||||
pub fn mime_type_to_extension(content_type: &str) -> &'static str {
|
||||
match content_type {
|
||||
// 音频类型
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/wav" => "wav",
|
||||
"audio/flac" => "flac",
|
||||
"audio/mp4" => "m4a",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/aac" => "aac",
|
||||
"audio/opus" => "opus",
|
||||
"audio/webm" => "webm",
|
||||
"audio/x-matroska" => "mka",
|
||||
"audio/x-ms-wma" => "wma",
|
||||
"audio/x-wav" => "wav",
|
||||
"audio/vnd.wave" => "wav",
|
||||
"audio/x-aiff" => "aiff",
|
||||
"audio/aiff" => "aiff",
|
||||
"audio/x-caf" => "caf",
|
||||
"audio/x-m4a" => "m4a",
|
||||
"audio/x-mpeg" => "mp3",
|
||||
"audio/x-ogg" => "ogg",
|
||||
|
||||
// 视频类型
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"video/x-matroska" => "mkv",
|
||||
"video/x-msvideo" => "avi",
|
||||
"video/quicktime" => "mov",
|
||||
"video/x-ms-wmv" => "wmv",
|
||||
"video/x-flv" => "flv",
|
||||
"video/3gpp" => "3gp",
|
||||
"video/3gpp2" => "3g2",
|
||||
"video/mpeg" => "mpeg",
|
||||
"video/x-mpeg" => "mpeg",
|
||||
|
||||
// 其他所有类型统一使用 bin 扩展名
|
||||
_ => {
|
||||
// 对于非音视频类型,直接使用默认扩展名,不输出警告日志
|
||||
"bin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 URL 中提取文件扩展名
|
||||
pub fn extract_extension_from_url(url: &str) -> Option<&'static str> {
|
||||
if let Ok(parsed_url) = Url::parse(url) {
|
||||
parsed_url
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last())
|
||||
.and_then(|filename| {
|
||||
if let Some(dot_index) = filename.rfind('.') {
|
||||
let ext = &filename[dot_index + 1..];
|
||||
// 将常见扩展名映射为标准格式
|
||||
match ext.to_lowercase().as_str() {
|
||||
"mp3" => Some("mp3"),
|
||||
"wav" => Some("wav"),
|
||||
"flac" => Some("flac"),
|
||||
"m4a" => Some("m4a"),
|
||||
"mp4" => Some("mp4"),
|
||||
"mov" => Some("mov"),
|
||||
"avi" => Some("avi"),
|
||||
"mkv" => Some("mkv"),
|
||||
"webm" => Some("webm"),
|
||||
"ogg" => Some("ogg"),
|
||||
"aac" => Some("aac"),
|
||||
"opus" => Some("opus"),
|
||||
"wma" => Some("wma"),
|
||||
"flv" => Some("flv"),
|
||||
"3gp" => Some("3gp"),
|
||||
"caf" => Some("caf"),
|
||||
"aiff" => Some("aiff"),
|
||||
"bin" => Some("bin"),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 从文件路径推断 MIME 类型
|
||||
pub fn infer_mime_type_from_path<P: AsRef<Path>>(path: P) -> Option<&'static str> {
|
||||
infer::get_from_path(path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|kind| kind.mime_type())
|
||||
}
|
||||
|
||||
/// 从文件扩展名获取 MIME 类型
|
||||
pub fn extension_to_mime_type(extension: &str) -> &'static str {
|
||||
match extension.to_lowercase().as_str() {
|
||||
"mp3" => "audio/mpeg",
|
||||
"wav" => "audio/wav",
|
||||
"flac" => "audio/flac",
|
||||
"m4a" => "audio/mp4",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
"ogg" => "audio/ogg",
|
||||
"aac" => "audio/aac",
|
||||
"opus" => "audio/opus",
|
||||
"wma" => "audio/x-ms-wma",
|
||||
"flv" => "video/x-flv",
|
||||
"3gp" => "video/3gpp",
|
||||
"caf" => "audio/x-caf",
|
||||
"aiff" => "audio/x-aiff",
|
||||
"bin" => "application/octet-stream",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能获取文件扩展名(优先使用 MIME 类型,回退到 URL 扩展名)
|
||||
pub fn get_file_extension(content_type: &str, url: &str) -> &'static str {
|
||||
let extension = mime_type_to_extension(content_type);
|
||||
|
||||
// 如果得到的是默认扩展名,尝试从 URL 中获取更精确的扩展名
|
||||
if extension == "bin" {
|
||||
if let Some(url_extension) = extract_extension_from_url(url) {
|
||||
return url_extension;
|
||||
}
|
||||
}
|
||||
|
||||
extension
|
||||
}
|
||||
|
||||
/// 检查是否为支持的音频格式
|
||||
pub fn is_supported_audio_format(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"audio/mpeg" | // MP3
|
||||
"audio/wav" | // WAV
|
||||
"audio/flac" | // FLAC
|
||||
"audio/mp4" | // M4A
|
||||
"audio/ogg" | // OGG
|
||||
"audio/aac" | // AAC
|
||||
"audio/opus" | // Opus
|
||||
"audio/webm" | // WebM Audio
|
||||
"audio/x-matroska" | // Matroska Audio
|
||||
"audio/x-wav" | // WAV (alternative)
|
||||
"audio/vnd.wave" | // WAV (alternative)
|
||||
"audio/x-aiff" | // AIFF
|
||||
"audio/aiff" | // AIFF (alternative)
|
||||
"audio/x-caf" // CAF
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查是否为支持的视频格式
|
||||
pub fn is_supported_video_format(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"video/mp4" | // MP4
|
||||
"video/webm" | // WebM
|
||||
"video/x-matroska" | // MKV
|
||||
"video/x-msvideo" | // AVI
|
||||
"video/quicktime" | // MOV
|
||||
"video/x-ms-wmv" | // WMV
|
||||
"video/x-flv" | // FLV
|
||||
"video/3gpp" | // 3GP
|
||||
"video/3gpp2" | // 3G2
|
||||
"video/mpeg" | // MPEG
|
||||
"video/x-mpeg" // MPEG (alternative)
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查是否为支持的媒体格式
|
||||
pub fn is_supported_media_format(mime_type: &str) -> bool {
|
||||
is_supported_audio_format(mime_type) || is_supported_video_format(mime_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mime_type_to_extension() {
|
||||
assert_eq!(mime_type_to_extension("audio/mpeg"), "mp3");
|
||||
assert_eq!(mime_type_to_extension("audio/wav"), "wav");
|
||||
assert_eq!(mime_type_to_extension("video/mp4"), "mp4");
|
||||
assert_eq!(mime_type_to_extension("unknown/type"), "bin");
|
||||
assert_eq!(mime_type_to_extension("text/plain"), "bin");
|
||||
assert_eq!(mime_type_to_extension("application/json"), "bin");
|
||||
assert_eq!(mime_type_to_extension("image/jpeg"), "bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_extension_from_url() {
|
||||
assert_eq!(
|
||||
extract_extension_from_url("https://example.com/test.mp3"),
|
||||
Some("mp3")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_extension_from_url("https://example.com/test.wav"),
|
||||
Some("wav")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_extension_from_url("https://example.com/test?format=mp3"),
|
||||
None
|
||||
);
|
||||
assert_eq!(extract_extension_from_url("invalid-url"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_to_mime_type() {
|
||||
assert_eq!(extension_to_mime_type("mp3"), "audio/mpeg");
|
||||
assert_eq!(extension_to_mime_type("wav"), "audio/wav");
|
||||
assert_eq!(extension_to_mime_type("mp4"), "video/mp4");
|
||||
assert_eq!(
|
||||
extension_to_mime_type("unknown"),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_extension() {
|
||||
assert_eq!(
|
||||
get_file_extension("audio/mpeg", "https://example.com/test.mp3"),
|
||||
"mp3"
|
||||
);
|
||||
assert_eq!(
|
||||
get_file_extension("unknown/type", "https://example.com/test.wav"),
|
||||
"wav"
|
||||
);
|
||||
assert_eq!(
|
||||
get_file_extension("unknown/type", "https://example.com/test"),
|
||||
"bin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supported_formats() {
|
||||
assert!(is_supported_audio_format("audio/mpeg"));
|
||||
assert!(is_supported_audio_format("audio/wav"));
|
||||
assert!(!is_supported_audio_format("video/mp4"));
|
||||
|
||||
assert!(is_supported_video_format("video/mp4"));
|
||||
assert!(is_supported_video_format("video/webm"));
|
||||
assert!(!is_supported_video_format("audio/mpeg"));
|
||||
|
||||
assert!(is_supported_media_format("audio/mpeg"));
|
||||
assert!(is_supported_media_format("video/mp4"));
|
||||
assert!(!is_supported_media_format("unknown/type"));
|
||||
}
|
||||
}
|
||||
229
qiming-mcp-proxy/voice-cli/src/utils/mod.rs
Normal file
229
qiming-mcp-proxy/voice-cli/src/utils/mod.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
pub mod cleanup;
|
||||
pub mod mime_types;
|
||||
pub mod signal_handling;
|
||||
pub mod task_id;
|
||||
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::Config;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{Level, info};
|
||||
use tracing_appender::rolling::{Builder, Rotation};
|
||||
use tracing_subscriber::{EnvFilter, prelude::*};
|
||||
|
||||
// Re-export signal handling components
|
||||
pub use cleanup::perform_shutdown_cleanup;
|
||||
pub use mime_types::{
|
||||
extension_to_mime_type, extract_extension_from_url, get_file_extension,
|
||||
infer_mime_type_from_path, is_supported_audio_format, is_supported_media_format,
|
||||
is_supported_video_format, mime_type_to_extension,
|
||||
};
|
||||
pub use signal_handling::{
|
||||
create_combined_shutdown_signal, create_service_shutdown_signal, create_shutdown_signal,
|
||||
handle_system_signals,
|
||||
};
|
||||
pub use task_id::generate_task_id;
|
||||
|
||||
/// Initialize logging based on configuration
|
||||
/// The guard is stored globally to ensure logging stays active
|
||||
pub fn init_logging(config: &Config) -> crate::Result<()> {
|
||||
// Check if logging is already initialized
|
||||
if tracing::dispatcher::has_been_set() {
|
||||
// Reset logging to allow proper file logging setup
|
||||
// Use the proper method to clear the global dispatcher
|
||||
tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::default()).ok();
|
||||
}
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
let log_dir = config.log_dir_path();
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
|
||||
// Parse log level
|
||||
let level = parse_log_level(&config.logging.level)?;
|
||||
|
||||
// Create file appender with rotation and max_log_files
|
||||
let file_appender = Builder::new()
|
||||
.rotation(Rotation::DAILY)
|
||||
.filename_prefix("voice-cli")
|
||||
.max_log_files(config.logging.max_files as usize)
|
||||
.build(&log_dir)
|
||||
.map_err(|e| {
|
||||
VoiceCliError::Config(format!("Failed to initialize log file appender: {}", e))
|
||||
})?;
|
||||
|
||||
// Create console layer with proper formatting
|
||||
let console_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(true);
|
||||
|
||||
// Create file layer with detailed formatting
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(file_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false);
|
||||
|
||||
// Create filters based on configured log level
|
||||
let console_filter = EnvFilter::new(&format!("{}", level));
|
||||
let file_filter = EnvFilter::new(&format!("{}", level));
|
||||
|
||||
// Create the subscriber with both layers
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(console_layer.with_filter(console_filter))
|
||||
.with(file_layer.with_filter(file_filter));
|
||||
|
||||
// Set as global default and get the guard
|
||||
let _ = tracing::subscriber::set_global_default(subscriber)
|
||||
.map_err(|e| VoiceCliError::Config(format!("Failed to set global subscriber: {}", e)))?;
|
||||
|
||||
// Store the guard globally to keep logging active
|
||||
|
||||
info!(
|
||||
"Logging initialized - Level: {}, Directory: {:?}",
|
||||
config.logging.level, log_dir
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse log level string to tracing Level
|
||||
fn parse_log_level(level_str: &str) -> crate::Result<Level> {
|
||||
match level_str.to_lowercase().as_str() {
|
||||
"trace" => Ok(Level::TRACE),
|
||||
"debug" => Ok(Level::DEBUG),
|
||||
"info" => Ok(Level::INFO),
|
||||
"warn" | "warning" => Ok(Level::WARN),
|
||||
"error" => Ok(Level::ERROR),
|
||||
_ => Err(VoiceCliError::Config(format!(
|
||||
"Invalid log level: {}. Valid levels: trace, debug, info, warn, error",
|
||||
level_str
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up old log files based on configuration
|
||||
pub fn cleanup_old_logs(config: &Config) -> crate::Result<()> {
|
||||
let log_dir = config.log_dir_path();
|
||||
|
||||
if !log_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_files = config.logging.max_files as usize;
|
||||
|
||||
// Get all log files
|
||||
let mut log_files: Vec<_> = std::fs::read_dir(&log_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext == "log")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by modification time (newest first)
|
||||
log_files.sort_by_key(|entry| {
|
||||
entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
log_files.reverse();
|
||||
|
||||
// Remove old files
|
||||
if log_files.len() > max_files {
|
||||
for old_file in log_files.iter().skip(max_files) {
|
||||
if let Err(e) = std::fs::remove_file(old_file.path()) {
|
||||
tracing::warn!("Failed to remove old log file {:?}: {}", old_file.path(), e);
|
||||
} else {
|
||||
tracing::debug!("Removed old log file: {:?}", old_file.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current executable path
|
||||
pub fn get_current_exe_path() -> crate::Result<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| VoiceCliError::Config(format!("Failed to get current executable path: {}", e)))
|
||||
}
|
||||
|
||||
/// Check if a port is available
|
||||
pub fn is_port_available(host: &str, port: u16) -> bool {
|
||||
match std::net::TcpListener::bind(format!("{}:{}", host, port)) {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a safe filename from a string
|
||||
pub fn safe_filename(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
|
||||
_ => '_',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get system information for debugging
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
SystemInfo {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
family: std::env::consts::FAMILY.to_string(),
|
||||
exe_suffix: std::env::consts::EXE_SUFFIX.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SystemInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub family: String,
|
||||
pub exe_suffix: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_level() {
|
||||
assert!(matches!(parse_log_level("info"), Ok(Level::INFO)));
|
||||
assert!(matches!(parse_log_level("INFO"), Ok(Level::INFO)));
|
||||
assert!(matches!(parse_log_level("debug"), Ok(Level::DEBUG)));
|
||||
assert!(matches!(parse_log_level("error"), Ok(Level::ERROR)));
|
||||
assert!(parse_log_level("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_filename() {
|
||||
assert_eq!(safe_filename("hello world"), "hello_world");
|
||||
assert_eq!(safe_filename("test-file.mp3"), "test-file.mp3");
|
||||
assert_eq!(safe_filename("special@#$chars"), "special___chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_port_available() {
|
||||
// Test with a likely available port
|
||||
assert!(is_port_available("127.0.0.1", 0)); // Port 0 should always be available for testing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_system_info() {
|
||||
let info = get_system_info();
|
||||
assert!(!info.os.is_empty());
|
||||
assert!(!info.arch.is_empty());
|
||||
}
|
||||
}
|
||||
206
qiming-mcp-proxy/voice-cli/src/utils/signal_handling.rs
Normal file
206
qiming-mcp-proxy/voice-cli/src/utils/signal_handling.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! Shared signal handling utilities
|
||||
//!
|
||||
//! This module provides unified signal handling for background services,
|
||||
//! eliminating duplicate signal handling code across different services.
|
||||
|
||||
use tokio::signal;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// Create a unified shutdown signal handler that listens to multiple sources
|
||||
///
|
||||
/// This function provides consistent signal handling across all services:
|
||||
/// - Ctrl+C (SIGINT)
|
||||
/// - SIGTERM (on Unix platforms)
|
||||
/// - Manual shutdown signals
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use voice_cli::utils::signal_handling::create_shutdown_signal;
|
||||
/// use tracing::info;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let shutdown_signal = create_shutdown_signal();
|
||||
/// tokio::select! {
|
||||
/// _ = async { /* service_work */ } => {},
|
||||
/// _ = shutdown_signal => {
|
||||
/// info!("Received shutdown signal, stopping service");
|
||||
/// }
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn create_shutdown_signal() {
|
||||
handle_system_signals().await
|
||||
}
|
||||
|
||||
/// Handle system signals for graceful shutdown
|
||||
///
|
||||
/// This provides the core signal handling logic used by all services.
|
||||
/// Separated into its own function for reusability and testing.
|
||||
pub async fn handle_system_signals() {
|
||||
let ctrl_c = async {
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
error!("Failed to listen for Ctrl+C: {}", e);
|
||||
} else {
|
||||
info!("Received Ctrl+C signal");
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
match signal(SignalKind::terminate()) {
|
||||
Ok(mut stream) => {
|
||||
stream.recv().await;
|
||||
info!("Received SIGTERM signal");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to listen for SIGTERM: {}", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => debug!("Ctrl+C signal handled"),
|
||||
_ = terminate => debug!("SIGTERM signal handled"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a combined shutdown signal that listens to both system signals and manual channels
|
||||
///
|
||||
/// This is useful for services that need to respond to both system signals (Ctrl+C, SIGTERM)
|
||||
/// and programmatic shutdown requests via channels.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `manual_shutdown` - A future that completes when manual shutdown is requested
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use voice_cli::utils::signal_handling::create_combined_shutdown_signal;
|
||||
/// use tracing::info;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
/// let shutdown_signal = create_combined_shutdown_signal(async {
|
||||
/// shutdown_rx.await.ok();
|
||||
/// });
|
||||
///
|
||||
/// tokio::select! {
|
||||
/// _ = async { /* service_work */ } => {},
|
||||
/// _ = shutdown_signal => {
|
||||
/// info!("Received shutdown signal");
|
||||
/// }
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn create_combined_shutdown_signal<F>(manual_shutdown: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()>,
|
||||
{
|
||||
tokio::select! {
|
||||
_ = handle_system_signals() => {
|
||||
info!("Received system shutdown signal");
|
||||
}
|
||||
_ = manual_shutdown => {
|
||||
info!("Received manual shutdown signal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a shutdown signal with service-specific logging
|
||||
///
|
||||
/// This provides service-specific logging messages for better debugging.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `service_name` - Name of the service for logging context
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use voice_cli::utils::signal_handling::create_service_shutdown_signal;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let shutdown_signal = create_service_shutdown_signal("http-server");
|
||||
/// tokio::select! {
|
||||
/// _ = async { /* service_work */ } => {},
|
||||
/// _ = shutdown_signal => {}
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn create_service_shutdown_signal(service_name: &str) {
|
||||
let ctrl_c = async {
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
error!("Failed to listen for Ctrl+C in {}: {}", service_name, e);
|
||||
} else {
|
||||
info!("{} received Ctrl+C signal", service_name);
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
match signal(SignalKind::terminate()) {
|
||||
Ok(mut stream) => {
|
||||
stream.recv().await;
|
||||
info!("{} received SIGTERM signal", service_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to listen for SIGTERM in {}: {}", service_name, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => debug!("{} handled Ctrl+C signal", service_name),
|
||||
_ = terminate => debug!("{} handled SIGTERM signal", service_name),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signal_handling_timeout() {
|
||||
// Test that the signal handlers don't hang indefinitely
|
||||
let result = timeout(Duration::from_millis(100), create_shutdown_signal()).await;
|
||||
|
||||
// Should timeout since no signals are sent
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_combined_shutdown_manual() {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
// Start the combined signal handler
|
||||
let signal_future = create_combined_shutdown_signal(async move {
|
||||
rx.await.ok();
|
||||
});
|
||||
|
||||
// Send manual shutdown signal
|
||||
let _ = tx.send(());
|
||||
|
||||
// Should complete quickly due to manual signal
|
||||
let result = timeout(Duration::from_millis(100), signal_future).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_service_shutdown_signal_timeout() {
|
||||
// Test service-specific signal handling
|
||||
let result = timeout(
|
||||
Duration::from_millis(100),
|
||||
create_service_shutdown_signal("test-service"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Should timeout since no signals are sent
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
54
qiming-mcp-proxy/voice-cli/src/utils/task_id.rs
Normal file
54
qiming-mcp-proxy/voice-cli/src/utils/task_id.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
/// Task ID generation utilities
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Generate a clean task ID with only alphanumeric characters
|
||||
/// Format: "task" + 32-character hexadecimal string (from UUID v7)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use voice_cli::utils::task_id::generate_task_id;
|
||||
///
|
||||
/// let task_id = generate_task_id();
|
||||
/// assert!(task_id.starts_with("task"));
|
||||
/// assert!(!task_id.contains('-'));
|
||||
/// assert!(!task_id.contains('_'));
|
||||
/// assert_eq!(task_id.len(), 36); // "task" + 32 hex chars
|
||||
/// ```
|
||||
pub fn generate_task_id() -> String {
|
||||
let uuid = Uuid::now_v7().to_string();
|
||||
// 移除所有连字符和下划线,只保留字母和数字
|
||||
let cleaned_uuid = uuid.replace(['-', '_'], "");
|
||||
format!("task{}", cleaned_uuid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_task_id_format() {
|
||||
let task_id = generate_task_id();
|
||||
|
||||
// Check format
|
||||
assert!(task_id.starts_with("task"));
|
||||
assert!(!task_id.contains('-'));
|
||||
assert!(!task_id.contains('_'));
|
||||
|
||||
// Check length (task + 32 hex chars)
|
||||
assert_eq!(task_id.len(), 36);
|
||||
|
||||
// Check that it's all alphanumeric after "task" prefix
|
||||
let suffix = &task_id[4..];
|
||||
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_task_id_uniqueness() {
|
||||
let id1 = generate_task_id();
|
||||
let id2 = generate_task_id();
|
||||
|
||||
// Should be different (due to timestamp in UUID v7)
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user