Files
diodiogod-TTS-Audio-Suite/utils/timing/reporting.py
T
diodiogod b21d0212fb Major refactoring: Reorganize project structure for better maintainability
🔧 ENGINES SEPARATION:
- Move chatterbox/ → engines/chatterbox/ (TTS/VC engine + models)
- Move chatterbox/f5tts/ → engines/f5tts/ (F5-TTS engine)
- Move adapters/ → engines/adapters/ (engine adapters for multilingual system)

📁 NODES ORGANIZATION:
- Create nodes/chatterbox/ for ChatterBox nodes (TTS, VC, SRT)
- Create nodes/f5tts/ for F5-TTS nodes (TTS, SRT, Edit, Options)
- Create nodes/audio/ for audio processing nodes (analyzer, recorder)
- Create nodes/base/ for base classes
- Rename node files for clarity (tts_node.py → chatterbox_tts_node.py)

🗂️ UTILITIES RESTRUCTURE:
- Replace core/ with organized utils/ structure
- utils/audio/ - Audio processing, analysis, caching
- utils/text/ - Text processing, character parsing, chunking
- utils/voice/ - Voice discovery, multilingual engine
- utils/timing/ - SRT timing logic (from chatterbox_srt/)
- utils/models/ - Model management (ChatterBox + F5-TTS)
- utils/system/ - System utilities, import management

🔄 IMPORT PATH UPDATES:
- Update all import paths to use new organized structure
- Fix lazy imports in nodes for runtime compatibility
- Restore missing F5-TTS edit engine from git history
- Fix path resolution for voice discovery and character aliases

🎯 KEY IMPROVEMENTS:
- Engine-centric organization with clear separation
- Purpose-driven folder grouping for better navigation
- Self-documenting file names indicating ownership
- Scalable structure for future engine additions
- Maintains backward compatibility for existing workflows

All 10 nodes loading successfully with complete functionality preserved.
2025-08-02 18:50:26 -03:00

535 lines
27 KiB
Python

