usertea commited on
Commit
b849929
·
1 Parent(s): ae768cc

EchoScript : app.py, Cascading language filtering, Transcript caching, Per-language translation caching, Tab visibility

Browse files
Files changed (1) hide show
  1. app.py +209 -103
app.py CHANGED
@@ -9,17 +9,28 @@ Services are instantiated lazily (on first use) rather than at import time,
9
  so the app can start up without needing model weights on disk yet, and so
10
  this module stays import-safe in environments without network access.
11
 
12
- Translation languages on offer depend on whether the person supplies their
13
- own Anthropic API key for that session:
14
-
15
- - No key: translation runs on the local, offline MarianMT backend, so only
16
- the languages it can reliably reach are offered (services.translation.
17
- MARIAN_TARGET_LANGUAGES).
18
- - Key supplied: translation runs through Claude, which has no missing-
19
- language-pair problem, so the full language list is offered (services.
20
- translation.ANTHROPIC_TARGET_LANGUAGES). The key is used only for the
21
- request(s) made during this session and is never written to disk,
22
- logged, or cached anywhere server-side.
 
 
 
 
 
 
 
 
 
 
 
23
  """
24
 
25
  from __future__ import annotations
@@ -47,7 +58,8 @@ from services.translation import (
47
  # Safe to share across requests/users: TranscriptionService holds no
48
  # per-request state, and TranslationService resolves its backend (and
49
  # takes the API key, if any) fresh on every translate() call rather than
50
- # storing it -- see services/translation.py.
 
51
  # ---------------------------------------------------------------------------
52
 
53
  _transcription_service: Optional[TranscriptionService] = None
@@ -81,23 +93,40 @@ def get_translation_service() -> TranslationService:
81
  _NAME_TO_CODE = {name: code for code, name in SUPPORTED_LANGUAGES.items()}
82
  SOURCE_LANGUAGE_CHOICES = ["Auto Detect"] + list(SUPPORTED_LANGUAGES.values())
83
 
84
- # "Outputs" checkbox labels, for each of the two language sets. The
85
- # Anthropic set is a superset of the Marian one, so switching a key in/out
86
- # only ever adds or removes options -- it never renames existing ones.
87
- _MARIAN_OUTPUT_CHOICES = ["Transcript"] + [f"{name} Translation" for name in MARIAN_TARGET_LANGUAGES.values()]
88
- _ANTHROPIC_OUTPUT_CHOICES = ["Transcript"] + [f"{name} Translation" for name in ANTHROPIC_TARGET_LANGUAGES.values()]
89
- DEFAULT_OUTPUTS = ["Transcript", "English Translation"]
90
-
91
  # Label -> ISO 639-1 code, built from the full (Anthropic) superset so it
92
  # resolves correctly regardless of which list is currently offered.
93
  _TRANSLATION_LABEL_TO_CODE = {
94
  f"{name} Translation": code for code, name in ANTHROPIC_TARGET_LANGUAGES.items()
95
  }
96
 
97
- # Tabs are pre-built for every language in the full superset (hidden by
98
- # default) so that toggling the API key never needs to add/remove
99
- # components -- only which ones are visible changes.
100
- _TRANSLATION_TAB_ORDER = list(_TRANSLATION_LABEL_TO_CODE.keys())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
 
103
  def _format_duration(seconds: float) -> str:
@@ -113,20 +142,6 @@ def _write_text_file(text: str, tmp_dir: Path, filename: str) -> str:
113
  return str(path)
114
 
115
 
116
- def _update_output_choices(api_key: str, current_value: list[str]):
117
- """Swap the Outputs checkbox list when the API key field changes.
118
-
119
- With a key: offer the full Anthropic language list. Without: restrict
120
- to what the offline MarianMT backend can reliably translate. Any
121
- currently-selected language that's no longer offered is dropped from
122
- the selection (this only happens when a key is removed).
123
- """
124
- has_key = bool((api_key or "").strip())
125
- choices = _ANTHROPIC_OUTPUT_CHOICES if has_key else _MARIAN_OUTPUT_CHOICES
126
- filtered_value = [v for v in (current_value or []) if v in choices] or ["Transcript"]
127
- return gr.update(choices=choices, value=filtered_value)
128
-
129
-
130
  # ---------------------------------------------------------------------------
131
  # Main processing callback
132
  # ---------------------------------------------------------------------------
@@ -138,6 +153,9 @@ def process_audio(
138
  source_language_label: str,
139
  selected_outputs: list[str],
140
  api_key: str,
 
 
 
141
  ):
142
  if not audio_path:
143
  raise gr.Error("Please upload an audio file first.")
@@ -148,24 +166,33 @@ def process_audio(
148
  except AudioError as exc:
149
  raise gr.Error(str(exc)) from exc
150
 
151
- working_path = audio_path
152
- if start is not None or end is not None:
153
- try:
154
- working_path = extract_window(audio_path, start, end)
155
- except AudioError as exc:
156
- raise gr.Error(str(exc)) from exc
157
-
158
- language_code = _NAME_TO_CODE.get(source_language_label) # None = auto-detect
159
-
160
- # Step 1: Audio -> canonical Transcript. This is the only step that
161
- # touches the audio; everything below works off `transcript`.
162
- transcript: Transcript = get_transcription_service().transcribe(
163
- working_path,
164
- source_filename=Path(audio_path).name,
165
- language=language_code,
166
- window_start=start,
167
- window_end=end,
168
- )
 
 
 
 
 
 
 
 
 
169
 
170
  tmp_dir = Path(tempfile.mkdtemp(prefix="echoscript_"))
171
 
@@ -178,55 +205,98 @@ def process_audio(
178
  f"**Words:** {transcript.word_count:,}"
179
  )
180
 
181
- # --- Transcript tab (always computed -- it's the source of truth --
182
- # but only exposed as a tab if the user kept "Transcript" checked) ---
 
 
 
 
 
 
 
 
 
183
  transcript_text = transcript.text
184
  transcript_file = _write_text_file(transcript_text, tmp_dir, "transcript.txt")
185
- transcript_tab_visible = "Transcript" in selected_outputs
186
 
187
- # --- Translation tabs ---
 
188
  translation_service = get_translation_service()
189
- translation_updates = {} # label -> (text, file_path)
190
- for label in _TRANSLATION_TAB_ORDER:
191
- if label not in selected_outputs:
192
- translation_updates[label] = (None, None)
193
- continue
194
 
 
195
  code = _TRANSLATION_LABEL_TO_CODE[label]
196
- try:
197
- translation = translation_service.translate(transcript, code, api_key=api_key)
198
- text = translation.text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
200
- except TranslationError as exc:
201
- # Surface the failure in that tab rather than aborting every
202
- # other output that already succeeded.
203
- text = f"\u26a0\ufe0f Translation failed: {exc}"
204
- file_path = None
205
- translation_updates[label] = (text, file_path)
206
 
207
  # --- Subtitles (always derived from the transcript, the canonical
208
  # source of truth -- never regenerated from audio) ---
209
  srt_path = _write_text_file(generate_srt(transcript.segments), tmp_dir, "transcript.srt")
210
  vtt_path = _write_text_file(generate_vtt(transcript.segments), tmp_dir, "transcript.vtt")
211
 
212
- outputs = [dashboard_md, transcript_text, transcript_file, gr.update(visible=transcript_tab_visible)]
 
 
 
 
 
 
213
 
214
- for label in _TRANSLATION_TAB_ORDER:
215
- text, file_path = translation_updates[label]
216
- visible = text is not None
217
- outputs += [gr.update(visible=visible), text or "", file_path]
218
 
219
- outputs += [srt_path, vtt_path]
220
 
221
  return outputs
222
 
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  # ---------------------------------------------------------------------------
225
  # UI layout
226
  # ---------------------------------------------------------------------------
227
 
228
  with gr.Blocks(title="EchoScript") as demo:
229
 
 
 
 
 
230
  gr.Markdown(
231
  """
