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
            ],
        }