Skip to main content

subx_core/core/sync/
engine.rs

1//! Refactored sync engine with VAD (Voice Activity Detection) support.
2//!
3//! This module provides unified subtitle synchronization functionality using
4//! local VAD (Voice Activity Detection) for voice detection and sync offset calculation.
5
6use log::{debug, warn};
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12use crate::config::SyncConfig;
13use crate::core::formats::Subtitle;
14use crate::services::vad::VadSyncDetector;
15use crate::{Result, error::SubXError};
16
17/// Unified sync engine based on VAD voice detection.
18///
19/// This engine provides automatic subtitle synchronization using Voice Activity
20/// Detection (VAD) to analyze audio tracks and calculate optimal sync offsets.
21pub struct SyncEngine {
22    config: SyncConfig,
23    vad_detector: Option<VadSyncDetector>,
24    /// Attachment point for the reporting seam. The sync engine has no
25    /// reporting site today; `expose-core-orchestration-apis` will use it
26    /// for progress and cancellation events.
27    reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
28}
29
30impl SyncEngine {
31    /// Create a new sync engine instance.
32    ///
33    /// # Arguments
34    ///
35    /// * `config` - Sync configuration containing VAD settings and thresholds
36    ///
37    /// # Returns
38    ///
39    /// A new sync engine instance with initialized VAD detector if enabled.
40    ///
41    /// The VAD detector requirement is unconditional: construction fails
42    /// when `config.vad.enabled` is false as well as when detector
43    /// initialization fails, regardless of the sync method the caller will
44    /// ultimately use. A caller that only needs the manual-offset transform
45    /// should use [`crate::core::sync::shift_subtitle_timing`] instead of
46    /// constructing an engine.
47    pub fn new(config: SyncConfig) -> Result<Self> {
48        let vad_detector = if config.vad.enabled {
49            match VadSyncDetector::new(config.vad.clone()) {
50                Ok(det) => {
51                    debug!(
52                        "[SyncEngine] VAD detector initialized successfully with config: {:?}",
53                        config.vad
54                    );
55                    Some(det)
56                }
57                Err(e) => {
58                    warn!("[SyncEngine] VAD initialization failed: {}", e);
59                    None
60                }
61            }
62        } else {
63            debug!("[SyncEngine] VAD is disabled in config");
64            None
65        };
66
67        if vad_detector.is_none() {
68            warn!("[SyncEngine] VAD detector is required but not available");
69            return Err(SubXError::config(
70                "VAD detector is required but not available",
71            ));
72        }
73
74        debug!("[SyncEngine] SyncEngine created with VAD detector");
75        Ok(Self {
76            config,
77            vad_detector,
78            reporter: crate::core::report::noop(),
79        })
80    }
81
82    /// Attach a reporting sink, consuming and returning the engine.
83    ///
84    /// # Arguments
85    ///
86    /// * `reporter` - Sink reserved for the sync engine's future
87    ///   progress/cancellation reporting (see `expose-core-orchestration-apis`).
88    pub fn with_reporter(
89        mut self,
90        reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
91    ) -> Self {
92        self.reporter = reporter;
93        self
94    }
95
96    /// The attached reporting sink (used by future progress/cancellation
97    /// reporting work).
98    pub fn reporter(&self) -> &std::sync::Arc<dyn crate::core::report::Reporter> {
99        &self.reporter
100    }
101
102    /// Detect sync offset using automatic or specified method.
103    ///
104    /// # Arguments
105    ///
106    /// * `audio_path` - Path to the audio file for analysis
107    /// * `subtitle` - Subtitle data to synchronize
108    /// * `method` - Optional sync method, defaults to automatic detection
109    ///
110    /// # Returns
111    ///
112    /// Sync result containing offset, confidence, and processing metadata.
113    pub async fn detect_sync_offset(
114        &self,
115        audio_path: &Path,
116        subtitle: &Subtitle,
117        method: Option<SyncMethod>,
118    ) -> Result<SyncResult> {
119        debug!(
120            "[SyncEngine] detect_sync_offset called | audio_path: {:?}, subtitle entries: {}, method: {:?}",
121            audio_path,
122            subtitle.entries.len(),
123            method
124        );
125        let start = Instant::now();
126        let m = method.unwrap_or_else(|| self.determine_default_method());
127        debug!("[SyncEngine] Using sync method: {:?}", m);
128        let mut res = match m {
129            SyncMethod::Auto | SyncMethod::LocalVad => {
130                self.vad_detect_sync_offset(audio_path, subtitle).await?
131            }
132            SyncMethod::Manual => {
133                debug!("[SyncEngine] Manual method selected but not supported in this context");
134                return Err(SubXError::config("Manual method requires explicit offset"));
135            }
136        };
137        res.processing_duration = start.elapsed();
138        debug!(
139            "[SyncEngine] detect_sync_offset finished | offset_seconds: {:.3}, confidence: {:.3}, duration_ms: {}",
140            res.offset_seconds,
141            res.confidence,
142            res.processing_duration.as_millis()
143        );
144        Ok(res)
145    }
146
147    async fn auto_detect_sync_offset(
148        &self,
149        audio_path: &Path,
150        subtitle: &Subtitle,
151    ) -> Result<SyncResult> {
152        debug!(
153            "[SyncEngine] auto_detect_sync_offset called | audio_path: {:?}, subtitle entries: {}",
154            audio_path,
155            subtitle.entries.len()
156        );
157        // Auto mode uses VAD
158        if self.vad_detector.is_some() {
159            return self.vad_detect_sync_offset(audio_path, subtitle).await;
160        }
161        Err(SubXError::audio_processing(
162            "No detector available in auto mode",
163        ))
164    }
165
166    /// Apply manual offset to subtitle timing.
167    ///
168    /// # Arguments
169    ///
170    /// * `subtitle` - Mutable subtitle data to modify
171    /// * `offset_seconds` - Offset in seconds (positive delays, negative advances)
172    ///
173    /// # Returns
174    ///
175    /// Sync result with the applied offset and full confidence.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the offset exceeds the configured maximum.
180    pub fn apply_manual_offset(
181        &self,
182        subtitle: &mut Subtitle,
183        offset_seconds: f32,
184    ) -> Result<SyncResult> {
185        debug!(
186            "[SyncEngine] apply_manual_offset called | offset_seconds: {:.3}, entries: {}",
187            offset_seconds,
188            subtitle.entries.len()
189        );
190        // Validate offset against max_offset_seconds configuration
191        if offset_seconds.abs() > self.config.max_offset_seconds {
192            warn!(
193                "[SyncEngine] Offset {:.2}s exceeds maximum allowed value {:.2}s. Aborting.",
194                offset_seconds, self.config.max_offset_seconds
195            );
196            return Err(SubXError::config(format!(
197                "Offset {:.2}s exceeds maximum allowed value {:.2}s. Please check the sync.max_offset_seconds configuration or use a smaller offset.",
198                offset_seconds, self.config.max_offset_seconds
199            )));
200        }
201
202        super::shift_subtitle_timing(subtitle, offset_seconds)
203    }
204
205    fn determine_default_method(&self) -> SyncMethod {
206        debug!(
207            "[SyncEngine] determine_default_method called | config.default_method: {}",
208            self.config.default_method
209        );
210        match self.config.default_method.as_str() {
211            "vad" => SyncMethod::LocalVad,
212            _ => SyncMethod::Auto,
213        }
214    }
215
216    async fn vad_detect_sync_offset(
217        &self,
218        audio_path: &Path,
219        subtitle: &Subtitle,
220    ) -> Result<SyncResult> {
221        debug!(
222            "[SyncEngine] vad_detect_sync_offset called | audio_path: {:?}, subtitle entries: {}",
223            audio_path,
224            subtitle.entries.len()
225        );
226        let det = self
227            .vad_detector
228            .as_ref()
229            .ok_or_else(|| SubXError::audio_processing("VAD detector not available"))?;
230
231        let mut result = det.detect_sync_offset(audio_path, subtitle, 0).await?; // analysis_window_seconds no longer used
232
233        // Validate detected offset against max_offset_seconds configuration
234        if result.offset_seconds.abs() > self.config.max_offset_seconds {
235            warn!(
236                "[SyncEngine] Detected offset {:.2}s exceeds configured maximum value {:.2}s. Clamping and warning.",
237                result.offset_seconds, self.config.max_offset_seconds
238            );
239
240            // Provide warning but don't completely fail, allow user to decide
241            result.warnings.push(format!(
242                "Detected offset {:.2}s exceeds configured maximum value {:.2}s. Consider checking audio quality or adjusting sync.max_offset_seconds configuration.",
243                result.offset_seconds, self.config.max_offset_seconds
244            ));
245
246            // Optionally: clamp to maximum value (preserving sign)
247            let sign = if result.offset_seconds >= 0.0 {
248                1.0
249            } else {
250                -1.0
251            };
252            let original_offset = result.offset_seconds;
253            result.offset_seconds = sign * self.config.max_offset_seconds;
254
255            result.additional_info = Some(json!({
256                "original_offset": original_offset,
257                "clamped_offset": result.offset_seconds,
258                "reason": "Exceeded max_offset_seconds configuration"
259            }));
260        } else {
261            debug!(
262                "[SyncEngine] VAD sync offset detected | offset_seconds: {:.3}, confidence: {:.3}",
263                result.offset_seconds, result.confidence
264            );
265        }
266
267        Ok(result)
268    }
269}
270
271/// Sync method enumeration.
272///
273/// Defines the available methods for subtitle synchronization,
274/// from automatic detection to manual offset specification.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub enum SyncMethod {
277    /// Automatic method selection (currently VAD only).
278    Auto,
279    /// Local VAD (Voice Activity Detection) processing.
280    LocalVad,
281    /// Manual offset specification.
282    Manual,
283}
284
285/// Synchronization result structure.
286///
287/// Contains the complete results of subtitle synchronization analysis,
288/// including calculated offset, confidence metrics, and processing metadata.
289#[derive(Debug, Clone)]
290pub struct SyncResult {
291    /// Calculated time offset in seconds
292    pub offset_seconds: f32,
293    /// Confidence level of the detection (0.0-1.0)
294    pub confidence: f32,
295    /// Synchronization method that was used
296    pub method_used: SyncMethod,
297    /// Peak correlation value from analysis
298    pub correlation_peak: f32,
299    /// Additional method-specific information
300    pub additional_info: Option<serde_json::Value>,
301    /// Time taken to complete the analysis
302    pub processing_duration: Duration,
303    /// Any warnings generated during processing
304    pub warnings: Vec<String>,
305}
306
307/// Method selection strategy for synchronization analysis.
308///
309/// Defines preferences and fallback behavior for automatic method selection
310/// when multiple synchronization approaches are available.
311#[derive(Debug, Clone)]
312pub struct MethodSelectionStrategy {
313    /// Preferred methods in order of preference
314    pub preferred_methods: Vec<SyncMethod>,
315    /// Minimum confidence threshold for accepting results
316    pub min_confidence_threshold: f32,
317    /// Whether to allow fallback to alternative methods
318    pub allow_fallback: bool,
319    /// Maximum time to spend on analysis attempts
320    pub max_attempt_duration: u32,
321}
322
323// Unit test module: Supplement sync engine core behavior verification
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::config::{TestConfigBuilder, TestConfigService, service::ConfigService};
328    use crate::core::formats::{Subtitle, SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
329    use std::time::Duration;
330
331    #[tokio::test]
332    async fn test_sync_engine_creation() {
333        let config = TestConfigBuilder::new()
334            .with_vad_enabled(true)
335            .build_config();
336        let config_service = TestConfigService::new(config);
337        let result = SyncEngine::new(config_service.get_config().unwrap().sync);
338        assert!(result.is_ok());
339    }
340
341    #[tokio::test]
342    async fn test_manual_offset_application() {
343        let config = TestConfigBuilder::new().build_config();
344        let config_service = TestConfigService::new(config);
345        let engine = SyncEngine::new(config_service.get_config().unwrap().sync).unwrap();
346
347        let mut subtitle = create_test_subtitle();
348        let original_start = subtitle.entries[0].start_time;
349
350        let result = engine.apply_manual_offset(&mut subtitle, 2.5).unwrap();
351        assert_eq!(result.offset_seconds, 2.5);
352        assert_eq!(result.method_used, SyncMethod::Manual);
353        assert_eq!(result.confidence, 1.0);
354
355        let expected_start = original_start + Duration::from_secs_f32(2.5);
356        assert_eq!(subtitle.entries[0].start_time, expected_start);
357    }
358
359    #[tokio::test]
360    async fn test_manual_offset_negative_application() {
361        let config = TestConfigBuilder::new().build_config();
362        let config_service = TestConfigService::new(config);
363        let engine = SyncEngine::new(config_service.get_config().unwrap().sync).unwrap();
364
365        let mut subtitle = create_test_subtitle();
366        let original_start = subtitle.entries[0].start_time;
367
368        let result = engine.apply_manual_offset(&mut subtitle, -2.5).unwrap();
369        assert_eq!(result.offset_seconds, -2.5);
370
371        let expected_start = original_start - Duration::from_secs_f32(2.5);
372        assert_eq!(subtitle.entries[0].start_time, expected_start);
373    }
374
375    #[tokio::test]
376    async fn test_determine_default_method() {
377        let test_cases = vec![("vad", SyncMethod::LocalVad), ("unknown", SyncMethod::Auto)];
378
379        for (config_value, expected_method) in test_cases {
380            let config = TestConfigBuilder::new()
381                .with_sync_method(config_value)
382                .build_config();
383            let engine = SyncEngine::new(config.sync).unwrap();
384            assert_eq!(engine.determine_default_method(), expected_method);
385        }
386    }
387
388    #[tokio::test]
389    async fn test_method_selection_strategy_struct() {
390        let strategy = MethodSelectionStrategy {
391            preferred_methods: vec![SyncMethod::LocalVad],
392            min_confidence_threshold: 0.7,
393            allow_fallback: true,
394            max_attempt_duration: 60,
395        };
396        assert_eq!(strategy.preferred_methods.len(), 1);
397        assert!(strategy.allow_fallback);
398    }
399
400    fn create_test_subtitle() -> Subtitle {
401        Subtitle {
402            entries: vec![SubtitleEntry::new(
403                1,
404                Duration::from_secs(10),
405                Duration::from_secs(12),
406                "Test subtitle".to_string(),
407            )],
408            metadata: SubtitleMetadata::default(),
409            format: SubtitleFormatType::Srt,
410        }
411    }
412}