232
  # EchoScript
@@ -270,18 +340,24 @@ with gr.Blocks(title="EchoScript") as demo:
270
  )
271
 
272
  outputs_input = gr.CheckboxGroup(
273
- choices=_MARIAN_OUTPUT_CHOICES,
274
  value=DEFAULT_OUTPUTS,
275
  label="Outputs",
276
  )
277
 
278
  api_key_input.change(
279
- fn=_update_output_choices,
280
- inputs=[api_key_input, outputs_input],
 
 
 
 
 
281
  outputs=[outputs_input],
282
  )
283
 
284
  process_button = gr.Button("Generate Outputs", variant="primary")
 
285
 
286
  with gr.Column(scale=2):
287
  gr.Markdown("### Results Dashboard")
@@ -297,22 +373,28 @@ with gr.Blocks(title="EchoScript") as demo:
297
  )
298
  transcript_download = gr.DownloadButton("Download TXT")
299
 
300
- translation_tabs = {}
301
- translation_boxes = {}
302
- translation_downloads = {}
303
- for label in _TRANSLATION_TAB_ORDER:
304
- short_name = label.replace(" Translation", "")
305
- with gr.Tab(short_name, visible=False) as tab:
306
- box = gr.Textbox(
307
- label=short_name,
308
- lines=16,
309
- interactive=True,
310
- buttons=["copy"],
311
- )
312
- download = gr.DownloadButton("Download TXT")
313
- translation_tabs[label] = tab
314
- translation_boxes[label] = box
315
- translation_downloads[label] = download
 
 
 
 
 
 
316
 