"""
SRT Reporting - Handles report generation and SRT output formatting
Extracted from the massive SRT TTS node for better maintainability
"""
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime
class SRTReportGenerator:
"""
Handles generation of timing reports and adjusted SRT content
"""
def __init__(self):
"""Initialize report generator"""
pass
def _get_original_overlapping_segments(self, subtitles: List) -> set:
"""Get set of segment indices that overlap with next segments in original SRT."""
overlapping_segments = set()
for i in range(len(subtitles) - 1):
current = subtitles[i]
next_sub = subtitles[i + 1]
if current.end_time > next_sub.start_time:
overlapping_segments.add(i)
return overlapping_segments
def generate_timing_report(self, subtitles: List, adjustments: List[Dict], timing_mode: str, has_original_overlaps: bool = False, mode_switched: bool = False, original_mode: str = None) -> str:
"""Generate detailed timing report with original vs generated overlap distinction"""
import numpy as np
# Get original overlapping segments if needed
original_overlapping_segments = set()
if has_original_overlaps:
original_overlapping_segments = self._get_original_overlapping_segments(subtitles)
# Prepare timing mode display info
mode_display = timing_mode
if mode_switched and original_mode:
mode_display = f"{timing_mode} (switched from {original_mode} due to overlapping subtitles)"
# Handle edge case where no subtitles were processed (immediate interruption)
if not subtitles:
return f"""SRT Timing Report ({mode_display} mode)
{'=' * 50}
Total subtitles: 0 (interrupted immediately)
Total duration: 0.000s
No segments were processed due to immediate interruption.
"""
report_lines = [
f"SRT Timing Report ({mode_display} mode)",
"=" * 50,
f"Total subtitles: {len(subtitles)}",
f"Total duration: {subtitles[-1].end_time:.3f}s",
"",
"Per-subtitle analysis:"
]
if timing_mode == "smart_natural":
# For smart_natural mode, iterate directly over the detailed adjustments report
for adj in adjustments:
report_lines.append(
f" {adj['sequence']:2d}. Original SRT: {adj['original_srt_start']:6.2f}-{adj['original_srt_end']:6.2f}s "
f"(Target: {adj['original_srt_duration']:.2f}s)"
)
report_lines.append(f" Natural Audio: {adj['natural_audio_duration']:.3f}s")
for action in adj['actions']:
report_lines.append(f" - {action}")
report_lines.append(
f" Final Audio Duration: {adj['final_segment_duration']:.3f}s "
f"(Final SRT: {adj['final_srt_start']:6.2f}-{adj['final_srt_end']:6.2f}s)"
)
# Find the corresponding subtitle to get its text
# This assumes subtitles are sorted by sequence or index
original_subtitle_text = next((s.text for s in subtitles if s.sequence == adj['sequence']), "N/A")
report_lines.append(f" Text: {original_subtitle_text[:60]}{'...' if len(original_subtitle_text) > 60 else ''}")
elif timing_mode == "concatenate":
# For concatenate mode, show original vs new timings
for adj in adjustments:
timing_change = adj.get('timing_change', 0.0)
timing_change_info = ""
if abs(timing_change) > 0.01: # Only show significant changes
if timing_change > 0:
timing_change_info = f" [timing +{timing_change:.2f}s later]"
else:
timing_change_info = f" [timing {timing_change:.2f}s earlier]"
report_lines.append(
f" {adj['sequence']:2d}. Original SRT: {adj['original_srt_start']:6.2f}-{adj['original_srt_end']:6.2f}s "
f"→ New: {adj['start_time']:6.2f}-{adj['end_time']:6.2f}s ({adj['natural_duration']:.2f}s){timing_change_info}"
)
report_lines.append(f" Text: {adj['original_text'][:60]}{'...' if len(adj['original_text']) > 60 else ''}")
else:
# For other modes, iterate using zip with original subtitles
for i, (subtitle, adj) in enumerate(zip(subtitles, adjustments)):
if timing_mode == "pad_with_silence":
# For pad_with_silence mode, show overlap/gap information with distinction
timing_info = ""
if adj['natural_duration'] > subtitle.duration:
# Audio is longer than SRT slot - check if it's a real overlap or gap extension
overlap = adj['natural_duration'] - subtitle.duration
audio_end_time = subtitle.start_time + adj['natural_duration']
# Check if there's a next segment and if audio truly overlaps with it
has_real_overlap = False
if i < len(subtitles) - 1:
next_subtitle = subtitles[i + 1]
if audio_end_time > next_subtitle.start_time:
has_real_overlap = True
real_overlap = audio_end_time - next_subtitle.start_time
if has_real_overlap:
# True overlap - audio collides with next segment
if i in original_overlapping_segments:
timing_info = f" 🔁✔️ [ORIGINAL+REAL OVERLAP: +{real_overlap:.2f}s into next]"
else:
timing_info = f" 🔁 [REAL OVERLAP: +{real_overlap:.2f}s into next segment]"
else:
# Gap extension - audio extends into silence, not a real problem
if i in original_overlapping_segments:
timing_info = f" 🔁✔️ [ORIGINAL OVERLAP] [gap extension: +{overlap:.2f}s into silence]"
else:
timing_info = f" [gap extension: +{overlap:.2f}s into silence]"
elif i in original_overlapping_segments:
# Only original overlap (generation fits within SRT timing)
timing_info = f" 🔁✔️ [ORIGINAL OVERLAP]"
elif i < len(subtitles) - 1:
# Check for silence gap to next subtitle
next_subtitle = subtitles[i + 1]
gap_duration = next_subtitle.start_time - subtitle.end_time
if gap_duration > 0:
timing_info = f" [+{gap_duration:.2f}s silence available]"
report_lines.append(
f" {subtitle.sequence:2d}. {subtitle.start_time:6.2f}-{subtitle.end_time:6.2f}s "
f"({subtitle.duration:.2f}s target, {adj['natural_duration']:.2f}s natural){timing_info}"
)
else:
# For other modes (e.g., stretch_to_fit), show stretch information and original overlaps
stretch_info = ""
if adj['needs_stretching']:
# Add emoji for compress/expand modes
emoji = ""
if adj['stretch_type'] == 'compress':
emoji = "🐰 "
elif adj['stretch_type'] == 'expand':
emoji = "🐌 "
stretch_info = f" [{emoji}{adj['stretch_type']} {adj['stretch_factor']:.2f}x]"
# Add original overlap indicator for stretch_to_fit mode
overlap_info = ""
if i in original_overlapping_segments:
overlap_info = f" 🔁✔️ [ORIGINAL OVERLAP - expected]"
report_lines.append(
f" {subtitle.sequence:2d}. {subtitle.start_time:6.2f}-{subtitle.end_time:6.2f}s "
f"({subtitle.duration:.2f}s target, {adj['natural_duration']:.2f}s natural){stretch_info}{overlap_info}"
)
report_lines.append(f" Text: {subtitle.text[:60]}{'...' if len(subtitle.text) > 60 else ''}")
# Summary statistics
if timing_mode == "pad_with_silence":
total_gaps = 0
total_gap_duration = 0
total_real_overlaps = 0
total_real_overlap_duration = 0
total_gap_extensions = 0
total_gap_extension_duration = 0
total_original_overlaps = len(original_overlapping_segments)
total_combined_overlaps = 0
for i, (subtitle, adj) in enumerate(zip(subtitles, adjustments)):
if adj['natural_duration'] > subtitle.duration:
extend_duration = adj['natural_duration'] - subtitle.duration
audio_end_time = subtitle.start_time + adj['natural_duration']
# Check if there's a real overlap with next segment
has_real_overlap = False
real_overlap_duration = 0
if i < len(subtitles) - 1:
next_subtitle = subtitles[i + 1]
if audio_end_time > next_subtitle.start_time:
has_real_overlap = True
real_overlap_duration = audio_end_time - next_subtitle.start_time
if has_real_overlap:
# True overlap with next segment
if i in original_overlapping_segments:
total_combined_overlaps += 1
else:
total_real_overlaps += 1
total_real_overlap_duration += real_overlap_duration
else:
# Gap extension into silence
total_gap_extensions += 1
total_gap_extension_duration += extend_duration
elif i < len(subtitles) - 1:
gap_duration = subtitles[i + 1].start_time - subtitles[i].end_time
if gap_duration > 0:
total_gaps += 1
total_gap_duration += gap_duration
summary_lines = [
"",
"Summary:",
f" Audio preserved at natural timing (no stretching)"
]
if total_original_overlaps > 0:
summary_lines.append(f" Original SRT overlaps: {total_original_overlaps} segments (expected from subtitle file)")
if total_real_overlaps > 0:
summary_lines.append(f" 🔁 Real overlaps: {total_real_overlaps} segments, +{total_real_overlap_duration:.2f}s total (audio collides with next segment)")
if total_combined_overlaps > 0:
summary_lines.append(f" Combined overlaps: {total_combined_overlaps} segments (both original + real generation overlaps)")
if total_gap_extensions > 0:
summary_lines.append(f" Gap extensions: {total_gap_extensions} segments, +{total_gap_extension_duration:.2f}s total (extending into silence - not problematic)")
if total_gaps > 0:
summary_lines.append(f" Silence gaps available: {total_gaps} gaps, {total_gap_duration:.2f}s total silence")
if total_real_overlaps == 0 and total_gap_extensions == 0 and total_gaps == 0 and total_original_overlaps == 0:
summary_lines.append(f" Perfect timing match - no gaps or overlaps")
report_lines.extend(summary_lines)
elif timing_mode == "concatenate":
# Summary for concatenate mode
total_original_duration = sum(adj.get('original_srt_duration', 0) for adj in adjustments)
total_new_duration = sum(adj.get('natural_duration', 0) for adj in adjustments)
duration_difference = total_new_duration - total_original_duration
segments_earlier = sum(1 for adj in adjustments if adj.get('timing_change', 0) < -0.01)
segments_later = sum(1 for adj in adjustments if adj.get('timing_change', 0) > 0.01)
segments_unchanged = len(adjustments) - segments_earlier - segments_later
summary_lines = [
"",
"Summary (Concatenate Mode):",
f" Original SRT duration: {total_original_duration:.2f}s",
f" Natural audio duration: {total_new_duration:.2f}s",
f" Duration change: {duration_difference:+.2f}s",
f" Segments with earlier timing: {segments_earlier}",
f" Segments with later timing: {segments_later}",
f" Segments with similar timing: {segments_unchanged}",
f" Audio quality: Natural (no stretching or padding applied)"
]
report_lines.extend(summary_lines)
elif timing_mode == "smart_natural":
# Define the same threshold used in smart timing processing
INSIGNIFICANT_TRUNCATION_THRESHOLD = 0.05
total_shifted = sum(1 for adj in adjustments if adj['next_segment_shifted_by'] > 0)
total_stretched = sum(1 for adj in adjustments if abs(adj['stretch_factor_applied'] - 1.0) > 0.01)
total_padded = sum(1 for adj in adjustments if adj['padding_added'] > 0)
# Only count significant truncations in the summary
total_truncated = sum(1 for adj in adjustments if adj['truncated_by'] > INSIGNIFICANT_TRUNCATION_THRESHOLD)
summary_lines = [
"",
"Summary (Smart Natural Mode):",
f" Segments with next segment shifted: {total_shifted}/{len(adjustments)}",
f" Segments with audio stretched/shrunk: {total_stretched}/{len(adjustments)}",
f" Segments with silence padded: {total_padded}/{len(adjustments)}",
f" Segments truncated: {total_truncated}/{len(adjustments)}",
]
report_lines.extend(summary_lines)
else:
total_stretch_needed = sum(1 for adj in adjustments if adj['needs_stretching'])
avg_stretch = np.mean([adj['stretch_factor'] for adj in adjustments])
total_original_overlaps = len(original_overlapping_segments)
summary_lines = [
"",
"Summary:",
f" Segments needing time adjustment: {total_stretch_needed}/{len(adjustments)}",
f" Average stretch factor: {avg_stretch:.2f}x",
]
if total_original_overlaps > 0:
summary_lines.append(f" Original SRT overlaps: {total_original_overlaps} segments (expected behavior - segments will overlap as intended)")
report_lines.extend(summary_lines)
return "\n".join(report_lines)
def generate_adjusted_srt_string(self, subtitles: List, adjustments: List[Dict], timing_mode: str) -> str:
"""Generate adjusted SRT string from final timings - EXACT ORIGINAL LOGIC"""
if not adjustments:
return "# No subtitles processed\n"
srt_lines = []
for i, adj in enumerate(adjustments):
def format_time(seconds):
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds_int = int(seconds % 60)
milliseconds = int((seconds - int(seconds)) * 1000)
return f"{hours:02}:{minutes:02}:{seconds_int:02},{milliseconds:03}"
# Determine timing based on mode
if timing_mode == "smart_natural":
start_time = adj.get('final_srt_start', adj.get('original_srt_start', 0))
end_time = start_time + adj.get('final_segment_duration', adj.get('natural_audio_duration', 1))
elif timing_mode == "concatenate":
# Use the recalculated timings from concatenation
start_time = adj.get('start_time', 0)
end_time = adj.get('end_time', adj.get('start_time', 0) + adj.get('natural_duration', 1))
else:
start_time = adj.get('start_time', subtitles[i].start_time if i < len(subtitles) else 0)
end_time = adj.get('end_time', subtitles[i].end_time if i < len(subtitles) else 1)
start_time_str = format_time(start_time)
end_time_str = format_time(end_time)
# Get text
text = adj.get('original_text', subtitles[i].text if i < len(subtitles) else f"Subtitle {i+1}")
srt_lines.append(str(adj.get('sequence', i+1)))
srt_lines.append(f"{start_time_str} --> {end_time_str}")
srt_lines.append(text)
srt_lines.append("")
return "\n".join(srt_lines)
def generate_generation_info(self, total_duration: float, segment_count: int,
timing_mode: str, cache_status: str,
model_source: str) -> str:
"""
Generate concise generation info string
Args:
total_duration: Total audio duration in seconds
segment_count: Number of segments processed
timing_mode: Timing mode used
cache_status: Cache status ("cached", "generated", "mixed")
model_source: Model source information
Returns:
Formatted generation info string
"""
return (f"Generated {total_duration:.1f}s SRT-timed audio from {segment_count} subtitles "
f"using {timing_mode} mode ({cache_status} segments, {model_source} models)")
def _generate_smart_natural_analysis(self, adjustments: List[Dict]) -> List[str]:
"""Generate analysis for smart natural timing mode"""
analysis_lines = []
for adj in adjustments:
sequence = adj.get('sequence', adj.get('segment_index', 0) + 1)
original_start = adj.get('original_srt_start', 0)
original_end = adj.get('original_srt_end', 0)
original_duration = adj.get('original_srt_duration', original_end - original_start)
analysis_lines.append(
f" {sequence:2d}. Original SRT: {original_start:6.2f}-{original_end:6.2f}s "
f"(Target: {original_duration:.2f}s)"
)
# Add actions taken
actions = adj.get('actions', ['No actions recorded'])
for action in actions:
analysis_lines.append(f" - {action}")
# Add timing deviation info if available
if 'timing_deviation' in adj:
deviation = adj['timing_deviation']
within_tolerance = adj.get('within_tolerance', True)
status = "✓" if within_tolerance else "⚠"
analysis_lines.append(f" {status} Timing deviation: {deviation:.3f}s")
# Add text preview
text = adj.get('original_text', 'No text available')
preview = text[:60] + '...' if len(text) > 60 else text
analysis_lines.append(f" Text: {preview}")
analysis_lines.append("") # Empty line between entries
return analysis_lines
def _generate_standard_analysis(self, subtitles: List, adjustments: List[Dict]) -> List[str]:
"""Generate analysis for standard timing modes"""
analysis_lines = []
for i, adj in enumerate(adjustments):
if i < len(subtitles):
subtitle = subtitles[i]
sequence = subtitle.sequence
start_time = subtitle.start_time
end_time = subtitle.end_time
duration = subtitle.duration
text = subtitle.text
else:
sequence = adj.get('sequence', i + 1)
start_time = adj.get('start_time', 0)
end_time = adj.get('end_time', 1)
duration = end_time - start_time
text = adj.get('original_text', f'Subtitle {i+1}')
# Format stretch information
stretch_info = ""
if adj.get('needs_stretching'):
stretch_type = adj.get('stretch_type', 'unknown')
stretch_factor = adj.get('stretch_factor', 1.0)
# Add emoji for compress/expand modes
emoji = ""
if stretch_type == 'compress':
emoji = "🐰 "
elif stretch_type == 'expand':
emoji = "🐌 "
stretch_info = f" [{emoji}{stretch_type} {stretch_factor:.2f}x]"
natural_duration = adj.get('natural_duration', duration)
analysis_lines.append(
f" {sequence:2d}. {start_time:6.2f}-{end_time:6.2f}s "
f"({duration:.2f}s target, {natural_duration:.2f}s natural){stretch_info}"
)
# Add text preview
preview = text[:60] + '...' if len(text) > 60 else text
analysis_lines.append(f" Text: {preview}")
return analysis_lines
def _generate_summary_statistics(self, adjustments: List[Dict], timing_mode: str) -> List[str]:
"""Generate summary statistics from adjustments"""
stats_lines = []
# Count different types of adjustments
stretched_count = sum(1 for adj in adjustments if adj.get('needs_stretching', False))
no_change_count = len(adjustments) - stretched_count
if timing_mode == "smart_natural":
# Smart natural specific stats
shifted_count = sum(1 for adj in adjustments if adj.get('next_segment_shifted_by', 0) > 0)
padded_count = sum(1 for adj in adjustments if adj.get('padding_added', 0) > 0)
stats_lines.extend([
f"- Segments using natural timing: {no_change_count}",
f"- Segments requiring adjustment: {stretched_count}",
f"- Segments causing shifts: {shifted_count}",
f"- Segments with padding: {padded_count}"
])
# Calculate average timing deviation
deviations = [adj.get('timing_deviation', 0) for adj in adjustments if 'timing_deviation' in adj]
if deviations:
avg_deviation = sum(deviations) / len(deviations)
max_deviation = max(deviations)
stats_lines.append(f"- Average timing deviation: {avg_deviation:.3f}s")
stats_lines.append(f"- Maximum timing deviation: {max_deviation:.3f}s")
else:
# Standard timing mode stats
if stretched_count > 0:
compress_count = sum(1 for adj in adjustments
if adj.get('stretch_type') == 'compress')
expand_count = sum(1 for adj in adjustments
if adj.get('stretch_type') == 'expand')
stats_lines.extend([
f"- Segments requiring stretching: {stretched_count}",
f" - Compressed: {compress_count}",
f" - Extended: {expand_count}",
f"- Segments with natural timing: {no_change_count}"
])
else:
stats_lines.append(f"- All {len(adjustments)} segments used natural timing")
return stats_lines
def _format_srt_timestamp(self, seconds: float) -> str:
"""
Format seconds as SRT timestamp (HH:MM:SS,mmm)
Args:
seconds: Time in seconds
Returns:
Formatted timestamp string
"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds_int = int(seconds % 60)
milliseconds = int((seconds - int(seconds)) * 1000)
return f"{hours:02}:{minutes:02}:{seconds_int:02},{milliseconds:03}"
def generate_debug_report(self, subtitles: List, adjustments: List[Dict],
audio_segments: List = None) -> str:
"""
Generate detailed debug report for troubleshooting
Args:
subtitles: List of SRTSubtitle objects
adjustments: List of timing adjustments
audio_segments: Optional list of audio segments for analysis
Returns:
Detailed debug report string
"""
debug_lines = [
"SRT Processing Debug Report",
"=" * 50,
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
f"Input subtitles: {len(subtitles) if subtitles else 0}",
f"Generated adjustments: {len(adjustments)}",
f"Audio segments: {len(audio_segments) if audio_segments else 'N/A'}",
""
]
# Add detailed adjustment analysis
if adjustments:
debug_lines.append("Detailed Adjustment Analysis:")
for i, adj in enumerate(adjustments):
debug_lines.append(f"Adjustment {i+1}:")
for key, value in adj.items():
if isinstance(value, float):
debug_lines.append(f" {key}: {value:.6f}")
elif isinstance(value, list):
debug_lines.append(f" {key}: {len(value)} items")
else:
debug_lines.append(f" {key}: {value}")
debug_lines.append("")
return "\n".join(debug_lines)