use log::{debug, warn};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::Path;
use std::time::{Duration, Instant};
use crate::config::SyncConfig;
use crate::core::formats::Subtitle;
use crate::services::vad::VadSyncDetector;
use crate::{Result, error::SubXError};
pub struct SyncEngine {
config: SyncConfig,
vad_detector: Option<VadSyncDetector>,
}
impl SyncEngine {
pub fn new(config: SyncConfig) -> Result<Self> {
let vad_detector = if config.vad.enabled {
match VadSyncDetector::new(config.vad.clone()) {
Ok(det) => {
debug!(
"[SyncEngine] VAD detector initialized successfully with config: {:?}",
config.vad
);
Some(det)
}
Err(e) => {
warn!("[SyncEngine] VAD initialization failed: {}", e);
None
}
}
} else {
debug!("[SyncEngine] VAD is disabled in config");
None
};
if vad_detector.is_none() {
warn!("[SyncEngine] VAD detector is required but not available");
return Err(SubXError::config(
"VAD detector is required but not available",
));
}
debug!("[SyncEngine] SyncEngine created with VAD detector");
Ok(Self {
config,
vad_detector,
})
}
pub async fn detect_sync_offset(
&self,
audio_path: &Path,
subtitle: &Subtitle,
method: Option<SyncMethod>,
) -> Result<SyncResult> {
debug!(
"[SyncEngine] detect_sync_offset called | audio_path: {:?}, subtitle entries: {}, method: {:?}",
audio_path,
subtitle.entries.len(),
method
);
let start = Instant::now();
let m = method.unwrap_or_else(|| self.determine_default_method());
debug!("[SyncEngine] Using sync method: {:?}", m);
let mut res = match m {
SyncMethod::Auto | SyncMethod::LocalVad => {
self.vad_detect_sync_offset(audio_path, subtitle).await?
}
SyncMethod::Manual => {
debug!("[SyncEngine] Manual method selected but not supported in this context");
return Err(SubXError::config("Manual method requires explicit offset"));
}
};
res.processing_duration = start.elapsed();
debug!(
"[SyncEngine] detect_sync_offset finished | offset_seconds: {:.3}, confidence: {:.3}, duration_ms: {}",
res.offset_seconds,
res.confidence,
res.processing_duration.as_millis()
);
Ok(res)
}
async fn auto_detect_sync_offset(
&self,
audio_path: &Path,
subtitle: &Subtitle,
) -> Result<SyncResult> {
debug!(
"[SyncEngine] auto_detect_sync_offset called | audio_path: {:?}, subtitle entries: {}",
audio_path,
subtitle.entries.len()
);
if self.vad_detector.is_some() {
return self.vad_detect_sync_offset(audio_path, subtitle).await;
}
Err(SubXError::audio_processing(
"No detector available in auto mode",
))
}
pub fn apply_manual_offset(
&self,
subtitle: &mut Subtitle,
offset_seconds: f32,
) -> Result<SyncResult> {
debug!(
"[SyncEngine] apply_manual_offset called | offset_seconds: {:.3}, entries: {}",
offset_seconds,
subtitle.entries.len()
);
if offset_seconds.abs() > self.config.max_offset_seconds {
warn!(
"[SyncEngine] Offset {:.2}s exceeds maximum allowed value {:.2}s. Aborting.",
offset_seconds, self.config.max_offset_seconds
);
return Err(SubXError::config(format!(
"Offset {:.2}s exceeds maximum allowed value {:.2}s. Please check the sync.max_offset_seconds configuration or use a smaller offset.",
offset_seconds, self.config.max_offset_seconds
)));
}
let start = Instant::now();
for entry in &mut subtitle.entries {
let offset_dur = Duration::from_secs_f32(offset_seconds.abs());
if offset_seconds >= 0.0 {
entry.start_time = entry.start_time.checked_add(offset_dur).ok_or_else(|| {
SubXError::audio_processing("Invalid offset results in negative time")
})?;
entry.end_time = entry.end_time.checked_add(offset_dur).ok_or_else(|| {
SubXError::audio_processing("Invalid offset results in negative time")
})?;
} else {
entry.start_time = if entry.start_time > offset_dur {
entry.start_time - offset_dur
} else {
Duration::ZERO
};
entry.end_time = if entry.end_time > offset_dur {
entry.end_time - offset_dur
} else {
Duration::ZERO
};
}
}
debug!(
"[SyncEngine] Manual offset applied to all entries | offset_seconds: {:.3}",
offset_seconds
);
Ok(SyncResult {
offset_seconds,
confidence: 1.0,
method_used: SyncMethod::Manual,
correlation_peak: 1.0,
additional_info: Some(json!({
"applied_offset": offset_seconds,
"entries_modified": subtitle.entries.len(),
})),
processing_duration: start.elapsed(),
warnings: Vec::new(),
})
}
fn determine_default_method(&self) -> SyncMethod {
debug!(
"[SyncEngine] determine_default_method called | config.default_method: {}",
self.config.default_method
);
match self.config.default_method.as_str() {
"vad" => SyncMethod::LocalVad,
_ => SyncMethod::Auto,
}
}
async fn vad_detect_sync_offset(
&self,
audio_path: &Path,
subtitle: &Subtitle,
) -> Result<SyncResult> {
debug!(
"[SyncEngine] vad_detect_sync_offset called | audio_path: {:?}, subtitle entries: {}",
audio_path,
subtitle.entries.len()
);
let det = self
.vad_detector
.as_ref()
.ok_or_else(|| SubXError::audio_processing("VAD detector not available"))?;
let mut result = det.detect_sync_offset(audio_path, subtitle, 0).await?;
if result.offset_seconds.abs() > self.config.max_offset_seconds {
warn!(
"[SyncEngine] Detected offset {:.2}s exceeds configured maximum value {:.2}s. Clamping and warning.",
result.offset_seconds, self.config.max_offset_seconds
);
result.warnings.push(format!(
"Detected offset {:.2}s exceeds configured maximum value {:.2}s. Consider checking audio quality or adjusting sync.max_offset_seconds configuration.",
result.offset_seconds, self.config.max_offset_seconds
));
let sign = if result.offset_seconds >= 0.0 {
1.0
} else {
-1.0
};
let original_offset = result.offset_seconds;
result.offset_seconds = sign * self.config.max_offset_seconds;
result.additional_info = Some(json!({
"original_offset": original_offset,
"clamped_offset": result.offset_seconds,
"reason": "Exceeded max_offset_seconds configuration"
}));
} else {
debug!(
"[SyncEngine] VAD sync offset detected | offset_seconds: {:.3}, confidence: {:.3}",
result.offset_seconds, result.confidence
);
}
Ok(result)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SyncMethod {
Auto,
LocalVad,
Manual,
}
#[derive(Debug, Clone)]
pub struct SyncResult {
pub offset_seconds: f32,
pub confidence: f32,
pub method_used: SyncMethod,
pub correlation_peak: f32,
pub additional_info: Option<serde_json::Value>,
pub processing_duration: Duration,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct MethodSelectionStrategy {
pub preferred_methods: Vec<SyncMethod>,
pub min_confidence_threshold: f32,
pub allow_fallback: bool,
pub max_attempt_duration: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{TestConfigBuilder, TestConfigService, service::ConfigService};
use crate::core::formats::{Subtitle, SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
use std::time::Duration;
#[tokio::test]
async fn test_sync_engine_creation() {
let config = TestConfigBuilder::new()
.with_vad_enabled(true)
.build_config();
let config_service = TestConfigService::new(config);
let result = SyncEngine::new(config_service.get_config().unwrap().sync);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_manual_offset_application() {
let config = TestConfigBuilder::new().build_config();
let config_service = TestConfigService::new(config);
let engine = SyncEngine::new(config_service.get_config().unwrap().sync).unwrap();
let mut subtitle = create_test_subtitle();
let original_start = subtitle.entries[0].start_time;
let result = engine.apply_manual_offset(&mut subtitle, 2.5).unwrap();
assert_eq!(result.offset_seconds, 2.5);
assert_eq!(result.method_used, SyncMethod::Manual);
assert_eq!(result.confidence, 1.0);
let expected_start = original_start + Duration::from_secs_f32(2.5);
assert_eq!(subtitle.entries[0].start_time, expected_start);
}
#[tokio::test]
async fn test_manual_offset_negative_application() {
let config = TestConfigBuilder::new().build_config();
let config_service = TestConfigService::new(config);
let engine = SyncEngine::new(config_service.get_config().unwrap().sync).unwrap();
let mut subtitle = create_test_subtitle();
let original_start = subtitle.entries[0].start_time;
let result = engine.apply_manual_offset(&mut subtitle, -2.5).unwrap();
assert_eq!(result.offset_seconds, -2.5);
let expected_start = original_start - Duration::from_secs_f32(2.5);
assert_eq!(subtitle.entries[0].start_time, expected_start);
}
#[tokio::test]
async fn test_determine_default_method() {
let test_cases = vec![("vad", SyncMethod::LocalVad), ("unknown", SyncMethod::Auto)];
for (config_value, expected_method) in test_cases {
let config = TestConfigBuilder::new()
.with_sync_method(config_value)
.build_config();
let engine = SyncEngine::new(config.sync).unwrap();
assert_eq!(engine.determine_default_method(), expected_method);
}
}
#[tokio::test]
async fn test_method_selection_strategy_struct() {
let strategy = MethodSelectionStrategy {
preferred_methods: vec![SyncMethod::LocalVad],
min_confidence_threshold: 0.7,
allow_fallback: true,
max_attempt_duration: 60,
};
assert_eq!(strategy.preferred_methods.len(), 1);
assert!(strategy.allow_fallback);
}
fn create_test_subtitle() -> Subtitle {
Subtitle {
entries: vec![SubtitleEntry::new(
1,
Duration::from_secs(10),
Duration::from_secs(12),
"Test subtitle".to_string(),
)],
metadata: SubtitleMetadata::default(),
format: SubtitleFormatType::Srt,
}
}
}