EchoScript / models /transcript.py
usertea
EchoScript : two fully independent pipelines, chosen explicitly, never both from one click : Transcript & Translations vs. Phonetic Transcription (IPA)
9b67bb3
Raw
History Blame Contribute Delete
5.44 kB
"""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
],
}