317
  with gr.Tab("Subtitles"):
318
  gr.Markdown(
@@ -325,16 +407,40 @@ with gr.Blocks(title="EchoScript") as demo:
325
  vtt_download = gr.DownloadButton("Download VTT")
326
 
327
  # Build the flat outputs list in the exact order process_audio() returns.
328
- click_outputs = [dashboard_output, transcript_box, transcript_download, transcript_tab]
329
- for label in _TRANSLATION_TAB_ORDER:
330
- click_outputs += [translation_tabs[label], translation_boxes[label], translation_downloads[label]]
331
- click_outputs += [srt_download, vtt_download]
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  process_button.click(
334
  fn=process_audio,
335
- inputs=[audio_input, start_input, end_input, language_input, outputs_input, api_key_input],
 
 
 
 
 
 
 
 
 
 
336
  outputs=click_outputs,
337
  )
338
 
 
 
339
  if __name__ == "__main__":
340
  demo.launch()
 
9
  so the app can start up without needing model weights on disk yet, and so
10
  this module stays import-safe in environments without network access.
11
 
12
+ Two things make repeated clicks cheap:
13
+
14
+ 1. Transcript caching. A `gr.State` holds the last Transcript together with
15
+ the exact (audio path, time window, source language) signature that
16
+ produced it. If "Generate Outputs" is clicked again with that signature
17
+ unchanged -- e.g. only the Outputs checkboxes changed -- transcription
18
+ is skipped entirely and the cached Transcript is reused.
19
+ 2. Per-language translation caching. A second `gr.State` dict caches each
20
+ Translation by language code, scoped to the current transcript
21
+ signature. Selecting an additional language only translates that new
22
+ language; languages already translated (even if briefly deselected and
23
+ reselected) are reused rather than recomputed.
24
+
25
+ Available translation languages are cascaded from two things:
26
+
27
+ - Whether an Anthropic API key is present for this session (see
28
+ services/translation.py for the offline-vs-Claude tradeoff). The key is
29
+ used only for the request(s) made during this session and is never
30
+ written to disk, logged, or cached anywhere server-side.
31
+ - The source language, explicit or detected: translating a language into
32
+ itself isn't offered. If "Auto Detect" is used, the list is re-filtered
33
+ once the actual language is known, after transcription.
34
  """
35
 
36
  from __future__ import annotations
 
58
  # Safe to share across requests/users: TranscriptionService holds no
59
  # per-request state, and TranslationService resolves its backend (and
60
  # takes the API key, if any) fresh on every translate() call rather than
61
+ # storing it -- see services/translation.py. Per-request caching of
62
+ # results (not the services themselves) lives in gr.State, below.
63
  # ---------------------------------------------------------------------------
64
 
65
  _transcription_service: Optional[TranscriptionService] = None
 
93
  _NAME_TO_CODE = {name: code for code, name in SUPPORTED_LANGUAGES.items()}
94
  SOURCE_LANGUAGE_CHOICES = ["Auto Detect"] + list(SUPPORTED_LANGUAGES.values())
95
 
 
 
 
 
 
 
 
96
  # Label -> ISO 639-1 code, built from the full (Anthropic) superset so it
97
  # resolves correctly regardless of which list is currently offered.
98
  _TRANSLATION_LABEL_TO_CODE = {
99
  f"{name} Translation": code for code, name in ANTHROPIC_TARGET_LANGUAGES.items()
100
  }
101
 
102
+ # Sections are pre-built for every language in the full superset (hidden by
103
+ # default) so that toggling the API key or source language never needs to
104
+ # add/remove components -- only which ones are visible changes.
105
+ _TRANSLATION_SECTION_ORDER = list(_TRANSLATION_LABEL_TO_CODE.keys())
106
+
107
+ DEFAULT_OUTPUTS = ["Transcript", "English Translation"]
108
+
109
+
110
+ def _compute_output_choices(has_key: bool, exclude_code: Optional[str]) -> list[str]:
111
+ """The Outputs checkbox list, cascaded from key presence + source language.
112
+
113
+ `exclude_code` removes "translate into the language it's already in"
114
+ from the list -- it's the explicit source language if one was chosen,
115
+ or the detected language once transcription has run.
116
+ """
117
+ pool = ANTHROPIC_TARGET_LANGUAGES if has_key else MARIAN_TARGET_LANGUAGES
118
+ return ["Transcript"] + [
119
+ f"{name} Translation" for code, name in pool.items() if code != exclude_code
120
+ ]
121
+
122
+
123
+ def _on_key_or_source_change(api_key: str, source_language_label: str, current_value: list[str]):
124
+ """Re-cascade the Outputs choices when the API key or source language changes."""
125
+ has_key = bool((api_key or "").strip())
126
+ exclude_code = _NAME_TO_CODE.get(source_language_label) # None when "Auto Detect"
127
+ choices = _compute_output_choices(has_key, exclude_code)
128
+ filtered_value = [v for v in (current_value or []) if v in choices] or ["Transcript"]
129
+ return gr.update(choices=choices, value=filtered_value)
130
 
131
 
132
  def _format_duration(seconds: float) -> str:
 
142
  return str(path)
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  # ---------------------------------------------------------------------------
146
  # Main processing callback
147
  # ---------------------------------------------------------------------------
 
153
  source_language_label: str,
154
  selected_outputs: list[str],
155
  api_key: str,
156
+ cached_transcript: Optional[Transcript],
157
+ cached_signature,
158
+ cached_translations: dict,
159
  ):
160
  if not audio_path:
161
  raise gr.Error("Please upload an audio file first.")
 
166
  except AudioError as exc:
167
  raise gr.Error(str(exc)) from exc
168
 
169
+ source_code = _NAME_TO_CODE.get(source_language_label) # None = auto-detect
170
+ signature = (audio_path, start, end, source_code)
171
+
172
+ if cached_transcript is not None and cached_signature == signature:
173
+ # Same file, same window, same forced source language as last time
174
+ # -- the canonical Transcript hasn't changed, so skip Whisper
175
+ # entirely and reuse it.
176
+ transcript = cached_transcript
177
+ else:
178
+ working_path = audio_path
179
+ if start is not None or end is not None:
180
+ try:
181
+ working_path = extract_window(audio_path, start, end)
182
+ except AudioError as exc:
183
+ raise gr.Error(str(exc)) from exc
184
+
185
+ # Step 1: Audio -> canonical Transcript. This is the only step
186
+ # that touches the audio; everything below works off `transcript`.
187
+ transcript = get_transcription_service().transcribe(
188
+ working_path,
189
+ source_filename=Path(audio_path).name,
190
+ language=source_code,
191
+ window_start=start,
192
+ window_end=end,
193
+ )
194
+ cached_signature = signature
195
+ cached_translations = {} # old translations were derived from a different transcript
196
 
197
  tmp_dir = Path(tempfile.mkdtemp(prefix="echoscript_"))
198
 
 
205
  f"**Words:** {transcript.word_count:,}"
206
  )
207
 
208
+ # Now that the actual language is known (important when "Auto Detect"
209
+ # was used), re-cascade the Outputs choices to drop "translate into
210
+ # the language it's already in" and drop that selection if it was
211
+ # only chosen by default/before detection.
212
+ has_key = bool((api_key or "").strip())
213
+ updated_choices = _compute_output_choices(has_key, transcript.language)
214
+ effective_outputs = [v for v in selected_outputs if v in updated_choices] or ["Transcript"]
215
+ outputs_update = gr.update(choices=updated_choices, value=effective_outputs)
216
+
217
+ # --- Transcript section (always computed -- it's the source of truth
218
+ # -- but only exposed if the user kept "Transcript" checked) ---
219
  transcript_text = transcript.text
220
  transcript_file = _write_text_file(transcript_text, tmp_dir, "transcript.txt")
221
+ transcript_visible = "Transcript" in effective_outputs
222
 
223
+ # --- Translations: reuse anything already cached for this transcript;
224
+ # only compute the languages newly selected since the last click. ---
225
  translation_service = get_translation_service()
226
+ section_updates = {} # label -> (visible, text, file_path)
 
 
 
 
227
 
228
+ for label in _TRANSLATION_SECTION_ORDER:
229
  code = _TRANSLATION_LABEL_TO_CODE[label]
230
+ if label not in effective_outputs:
231
+ section_updates[label] = (False, "", None)
232
+ continue
233
+
234
+ if code in cached_translations:
235
+ text = cached_translations[code]
236
+ else:
237
+ try:
238
+ translation = translation_service.translate(transcript, code, api_key=api_key)
239
+ text = translation.text
240
+ cached_translations[code] = text
241
+ except TranslationError as exc:
242
+ # Surface the failure in that section rather than aborting
243
+ # every other output that already succeeded. Deliberately
244
+ # not cached, so it's retried on the next click.
245
+ text = f"\u26a0\ufe0f Translation failed: {exc}"
246
+
247
+ file_path = None
248
+ if not text.startswith("\u26a0\ufe0f"):
249
  file_path = _write_text_file(text, tmp_dir, f"{code}.txt")
250
+ section_updates[label] = (True, text, file_path)
 
 
 
 
 
251
 
252
  # --- Subtitles (always derived from the transcript, the canonical
253
  # source of truth -- never regenerated from audio) ---
254
  srt_path = _write_text_file(generate_srt(transcript.segments), tmp_dir, "transcript.srt")
255
  vtt_path = _write_text_file(generate_vtt(transcript.segments), tmp_dir, "transcript.vtt")
256
 
257
+ outputs = [
258
+ dashboard_md,
259
+ transcript_text,
260
+ transcript_file,
261
+ gr.update(visible=transcript_visible),
262
+ outputs_update,
263
+ ]
264
 
265
+ for label in _TRANSLATION_SECTION_ORDER:
266
+ visible, text, file_path = section_updates[label]
267
+ outputs += [gr.update(visible=visible), text, file_path]
 
268
 
269
+ outputs += [srt_path, vtt_path, transcript, cached_signature, cached_translations]
270
 
271
  return outputs
272
 
273
 
274
+ def reset_session_state():
275
+ """Clear cached transcript/translations and the visible results."""
276
+ cleared = [None, None, {}]
277
+ ui_reset = [
278
+ "Upload an audio file and click **Generate Outputs** to begin.",
279
+ "",
280
+ None,
281
+ gr.update(visible=True),
282
+ gr.update(choices=_compute_output_choices(has_key=False, exclude_code=None), value=DEFAULT_OUTPUTS),
283
+ ]
284
+ for _ in _TRANSLATION_SECTION_ORDER:
285
+ ui_reset += [gr.update(visible=False), "", None]
286
+ ui_reset += [None, None]
287
+ return ui_reset + cleared
288
+
289
+
290
  # ---------------------------------------------------------------------------
291
  # UI layout
292
  # ---------------------------------------------------------------------------
293
 
294
  with gr.Blocks(title="EchoScript") as demo:
295
 
296
+ transcript_state = gr.State(value=None)
297
+ signature_state = gr.State(value=None)
298
+ translations_state = gr.State(value={})
299
+
300
  gr.Markdown(
301
  """
302
  # EchoScript
 
340
  )
