提交qiming-mcp-proxy

This commit is contained in:
Codex
2026-06-01 13:03:20 +08:00
parent 9e9486b7c2
commit afb3d9f4e6
394 changed files with 124494 additions and 0 deletions

View 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

View 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(())
}

View 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,
}
}

View 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,
}

View 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");
}
}

View 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>;

View 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;

View 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());
}
}

View 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(())
}
}

View 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),
}
}
}

View 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>,
}

View 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>,
}

View 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
}
}

View 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
}
}

View 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()
}

View 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");
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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");
}
}

View 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()),
)
}

View 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(())
}

View 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());
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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());
}
}

View 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)
));
}
}

View 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"));
}
}

View 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};

View 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());
}
}

View 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
}
}

View 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);
}
}

View 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());
}
}

View 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");
}
}

View 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());
}
}

View File

@@ -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");
}
}

View 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;

View File

@@ -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 { .. }));
// }
}

View 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;
}
}

View 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"));
}
}

View 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());
}
}

View 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());
}
}

View 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);
}
}