"""Detectar e remover silêncio. Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto. """ from typing import Any, Dict, List, Optional from ..models import ( MarkerType, TimeValue, ) from .helpers import CLIP_TAGS, build_marker_element class SilenceMixin: """Detectar e remover silêncio.""" # SILENCE DETECTION OPERATIONS (v0.5.0) # ======================================================================== def detect_silence_candidates( self, min_gap_seconds: float = 0.5, patterns: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """Detect potential silence regions using timeline heuristics. Checks for: 1. Gap elements in spine (high confidence) 2. Ultra-short clips < 0.5s (medium confidence) 3. Clips matching name patterns like "silence", "room tone" (high) 4. Duration anomalies > 2 std dev from mean (low-medium) Args: min_gap_seconds: Minimum gap duration to flag patterns: Name patterns to match (default: gap, silence, room tone) Returns: List of silence candidate dicts """ if patterns is None: patterns = ['gap', 'silence', 'room tone', 'dead air', 'blank'] spine = self._get_spine() candidates = [] durations = [] clip_index = 0 # First pass: collect durations for anomaly detection for child in spine: if child.tag in CLIP_TAGS: dur = self._parse_time(child.get('duration', '0s')) durations.append(dur.to_seconds()) # Calculate stats for anomaly detection mean_dur = sum(durations) / len(durations) if durations else 0 variance = (sum((d - mean_dur) ** 2 for d in durations) / len(durations) if len(durations) > 1 else 0) std_dev = variance ** 0.5 # Second pass: detect candidates for child in spine: tag = child.tag offset = child.get('offset', '0s') dur = self._parse_time(child.get('duration', '0s')) dur_secs = dur.to_seconds() tc = TimeValue.from_timecode(offset, self.fps).to_timecode(self.fps) if tag == 'gap' and dur_secs >= min_gap_seconds: candidates.append({ 'start_timecode': tc, 'duration_seconds': dur_secs, 'reason': 'gap', 'confidence': 0.9, 'clip_name': None, 'clip_index': None, }) elif tag in CLIP_TAGS: name = child.get('name', '').lower() # Name pattern match for pat in patterns: if pat.lower() in name: candidates.append({ 'start_timecode': tc, 'duration_seconds': dur_secs, 'reason': 'name_match', 'confidence': 0.85, 'clip_name': child.get('name', ''), 'clip_index': clip_index, }) break # Ultra-short clip if dur_secs < 0.5: candidates.append({ 'start_timecode': tc, 'duration_seconds': dur_secs, 'reason': 'ultra_short', 'confidence': 0.6, 'clip_name': child.get('name', ''), 'clip_index': clip_index, }) # Duration anomaly (> 2 std dev longer than mean) if std_dev > 0 and dur_secs > mean_dur + 2 * std_dev: candidates.append({ 'start_timecode': tc, 'duration_seconds': dur_secs, 'reason': 'duration_anomaly', 'confidence': 0.4, 'clip_name': child.get('name', ''), 'clip_index': clip_index, }) clip_index += 1 return candidates def remove_silence_candidates( self, mode: str = "mark", min_gap_seconds: float = 0.5, min_confidence: float = 0.7, patterns: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """Remove or mark detected silence candidates. Args: mode: "delete" removes clips/gaps, "mark" adds red markers, "shorten" trims to minimum min_gap_seconds: Minimum gap to consider min_confidence: Only act on candidates above this threshold patterns: Name patterns to match Returns: List of actions taken """ candidates = self.detect_silence_candidates(min_gap_seconds, patterns) candidates = [c for c in candidates if c['confidence'] >= min_confidence] spine = self._get_spine() actions = [] if mode == "mark": for c in candidates: child = self._find_spine_element_at_timecode( spine, c['start_timecode'], require_clip=True ) if child is not None: build_marker_element( parent=child, marker_type=MarkerType.STANDARD, start=child.get('start', '0s'), duration=f"1/{int(self.fps)}s", name=f"SILENCE: {c['reason']}", ) actions.append({ 'action': 'marked', 'clip_name': c.get('clip_name', 'gap'), 'reason': c['reason'], }) elif mode == "delete": elements_to_remove = [] for c in candidates: child = self._find_spine_element_at_timecode( spine, c['start_timecode'] ) if child is not None: elements_to_remove.append(child) actions.append({ 'action': 'deleted', 'clip_name': c.get('clip_name', 'gap'), 'reason': c['reason'], }) for elem in elements_to_remove: spine.remove(elem) if elements_to_remove: self._recalculate_offsets(spine) return actions