Spaces:
Sleeping
Sleeping
File size: 5,444 Bytes
7d761b6 9b67bb3 7d761b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | """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
],
}
|