VoxSum-bak / src /export_utils.py
Luigi's picture
feat: add X-ASR + Qwen3-ASR backends, Gemma-4 LLMs; fix review findings
db7a819
Raw
History Blame Contribute Delete
12.8 kB
"""
Export utilities for transcripts and summaries
Supports various formats depending on speaker diarization state
"""
import json
from typing import List, Tuple, Dict, Any
from datetime import timedelta
from xml.sax.saxutils import escape as xml_escape
import re
def format_timestamp(seconds: float, format_type: str = "srt") -> str:
"""Format timestamp for different subtitle formats.
The value is quantized to the format's display precision *before* it is
split into hours/minutes/seconds, so a sub-second value that rounds up to a
whole second carries correctly into the higher fields. Formatting the
seconds remainder with rounding (the previous approach) could emit an
invalid ``:60`` field, e.g. 59.9996s -> ``00:00:60,000``.
"""
if seconds < 0:
seconds = 0.0
if format_type in ("srt", "vtt"):
total_ms = int(round(seconds * 1000))
hours = total_ms // 3_600_000
minutes = (total_ms // 60_000) % 60
secs = (total_ms // 1000) % 60
ms = total_ms % 1000
sep = "," if format_type == "srt" else "."
return f"{hours:02d}:{minutes:02d}:{secs:02d}{sep}{ms:03d}"
elif format_type == "ass":
total_cs = int(round(seconds * 100)) # centiseconds
hours = total_cs // 360_000
minutes = (total_cs // 6_000) % 60
secs = (total_cs // 100) % 60
cs = total_cs % 100
return f"{hours:01d}:{minutes:02d}:{secs:02d}.{cs:02d}"
else: # default — tenths of a second
total_ds = int(round(seconds * 10))
hours = total_ds // 36_000
minutes = (total_ds // 600) % 60
secs = (total_ds // 10) % 60
ds = total_ds % 10
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{ds:01d}"
def export_to_srt(utterances: List[Tuple[float, float, str]], utterances_with_speakers=None) -> str:
"""Export to SubRip (.srt) format"""
srt_content = []
# Use speaker-aware utterances if available
data_source = utterances_with_speakers if utterances_with_speakers else [(start, end, text, 0) for start, end, text in utterances]
for i, (start, end, text, speaker_id) in enumerate(data_source, 1):
speaker_prefix = f"Speaker {speaker_id + 1}: " if utterances_with_speakers else ""
srt_content.append(f"{i}")
srt_content.append(f"{format_timestamp(start, 'srt')} --> {format_timestamp(end, 'srt')}")
srt_content.append(f"{speaker_prefix}{text}")
srt_content.append("") # Empty line between entries
return "\n".join(srt_content)
def export_to_vtt(utterances: List[Tuple[float, float, str]], utterances_with_speakers=None) -> str:
"""Export to WebVTT (.vtt) format"""
vtt_content = ["WEBVTT", ""]
# Use speaker-aware utterances if available
data_source = utterances_with_speakers if utterances_with_speakers else [(start, end, text, 0) for start, end, text in utterances]
for start, end, text, speaker_id in data_source:
speaker_prefix = f"Speaker {speaker_id + 1}: " if utterances_with_speakers else ""
vtt_content.append(f"{format_timestamp(start, 'vtt')} --> {format_timestamp(end, 'vtt')}")
vtt_content.append(f"{speaker_prefix}{text}")
vtt_content.append("") # Empty line between entries
return "\n".join(vtt_content)
def export_to_ass(utterances: List[Tuple[float, float, str]], utterances_with_speakers=None) -> str:
"""Export to Advanced SubStation Alpha (.ass) format"""
header = """[Script Info]
Title: VoxSum Transcript
ScriptType: v4.00+
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
events = []
# Use speaker-aware utterances if available
data_source = utterances_with_speakers if utterances_with_speakers else [(start, end, text, 0) for start, end, text in utterances]
for start, end, text, speaker_id in data_source:
speaker_prefix = f"Speaker {speaker_id + 1}: " if utterances_with_speakers else ""
events.append(f"Dialogue: 0,{format_timestamp(start, 'ass')},{format_timestamp(end, 'ass')},Default,,0,0,0,,{speaker_prefix}{text}")
return header + "\n".join(events)
def export_to_transcript_json(utterances: List[Tuple[float, float, str]], utterances_with_speakers=None, metadata=None) -> str:
"""Export to JSON format with detailed transcript data"""
# Use speaker-aware utterances if available
data_source = utterances_with_speakers if utterances_with_speakers else [(start, end, text, 0) for start, end, text in utterances]
transcript_data = {
"metadata": metadata or {
"source": "VoxSum",
"format_version": "1.0",
"speakers_detected": len(set(speaker for _, _, _, speaker in data_source)) if utterances_with_speakers else 1
},
"utterances": [
{
"start": start,
"end": end,
"duration": end - start,
"text": text,
"speaker_id": speaker_id,
"speaker_label": f"Speaker {speaker_id + 1}"
}
for start, end, text, speaker_id in data_source
]
}
return json.dumps(transcript_data, indent=2, ensure_ascii=False)
def export_to_elan_eaf(utterances: List[Tuple[float, float, str]], utterances_with_speakers=None) -> str:
"""Export to ELAN (.eaf) format for linguistic analysis"""
import datetime
# Use speaker-aware utterances if available
data_source = utterances_with_speakers if utterances_with_speakers else [(start, end, text, 0) for start, end, text in utterances]
# Get unique speakers
speakers = sorted(set(speaker for _, _, _, speaker in data_source))
current_date = datetime.datetime.now().isoformat()
eaf_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<ANNOTATION_DOCUMENT AUTHOR="VoxSum" DATE="{current_date}" FORMAT="3.0" VERSION="3.0">
<HEADER MEDIA_FILE="" TIME_UNITS="milliseconds">
<PROPERTY NAME="URN">urn:nl-mpi-tools-elan-eaf:voxsum-transcript</PROPERTY>
<PROPERTY NAME="lastUsedAnnotationId">{len(data_source)}</PROPERTY>
</HEADER>
<TIME_ORDER>
"""
# Time slots
time_id = 1
for start, end, _, _ in data_source:
eaf_content += f' <TIME_SLOT TIME_SLOT_ID="ts{time_id}" TIME_VALUE="{int(start * 1000)}"/>\n'
time_id += 1
eaf_content += f' <TIME_SLOT TIME_SLOT_ID="ts{time_id}" TIME_VALUE="{int(end * 1000)}"/>\n'
time_id += 1
eaf_content += " </TIME_ORDER>\n"
# Tiers for each speaker. ANNOTATION_ID must be unique across the whole
# document (ELAN rejects duplicates), so the counter is document-wide and is
# never reset per tier. Annotation text is XML-escaped — a raw '&', '<' or
# '>' (common in transcripts, e.g. "AT&T") would otherwise make the .eaf
# malformed and unloadable.
annotation_id = 1
for speaker_id in speakers:
eaf_content += f' <TIER LINGUISTIC_TYPE_REF="default-lt" TIER_ID="Speaker_{speaker_id + 1}">\n'
time_id = 1
for start, end, text, spk_id in data_source:
if spk_id == speaker_id:
eaf_content += f' <ANNOTATION>\n'
eaf_content += f' <ALIGNABLE_ANNOTATION ANNOTATION_ID="a{annotation_id}" TIME_SLOT_REF1="ts{time_id}" TIME_SLOT_REF2="ts{time_id + 1}">\n'
eaf_content += f' <ANNOTATION_VALUE>{xml_escape(text)}</ANNOTATION_VALUE>\n'
eaf_content += f' </ALIGNABLE_ANNOTATION>\n'
eaf_content += f' </ANNOTATION>\n'
annotation_id += 1
time_id += 2
eaf_content += " </TIER>\n"
eaf_content += """ <LINGUISTIC_TYPE GRAPHIC_REFERENCES="false" LINGUISTIC_TYPE_ID="default-lt" TIME_ALIGNABLE="true"/>
<CONSTRAINT DESCRIPTION="Time subdivision of parent annotation's time interval, no time gaps allowed within this interval" STEREOTYPE="Time_Subdivision"/>
<CONSTRAINT DESCRIPTION="Symbolic subdivision of a parent annotation. Annotations refering to the same parent are ordered" STEREOTYPE="Symbolic_Subdivision"/>
<CONSTRAINT DESCRIPTION="1-1 association with a parent annotation" STEREOTYPE="Symbolic_Association"/>
<CONSTRAINT DESCRIPTION="Time alignable annotations within the parent annotation's time interval, gaps are allowed" STEREOTYPE="Included_In"/>
</ANNOTATION_DOCUMENT>"""
return eaf_content
def export_plain_text(utterances: List[Tuple[float, float, str]], utterances_with_speakers=None, include_timestamps=True) -> str:
"""Export to plain text format"""
lines = []
# Use speaker-aware utterances if available
data_source = utterances_with_speakers if utterances_with_speakers else [(start, end, text, 0) for start, end, text in utterances]
current_speaker = None
for start, end, text, speaker_id in data_source:
# Add speaker header when speaker changes (for diarized content)
if utterances_with_speakers and speaker_id != current_speaker:
if lines: # Add blank line before new speaker (except first)
lines.append("")
lines.append(f"Speaker {speaker_id + 1}:")
current_speaker = speaker_id
# Add timestamp if requested
if include_timestamps:
timestamp = f"[{format_timestamp(start, 'default')}] "
else:
timestamp = ""
lines.append(f"{timestamp}{text}")
return "\n".join(lines)
def export_summary_markdown(summary: str, metadata=None) -> str:
"""Export summary in Markdown format with metadata"""
md_content = []
if metadata:
md_content.append("# Summary")
md_content.append("")
if metadata.get("title"):
md_content.append(f"**Title:** {metadata['title']}")
if metadata.get("duration"):
md_content.append(f"**Duration:** {metadata['duration']}")
if metadata.get("speakers"):
md_content.append(f"**Speakers:** {metadata['speakers']}")
if metadata.get("date"):
md_content.append(f"**Date:** {metadata['date']}")
md_content.append("")
md_content.append("## Content")
md_content.append("")
md_content.append(summary)
return "\n".join(md_content)
def export_summary_plain_text(summary: str, metadata=None) -> str:
"""Export summary in plain text format"""
content = []
if metadata:
content.append("SUMMARY")
content.append("=" * 50)
content.append("")
if metadata.get("title"):
content.append(f"Title: {metadata['title']}")
if metadata.get("duration"):
content.append(f"Duration: {metadata['duration']}")
if metadata.get("speakers"):
content.append(f"Speakers: {metadata['speakers']}")
if metadata.get("date"):
content.append(f"Date: {metadata['date']}")
content.append("")
content.append("CONTENT")
content.append("-" * 50)
content.append("")
content.append(summary)
return "\n".join(content)
# Export format definitions
SUBTITLE_FORMATS = {
"SRT (SubRip)": {
"extension": ".srt",
"mime_type": "text/plain",
"function": export_to_srt
},
"VTT (WebVTT)": {
"extension": ".vtt",
"mime_type": "text/vtt",
"function": export_to_vtt
},
"ASS (Advanced SubStation Alpha)": {
"extension": ".ass",
"mime_type": "text/plain",
"function": export_to_ass
}
}
TRANSCRIPT_FORMATS = {
"Plain Text": {
"extension": ".txt",
"mime_type": "text/plain",
"function": export_plain_text
},
"JSON": {
"extension": ".json",
"mime_type": "application/json",
"function": export_to_transcript_json
},
"ELAN (EAF)": {
"extension": ".eaf",
"mime_type": "application/xml",
"function": export_to_elan_eaf
}
}
SUMMARY_FORMATS = {
"Markdown": {
"extension": ".md",
"mime_type": "text/markdown",
"function": export_summary_markdown
},
"Plain Text": {
"extension": ".txt",
"mime_type": "text/plain",
"function": export_summary_plain_text
}
}