341
 
342
  outputs_input = gr.CheckboxGroup(
343
+ choices=_compute_output_choices(has_key=False, exclude_code=None),
344
  value=DEFAULT_OUTPUTS,
345
  label="Outputs",
346
  )
347
 
348
  api_key_input.change(
349
+ fn=_on_key_or_source_change,
350
+ inputs=[api_key_input, language_input, outputs_input],
351
+ outputs=[outputs_input],
352
+ )
353
+ language_input.change(
354
+ fn=_on_key_or_source_change,
355
+ inputs=[api_key_input, language_input, outputs_input],
356
  outputs=[outputs_input],
357
  )
358
 
359
  process_button = gr.Button("Generate Outputs", variant="primary")
360
+ reset_button = gr.Button("Reset (clear cache)", size="sm")
361
 
362
  with gr.Column(scale=2):
363
  gr.Markdown("### Results Dashboard")
 
373
  )
374
  transcript_download = gr.DownloadButton("Download TXT")
375
 
376
+ with gr.Tab("Translations"):
377
+ gr.Markdown(
378
+ "Every language selected in **Outputs** appears below at "
379
+ "once -- nothing is hidden behind a tab you have to click "
380
+ "through."
381
+ )
382
+ translation_groups = {}
383
+ translation_boxes = {}
384
+ translation_downloads = {}
385
+ for label in _TRANSLATION_SECTION_ORDER:
386
+ short_name = label.replace(" Translation", "")
387
+ with gr.Group(visible=False) as group:
388
+ box = gr.Textbox(
389
+ label=short_name,
390
+ lines=10,
391
+ interactive=True,
392
+ buttons=["copy"],
393
+ )
394
+ download = gr.DownloadButton("Download TXT")
395
+ translation_groups[label] = group
396
+ translation_boxes[label] = box
397
+ translation_downloads[label] = download
398
 
