"""Core data model for EchoScript. A `Transcript` is the canonical representation of "what was said" in an audio file. It is produced exactly once per upload (optionally restricted to a start/end time window). Every other artifact -- translations, subtitle files, future summaries/keywords -- is derived from a Transcript and must never reach back into the original audio. Audio -> Transcript -> Outputs (allowed) Audio -> Translation (never) Phonetic transcription (services/phonetics.py) is a separate, mutually exclusive pipeline, not a step alongside this one: the person chooses either "Generate Transcript" (this model, feeding translations/subtitles) or "Generate Phonetic Transcription" (reads the audio directly, IPA output, no Transcript involved at all) -- never both from the same audio in the same app.py action. This keeps Transcript the single, unambiguous source of truth for every translation, with no parallel audio-reading path that could make you wonder which one a downstream artifact came from. See services/phonetics.py and app.py's mode selector for why. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Optional @dataclass(frozen=True) class Segment: """A single timed chunk of text (transcribed or translated).""" index: int start: float # seconds, relative to the processed audio window end: float # seconds text: str @property def duration(self) -> float: return max(0.0, self.end - self.start) @dataclass class Transcript: """The canonical transcript of an audio file. This is the single source of truth for everything downstream. If a user edits text in the UI (see v1.1: Transcript Editing), that edit happens on this object, and every translation/subtitle export regenerated after the edit will reflect it automatically. """ source_filename: str language: str # ISO 639-1 code detected/forced, e.g. "fr" language_probability: float # 0..1, Whisper's detection confidence duration: float # seconds, of the processed window segments: list[Segment] = field(default_factory=list) # Optional processing window applied to the source audio, in seconds, # relative to the original file. None means "from the very start" / # "to the very end" for that side of the window. window_start: Optional[float] = None window_end: Optional[float] = None @property def text(self) -> str: """Full plain-text transcript, segments joined with newlines.""" return "\n".join(s.text for s in self.segments) @property def word_count(self) -> int: return len(self.text.split()) def replace_text(self, new_text: str) -> None: """Used by the (future) transcript-editing feature. Re-flows freeform edited text back across the existing segment timings as evenly as possible, so timing-dependent outputs (SRT/VTT) keep working after a manual correction. Intentionally simple for v1.0; a smarter alignment can replace this later without touching any other service. """ lines = new_text.split("\n") if len(lines) != len(self.segments): # Fallback: dump everything into the first segment rather than # silently losing edited text. if self.segments: self.segments = [ Segment( index=1, start=self.segments[0].start, end=self.segments[-1].end, text=new_text.strip(), ) ] return self.segments = [ Segment(index=seg.index, start=seg.start, end=seg.end, text=line.strip()) for seg, line in zip(self.segments, lines) ] def to_dict(self) -> dict: return { "source_filename": self.source_filename, "language": self.language, "language_probability": self.language_probability, "duration": self.duration, "window_start": self.window_start, "window_end": self.window_end, "word_count": self.word_count, "segments": [ {"index": s.index, "start": s.start, "end": s.end, "text": s.text} for s in self.segments ], } @dataclass class Translation: """A translation of a Transcript into a target language. Always derived from `Transcript.text` / per-segment text, never from the original audio. Segment timings are copied 1:1 from the source transcript so subtitle generation keeps working on translated output. """ source_language: str target_language: str segments: list[Segment] = field(default_factory=list) @property def text(self) -> str: return "\n".join(s.text for s in self.segments) @property def word_count(self) -> int: return len(self.text.split()) def to_dict(self) -> dict: return { "source_language": self.source_language, "target_language": self.target_language, "word_count": self.word_count, "segments": [ {"index": s.index, "start": s.start, "end": s.end, "text": s.text} for s in self.segments ], }