usertea commited on
Commit
7d761b6
·
1 Parent(s): 7e1a6e6

EchoScript : New Architecture

Browse files
app.py CHANGED
@@ -1,87 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
1
  import tempfile
2
  from pathlib import Path
3
  from zipfile import ZipFile
4
 
5
  import gradio as gr
6
- from faster_whisper import WhisperModel
7
 
8
- MODEL_SIZE = "base"
 
 
9
 
10
- model = WhisperModel(
11
- MODEL_SIZE,
12
  device="cpu",
13
  compute_type="int8",
14
- download_root="/tmp/whisper_models"
15
  )
16
-
17
- LANGUAGE_NAMES = {
18
- "fr": "French",
19
- "en": "English",
20
- "de": "German",
21
- "fa": "Persian",
22
- "es": "Spanish",
23
- "it": "Italian",
24
- "pt": "Portuguese",
25
- "nl": "Dutch",
26
- "ar": "Arabic",
27
- "ru": "Russian",
28
- "tr": "Turkish"
29
- }
30
-
31
-
32
- def format_srt_timestamp(seconds):
33
- hours = int(seconds // 3600)
34
- minutes = int((seconds % 3600) // 60)
35
- secs = int(seconds % 60)
36
- millis = int((seconds - int(seconds)) * 1000)
37
-
38
- return (
39
- f"{hours:02}:{minutes:02}:{secs:02},{millis:03}"
40
- )
41
-
42
-
43
- def format_vtt_timestamp(seconds):
44
- hours = int(seconds // 3600)
45
- minutes = int((seconds % 3600) // 60)
46
- secs = int(seconds % 60)
47
- millis = int((seconds - int(seconds)) * 1000)
48
-
49
- return (
50
- f"{hours:02}:{minutes:02}:{secs:02}.{millis:03}"
51
- )
52
-
53
-
54
- def generate_srt(segments):
55
- lines = []
56
-
57
- for idx, segment in enumerate(segments, start=1):
58
-
59
- start = format_srt_timestamp(segment.start)
60
- end = format_srt_timestamp(segment.end)
61
-
62
- lines.append(
63
- f"{idx}\n"
64
- f"{start} --> {end}\n"
65
- f"{segment.text.strip()}\n"
66
- )
67
-
68
- return "\n".join(lines)
69
-
70
-
71
- def generate_vtt(segments):
72
- lines = ["WEBVTT\n"]
73
-
74
- for segment in segments:
75
-
76
- start = format_vtt_timestamp(segment.start)
77
- end = format_vtt_timestamp(segment.end)
78
-
79
- lines.append(
80
- f"{start} --> {end}\n"
81
- f"{segment.text.strip()}\n"
82
- )
83
-
84
- return "\n".join(lines)
85
 
86
 
87
  def process_files(files, mode):
@@ -90,9 +34,7 @@ def process_files(files, mode):
90
  return "", None
91
 
92
  tmp_dir = Path(tempfile.mkdtemp())
93
-
94
  summary_lines = []
95
-
96
  zip_path = tmp_dir / "echoscript_results.zip"
97
 
98
  with ZipFile(zip_path, "w") as zipf:
@@ -100,85 +42,45 @@ def process_files(files, mode):
100
  for uploaded_file in files:
101
 
102
  audio_path = uploaded_file
103
-
104
  stem = Path(audio_path).stem
105
 
106
- task = (
107
- "transcribe"
108
- if mode == "Transcribe"
109
- else "translate"
110
- )
111
-
112
- segments_generator, info = model.transcribe(
113
  audio_path,
114
- task=task,
115
- beam_size=5
116
  )
117
 
118
- segments = list(segments_generator)
119
-
120
- transcript = "\n".join(
121
- segment.text.strip()
122
- for segment in segments
123
- )
124
-
125
- srt_content = generate_srt(segments)
126
- vtt_content = generate_vtt(segments)
127
-
128
- language = LANGUAGE_NAMES.get(
129
- info.language,
130
- info.language
131
- )
132
-
133
- summary_lines.append(
134
- f"{stem}\n"
135
- f"Language: {language}\n"
136
- f"Confidence: {info.language_probability:.2%}\n"
137
- )
138
 
139
  txt_file = tmp_dir / f"{stem}.txt"
140
  srt_file = tmp_dir / f"{stem}.srt"
141
  vtt_file = tmp_dir / f"{stem}.vtt"
142
 
143
- txt_file.write_text(
144
- transcript,
145
- encoding="utf-8"
146
- )
147
-
148
- srt_file.write_text(
149
- srt_content,
150
- encoding="utf-8"
151
- )
152
 
153
- vtt_file.write_text(
154
- vtt_content,
155
- encoding="utf-8"
156
- )
157
 
158
- zipf.write(
159
- txt_file,
160
- arcname=txt_file.name
161
- )
162
-
163
- zipf.write(
164
- srt_file,
165
- arcname=srt_file.name
166
- )
167
-
168
- zipf.write(
169
- vtt_file,
170
- arcname=vtt_file.name
171
  )
172
 
173
- return (
174
- "\n\n".join(summary_lines),
175
- str(zip_path)
176
- )
177
 
178
 
179
- with gr.Blocks(
180
- title="EchoScript"
181
- ) as demo:
182
 
183
  gr.Markdown(
184
  """
@@ -198,42 +100,23 @@ Transcribe audio files using Faster-Whisper.
198
  """
199
  )
200
 
201
- files_input = gr.Files(
202
- label="Upload Audio Files"
203
- )
204
 
205
  mode_input = gr.Dropdown(
206
- choices=[
207
- "Transcribe",
208
- "Translate to English"
209
- ],
210
  value="Transcribe",
211
- label="Mode"
212
- )
213
-
214
- run_button = gr.Button(
215
- "Start Processing"
216
  )
217
 
218
- summary_output = gr.Textbox(
219
- label="Results",
220
- lines=12
221
- )
222
 
223
- download_output = gr.File(
224
- label="Download ZIP"
225
- )
226
 
227
  run_button.click(
228
  fn=process_files,
229
- inputs=[
230
- files_input,
231
- mode_input
232
- ],
233
- outputs=[
234
- summary_output,
235
- download_output
236
- ]
237
  )
238
 
239
  if __name__ == "__main__":
 
1
+ """EchoScript app entrypoint.
2
+
3
+ UI is intentionally left as-is for this step -- only the internals changed.
4
+ Previously this file talked to faster-whisper directly. It now goes through
5
+ the service layer (services/transcription.py, services/translation.py,
6
+ services/subtitles.py) operating on the models/transcript.py data model, so
7
+ the Audio -> Transcript -> Outputs flow is real code, not just a diagram.
8
+ The Gradio UI itself (upload dropzone, time window, language dropdown,
9
+ output checkboxes, results dashboard, tabs) is the next step.
10
+ """
11
+
12
  import tempfile
13
  from pathlib import Path
14
  from zipfile import ZipFile
15
 
16
  import gradio as gr
 
17
 
18
+ from services.subtitles import generate_srt, generate_vtt
19
+ from services.transcription import TranscriptionService
20
+ from services.translation import TranslationService
21
 
22
+ transcription_service = TranscriptionService(
23
+ model_size="base",
24
  device="cpu",
25
  compute_type="int8",
26
+ download_root="/tmp/whisper_models",
27
  )
28
+ translation_service = TranslationService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def process_files(files, mode):
 
34
  return "", None
35
 
36
  tmp_dir = Path(tempfile.mkdtemp())
 
37
  summary_lines = []
 
38
  zip_path = tmp_dir / "echoscript_results.zip"
39
 
40
  with ZipFile(zip_path, "w") as zipf:
 
42
  for uploaded_file in files:
43
 
44
  audio_path = uploaded_file
 
45
  stem = Path(audio_path).stem
46
 
47
+ # Step 1: Audio -> canonical Transcript. Always "transcribe",
48
+ # never "translate" -- see services/translation.py for why.
49
+ transcript = transcription_service.transcribe(
 
 
 
 
50
  audio_path,
51
+ source_filename=Path(audio_path).name,
 
52
  )
53
 
54
+ # Step 2: Outputs are derived from the Transcript, never from
55
+ # the audio again.
56
+ if mode == "Translate to English" and transcript.language != "en":
57
+ output = translation_service.translate(transcript, "en")
58
+ else:
59
+ output = transcript
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  txt_file = tmp_dir / f"{stem}.txt"
62
  srt_file = tmp_dir / f"{stem}.srt"
63
  vtt_file = tmp_dir / f"{stem}.vtt"
64
 
65
+ txt_file.write_text(output.text, encoding="utf-8")
66
+ srt_file.write_text(generate_srt(output.segments), encoding="utf-8")
67
+ vtt_file.write_text(generate_vtt(output.segments), encoding="utf-8")
 
 
 
 
 
 
68
 
69
+ for f in (txt_file, srt_file, vtt_file):
70
+ zipf.write(f, arcname=f.name)
 
 
71
 
72
+ language_name = transcript.language
73
+ summary_lines.append(
74
+ f"{stem}\n"
75
+ f"Language: {language_name}\n"
76
+ f"Confidence: {transcript.language_probability:.2%}\n"
77
+ f"Words: {transcript.word_count}\n"
 
 
 
 
 
 
 
78
  )
79
 
80
+ return "\n\n".join(summary_lines), str(zip_path)
 
 
 
81
 
82
 
83
+ with gr.Blocks(title="EchoScript") as demo:
 
 
84
 
85
  gr.Markdown(
86
  """
 
100
  """
101
  )
102
 
103
+ files_input = gr.Files(label="Upload Audio Files")
 
 
104
 
105
  mode_input = gr.Dropdown(
106
+ choices=["Transcribe", "Translate to English"],
 
 
 
107
  value="Transcribe",
108
+ label="Mode",
 
 
 
 
109
  )
110
 
111
+ run_button = gr.Button("Start Processing")
 
 
 
112
 
113
+ summary_output = gr.Textbox(label="Results", lines=12)
114
+ download_output = gr.File(label="Download ZIP")
 
115
 
116
  run_button.click(
117
  fn=process_files,
118
+ inputs=[files_input, mode_input],
119
+ outputs=[summary_output, download_output],
 
 
 
 
 
 
120
  )
121
 
122
  if __name__ == "__main__":
models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from models.transcript import Segment, Transcript, Translation
2
+
3
+ __all__ = ["Segment", "Transcript", "Translation"]
models/transcript.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core data model for EchoScript.
2
+
3
+ A `Transcript` is the canonical representation of "what was said" in an
4
+ audio file. It is produced exactly once per upload (optionally restricted
5
+ to a start/end time window). Every other artifact -- translations,
6
+ subtitle files, future summaries/keywords -- is derived from a Transcript
7
+ and must never reach back into the original audio.
8
+
9
+ Audio -> Transcript -> Outputs (allowed)
10
+ Audio -> Translation (never)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Segment:
21
+ """A single timed chunk of text (transcribed or translated)."""
22
+
23
+ index: int
24
+ start: float # seconds, relative to the processed audio window
25
+ end: float # seconds
26
+ text: str
27
+
28
+ @property
29
+ def duration(self) -> float:
30
+ return max(0.0, self.end - self.start)
31
+
32
+
33
+ @dataclass
34
+ class Transcript:
35
+ """The canonical transcript of an audio file.
36
+
37
+ This is the single source of truth for everything downstream. If a user
38
+ edits text in the UI (see v1.1: Transcript Editing), that edit happens
39
+ on this object, and every translation/subtitle export regenerated after
40
+ the edit will reflect it automatically.
41
+ """
42
+
43
+ source_filename: str
44
+ language: str # ISO 639-1 code detected/forced, e.g. "fr"
45
+ language_probability: float # 0..1, Whisper's detection confidence
46
+ duration: float # seconds, of the processed window
47
+ segments: list[Segment] = field(default_factory=list)
48
+
49
+ # Optional processing window applied to the source audio, in seconds,
50
+ # relative to the original file. None means "from the very start" /
51
+ # "to the very end" for that side of the window.
52
+ window_start: Optional[float] = None
53
+ window_end: Optional[float] = None
54
+
55
+ @property
56
+ def text(self) -> str:
57
+ """Full plain-text transcript, segments joined with newlines."""
58
+ return "\n".join(s.text for s in self.segments)
59
+
60
+ @property
61
+ def word_count(self) -> int:
62
+ return len(self.text.split())
63
+
64
+ def replace_text(self, new_text: str) -> None:
65
+ """Used by the (future) transcript-editing feature.
66
+
67
+ Re-flows freeform edited text back across the existing segment
68
+ timings as evenly as possible, so timing-dependent outputs (SRT/VTT)
69
+ keep working after a manual correction. Intentionally simple for
70
+ v1.0; a smarter alignment can replace this later without touching
71
+ any other service.
72
+ """
73
+ lines = new_text.split("\n")
74
+ if len(lines) != len(self.segments):
75
+ # Fallback: dump everything into the first segment rather than
76
+ # silently losing edited text.
77
+ if self.segments:
78
+ self.segments = [
79
+ Segment(
80
+ index=1,
81
+ start=self.segments[0].start,
82
+ end=self.segments[-1].end,
83
+ text=new_text.strip(),
84
+ )
85
+ ]
86
+ return
87
+
88
+ self.segments = [
89
+ Segment(index=seg.index, start=seg.start, end=seg.end, text=line.strip())
90
+ for seg, line in zip(self.segments, lines)
91
+ ]
92
+
93
+ def to_dict(self) -> dict:
94
+ return {
95
+ "source_filename": self.source_filename,
96
+ "language": self.language,
97
+ "language_probability": self.language_probability,
98
+ "duration": self.duration,
99
+ "window_start": self.window_start,
100
+ "window_end": self.window_end,
101
+ "word_count": self.word_count,
102
+ "segments": [
103
+ {"index": s.index, "start": s.start, "end": s.end, "text": s.text}
104
+ for s in self.segments
105
+ ],
106
+ }
107
+
108
+
109
+ @dataclass
110
+ class Translation:
111
+ """A translation of a Transcript into a target language.
112
+
113
+ Always derived from `Transcript.text` / per-segment text, never from
114
+ the original audio. Segment timings are copied 1:1 from the source
115
+ transcript so subtitle generation keeps working on translated output.
116
+ """
117
+
118
+ source_language: str
119
+ target_language: str
120
+ segments: list[Segment] = field(default_factory=list)
121
+
122
+ @property
123
+ def text(self) -> str:
124
+ return "\n".join(s.text for s in self.segments)
125
+
126
+ @property
127
+ def word_count(self) -> int:
128
+ return len(self.text.split())
129
+
130
+ def to_dict(self) -> dict:
131
+ return {
132
+ "source_language": self.source_language,
133
+ "target_language": self.target_language,
134
+ "word_count": self.word_count,
135
+ "segments": [
136
+ {"index": s.index, "start": s.start, "end": s.end, "text": s.text}
137
+ for s in self.segments
138
+ ],
139
+ }
requirements.txt CHANGED
@@ -1,2 +1,6 @@
1
- gradio>=5.0
2
- faster-whisper>=1.1
 
 
 
 
 
1
+ gradio>=4.0
2
+ faster-whisper>=1.0
3
+ transformers>=4.40
4
+ sentencepiece>=0.2
5
+ sacremoses>=0.1
6
+ torch>=2.0
services/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from services.audio import AudioError, extract_window, resolve_window, validate_extension
2
+ from services.subtitles import generate_srt, generate_vtt
3
+ from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
4
+ from services.translation import (
5
+ SUPPORTED_TARGET_LANGUAGES,
6
+ TranslationError,
7
+ TranslationService,
8
+ )
9
+
10
+ __all__ = [
11
+ "AudioError",
12
+ "extract_window",
13
+ "resolve_window",
14
+ "validate_extension",
15
+ "generate_srt",
16
+ "generate_vtt",
17
+ "SUPPORTED_LANGUAGES",
18
+ "TranscriptionService",
19
+ "SUPPORTED_TARGET_LANGUAGES",
20
+ "TranslationError",
21
+ "TranslationService",
22
+ ]
services/audio.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio input handling: extension validation and optional time-window trim.
2
+
3
+ This is the only service allowed to touch the raw audio file. Once a
4
+ Transcript exists, nothing else needs the audio again.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac"}
16
+
17
+ # Accepts "HH:MM:SS", "MM:SS", with optional fractional seconds.
18
+ _TIME_RE = re.compile(
19
+ r"^(?:(?P<hours>\d{1,2}):)?(?P<minutes>\d{1,2}):(?P<seconds>\d{1,2}(?:\.\d+)?)$"
20
+ )
21
+
22
+
23
+ class AudioError(ValueError):
24
+ """Raised for invalid audio files or invalid/illogical time windows."""
25
+
26
+
27
+ def validate_extension(filename: str) -> None:
28
+ suffix = Path(filename).suffix.lower()
29
+ if suffix not in SUPPORTED_EXTENSIONS:
30
+ raise AudioError(
31
+ f"Unsupported file type '{suffix}'. "
32
+ f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
33
+ )
34
+
35
+
36
+ def parse_timestamp(value: Optional[str]) -> Optional[float]:
37
+ """Parse 'HH:MM:SS' or 'MM:SS' into seconds. Blank/None -> None."""
38
+ if value is None:
39
+ return None
40
+ value = value.strip()
41
+ if not value:
42
+ return None
43
+
44
+ match = _TIME_RE.match(value)
45
+ if not match:
46
+ raise AudioError(f"Invalid timestamp '{value}'. Expected HH:MM:SS.")
47
+
48
+ hours = int(match.group("hours") or 0)
49
+ minutes = int(match.group("minutes"))
50
+ seconds = float(match.group("seconds"))
51
+ return hours * 3600 + minutes * 60 + seconds
52
+
53
+
54
+ def resolve_window(
55
+ start_value: Optional[str],
56
+ end_value: Optional[str],
57
+ ) -> tuple[Optional[float], Optional[float]]:
58
+ """Parse and sanity-check a start/end window.
59
+
60
+ Returns (start_seconds, end_seconds); either or both may be None,
61
+ meaning "entire file" on that side.
62
+ """
63
+ start = parse_timestamp(start_value)
64
+ end = parse_timestamp(end_value)
65
+
66
+ if start is not None and end is not None and end <= start:
67
+ raise AudioError("End time must be after start time.")
68
+
69
+ return start, end
70
+
71
+
72
+ def extract_window(
73
+ audio_path: str,
74
+ start: Optional[float],
75
+ end: Optional[float],
76
+ workdir: Optional[Path] = None,
77
+ ) -> str:
78
+ """Trim `audio_path` to [start, end] seconds using ffmpeg.
79
+
80
+ Returns a path to the trimmed file. If both start and end are None,
81
+ returns the original path unchanged ("entire file").
82
+ """
83
+ if start is None and end is None:
84
+ return audio_path
85
+
86
+ workdir = workdir or Path(tempfile.mkdtemp(prefix="echoscript_audio_"))
87
+ workdir.mkdir(parents=True, exist_ok=True)
88
+
89
+ suffix = Path(audio_path).suffix
90
+ out_path = workdir / f"window{suffix}"
91
+
92
+ cmd = ["ffmpeg", "-y", "-i", str(audio_path)]
93
+ if start is not None:
94
+ cmd += ["-ss", str(start)]
95
+ if end is not None:
96
+ cmd += ["-t", str(end - (start or 0.0))]
97
+ cmd += ["-c", "copy", str(out_path)]
98
+
99
+ result = subprocess.run(cmd, capture_output=True, text=True)
100
+ if result.returncode != 0:
101
+ # Stream-copy can fail if the cut point isn't on a keyframe;
102
+ # fall back to re-encoding.
103
+ cmd[-2:] = ["-c:a", "pcm_s16le", str(out_path)]
104
+ result = subprocess.run(cmd, capture_output=True, text=True)
105
+ if result.returncode != 0:
106
+ raise AudioError(f"ffmpeg failed to trim audio: {result.stderr.strip()}")
107
+
108
+ return str(out_path)
services/subtitles.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Subtitle generation: timed segments -> SRT/VTT text.
2
+
3
+ Works against anything exposing `.segments` of objects with start/end/text
4
+ (a Transcript or a Translation), keeping with the rule that subtitles are
5
+ always derived data and never re-recorded from audio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Iterable, Protocol
11
+
12
+
13
+ class HasTimedText(Protocol):
14
+ start: float
15
+ end: float
16
+ text: str
17
+
18
+
19
+ def _format_srt_timestamp(seconds: float) -> str:
20
+ seconds = max(0.0, seconds)
21
+ hours = int(seconds // 3600)
22
+ minutes = int((seconds % 3600) // 60)
23
+ secs = int(seconds % 60)
24
+ millis = int(round((seconds - int(seconds)) * 1000))
25
+ return f"{hours:02}:{minutes:02}:{secs:02},{millis:03}"
26
+
27
+
28
+ def _format_vtt_timestamp(seconds: float) -> str:
29
+ seconds = max(0.0, seconds)
30
+ hours = int(seconds // 3600)
31
+ minutes = int((seconds % 3600) // 60)
32
+ secs = int(seconds % 60)
33
+ millis = int(round((seconds - int(seconds)) * 1000))
34
+ return f"{hours:02}:{minutes:02}:{secs:02}.{millis:03}"
35
+
36
+
37
+ def generate_srt(segments: Iterable[HasTimedText]) -> str:
38
+ lines = []
39
+ for idx, segment in enumerate(segments, start=1):
40
+ start = _format_srt_timestamp(segment.start)
41
+ end = _format_srt_timestamp(segment.end)
42
+ lines.append(f"{idx}\n{start} --> {end}\n{segment.text.strip()}\n")
43
+ return "\n".join(lines)
44
+
45
+
46
+ def generate_vtt(segments: Iterable[HasTimedText]) -> str:
47
+ lines = ["WEBVTT\n"]
48
+ for segment in segments:
49
+ start = _format_vtt_timestamp(segment.start)
50
+ end = _format_vtt_timestamp(segment.end)
51
+ lines.append(f"{start} --> {end}\n{segment.text.strip()}\n")
52
+ return "\n".join(lines)
services/transcription.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transcription service: Audio -> canonical Transcript.
2
+
3
+ This is the only place that runs Whisper. It always uses Whisper's
4
+ "transcribe" task, never "translate" -- translation is handled as a
5
+ separate, text-based step (see services/translation.py) operating on the
6
+ Transcript this produces. That split is the core v1.0 architecture
7
+ decision: there is exactly one path from audio to text, and everything
8
+ else branches off the resulting Transcript.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ from faster_whisper import WhisperModel
16
+
17
+ from models.transcript import Segment, Transcript
18
+
19
+ # ISO 639-1 code -> display name. Used both for the "Source Language"
20
+ # dropdown and for labelling detected languages in the results dashboard.
21
+ SUPPORTED_LANGUAGES: dict[str, str] = {
22
+ "fr": "French",
23
+ "en": "English",
24
+ "de": "German",
25
+ "fa": "Persian",
26
+ "es": "Spanish",
27
+ }
28
+
29
+
30
+ class TranscriptionService:
31
+ """Thin wrapper around a faster-whisper model.
32
+
33
+ Deliberately has no knowledge of translation, subtitles, or the UI --
34
+ it only knows how to turn audio into a Transcript.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ model_size: str = "base",
40
+ device: str = "cpu",
41
+ compute_type: str = "int8",
42
+ download_root: str = "/tmp/whisper_models",
43
+ ) -> None:
44
+ self._model = WhisperModel(
45
+ model_size,
46
+ device=device,
47
+ compute_type=compute_type,
48
+ download_root=download_root,
49
+ )
50
+
51
+ def transcribe(
52
+ self,
53
+ audio_path: str,
54
+ source_filename: str,
55
+ language: Optional[str] = None,
56
+ window_start: Optional[float] = None,
57
+ window_end: Optional[float] = None,
58
+ beam_size: int = 5,
59
+ ) -> Transcript:
60
+ """Run speech-to-text and return the canonical Transcript.
61
+
62
+ `language` is an ISO 639-1 code, or None for auto-detect (the
63
+ "Auto Detect" dropdown option).
64
+ """
65
+ segments_iter, info = self._model.transcribe(
66
+ audio_path,
67
+ task="transcribe",
68
+ language=language,
69
+ beam_size=beam_size,
70
+ )
71
+
72
+ segments = [
73
+ Segment(index=i, start=seg.start, end=seg.end, text=seg.text.strip())
74
+ for i, seg in enumerate(segments_iter, start=1)
75
+ ]
76
+
77
+ duration = segments[-1].end if segments else 0.0
78
+
79
+ return Transcript(
80
+ source_filename=source_filename,
81
+ language=info.language,
82
+ language_probability=info.language_probability,
83
+ duration=duration,
84
+ segments=segments,
85
+ window_start=window_start,
86
+ window_end=window_end,
87
+ )
services/translation.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Translation service: Transcript -> Translation (text only).
2
+
3
+ Per the EchoScript v1.0 architecture decision, translation is ALWAYS
4
+ derived from the canonical Transcript's text, never from the original
5
+ audio:
6
+
7
+ Audio -> Transcript -> Translation (allowed)
8
+ Audio -> Translation (never)
9
+
10
+ This keeps a single source of truth: if a name or term is fixed once in
11
+ the transcript (v1.1: Transcript Editing), every translation regenerated
12
+ afterwards picks up the fix automatically, and every translation stays in
13
+ sync with the same segment timings as the transcript (so subtitles still
14
+ work for translated output).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from functools import lru_cache
20
+
21
+ from models.transcript import Segment, Transcript, Translation
22
+
23
+ # Target languages exposed as the "Outputs" checkboxes in the UI.
24
+ SUPPORTED_TARGET_LANGUAGES: dict[str, str] = {
25
+ "en": "English",
26
+ "de": "German",
27
+ "fa": "Persian",
28
+ "es": "Spanish",
29
+ }
30
+
31
+
32
+ class TranslationError(RuntimeError):
33
+ """Raised when no translation model/engine is available for a pair."""
34
+
35
+
36
+ @lru_cache(maxsize=None)
37
+ def _load_pipeline(source_language: str, target_language: str):
38
+ """Lazily load and cache a MarianMT pipeline for one language pair.
39
+
40
+ Cached so repeated translations within a session don't reload a model
41
+ from disk every time. Swapping the translation backend later (a
42
+ different model, a hosted API, an offline engine like Argos) only
43
+ requires changing this one function.
44
+ """
45
+ from transformers import pipeline # heavy import, deferred until needed
46
+
47
+ model_name = f"Helsinki-NLP/opus-mt-{source_language}-{target_language}"
48
+ try:
49
+ return pipeline("translation", model=model_name)
50
+ except Exception as exc: # pragma: no cover - depends on model availability
51
+ raise TranslationError(
52
+ f"No translation model available for "
53
+ f"'{source_language}' -> '{target_language}': {exc}"
54
+ ) from exc
55
+
56
+
57
+ class TranslationService:
58
+ """Translates a Transcript into one or more target languages."""
59
+
60
+ def translate(self, transcript: Transcript, target_language: str) -> Translation:
61
+ """Translate every segment of `transcript`, preserving timing."""
62
+ if target_language == transcript.language:
63
+ # Already in the target language -- relabel, don't re-translate.
64
+ return Translation(
65
+ source_language=transcript.language,
66
+ target_language=target_language,
67
+ segments=list(transcript.segments),
68
+ )
69
+
70
+ translator = _load_pipeline(transcript.language, target_language)
71
+
72
+ translated_segments = []
73
+ for seg in transcript.segments:
74
+ if not seg.text:
75
+ translated_segments.append(seg)
76
+ continue
77
+ result_text = translator(seg.text)[0]["translation_text"]
78
+ translated_segments.append(
79
+ Segment(index=seg.index, start=seg.start, end=seg.end, text=result_text.strip())
80
+ )
81
+
82
+ return Translation(
83
+ source_language=transcript.language,
84
+ target_language=target_language,
85
+ segments=translated_segments,
86
+ )
87
+
88
+ def translate_many(
89
+ self,
90
+ transcript: Transcript,
91
+ target_languages: list[str],
92
+ ) -> dict[str, Translation]:
93
+ """Translate into several target languages at once.
94
+
95
+ Returns a dict keyed by target-language code, in line with how the
96
+ UI's multi-select "Outputs" checkboxes will want to fan out.
97
+ """
98
+ return {lang: self.translate(transcript, lang) for lang in target_languages}