399
  with gr.Tab("Subtitles"):
400
  gr.Markdown(
 
407
  vtt_download = gr.DownloadButton("Download VTT")
408
 
409
  # Build the flat outputs list in the exact order process_audio() returns.
410
+ click_outputs = [
411
+ dashboard_output,
412
+ transcript_box,
413
+ transcript_download,
414
+ transcript_tab,
415
+ outputs_input,
416
+ ]
417
+ for label in _TRANSLATION_SECTION_ORDER:
418
+ click_outputs += [translation_groups[label], translation_boxes[label], translation_downloads[label]]
419
+ click_outputs += [
420
+ srt_download,
421
+ vtt_download,
422
+ transcript_state,
423
+ signature_state,
424
+ translations_state,
425
+ ]
426
 
427
  process_button.click(
428
  fn=process_audio,
429
+ inputs=[
430
+ audio_input,
431
+ start_input,
432
+ end_input,
433
+ language_input,
434
+ outputs_input,
435
+ api_key_input,
436
+ transcript_state,
437
+ signature_state,
438
+ translations_state,
439
+ ],
440
  outputs=click_outputs,
441
  )
442
 
443
+ reset_button.click(fn=reset_session_state, outputs=click_outputs)
444
+
445
  if __name__ == "__main__":
446
  demo.launch()