Spaces:
Sleeping
Sleeping
usertea commited on
Commit ·
9b67bb3
1
Parent(s): d940cee
EchoScript : two fully independent pipelines, chosen explicitly, never both from one click : Transcript & Translations vs. Phonetic Transcription (IPA)
Browse files- app.py +167 -42
- models/transcript.py +10 -0
- requirements.txt +1 -0
- services/phonetics.py +112 -0
app.py
CHANGED
|
@@ -1,25 +1,43 @@
|
|
| 1 |
"""EchoScript v1.0 UI.
|
| 2 |
|
| 3 |
-
Two
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
1. "Generate Transcript" -- audio, time window, and an optional source-
|
| 13 |
language hint go in; a canonical Transcript comes out (detected
|
| 14 |
language, confidence, duration, word count, full text). This is the
|
| 15 |
-
only step that touches the audio. Once it's done,
|
| 16 |
-
language picker appears, scoped to the language that
|
| 17 |
-
detected (or forced) -- never a pre-detection guess.
|
| 18 |
2. "Generate Translations" -- pick which languages to translate the
|
| 19 |
transcript into (the list depends on whether an Anthropic API key is
|
| 20 |
present: more languages with a key, the offline-safe set without one)
|
| 21 |
-
and click again. Always derived from the cached Transcript, never
|
| 22 |
-
the audio.
|
| 23 |
|
| 24 |
Caching: a `gr.State` holds the last Transcript plus the exact (audio,
|
| 25 |
window, source-language) signature that produced it -- clicking "Generate
|
|
@@ -28,6 +46,8 @@ re-running Whisper. A second `gr.State` dict caches each Translation by
|
|
| 28 |
language code, scoped to the current transcript, so adding one more
|
| 29 |
language to "Generate Translations" doesn't redo the others, and
|
| 30 |
deselecting a language doesn't drop it from the cache (just hides it).
|
|
|
|
|
|
|
| 31 |
|
| 32 |
Services are instantiated lazily (on first use) rather than at import
|
| 33 |
time, so the app can start up without needing model weights on disk yet.
|
|
@@ -43,6 +63,7 @@ import gradio as gr
|
|
| 43 |
|
| 44 |
from models.transcript import Transcript
|
| 45 |
from services.audio import AudioError, extract_window, resolve_window, validate_extension
|
|
|
|
| 46 |
from services.subtitles import generate_srt, generate_vtt
|
| 47 |
from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
|
| 48 |
from services.translation import (
|
|
@@ -151,6 +172,7 @@ def generate_transcript(
|
|
| 151 |
cached_signature,
|
| 152 |
cached_translations: dict,
|
| 153 |
):
|
|
|
|
| 154 |
if not audio_path:
|
| 155 |
raise gr.Error("Please upload an audio file first.")
|
| 156 |
|
|
@@ -172,8 +194,7 @@ def generate_transcript(
|
|
| 172 |
except AudioError as exc:
|
| 173 |
raise gr.Error(str(exc)) from exc
|
| 174 |
|
| 175 |
-
# The only step that touches the audio
|
| 176 |
-
# when no source language is forced.
|
| 177 |
transcript = get_transcription_service().transcribe(
|
| 178 |
working_path,
|
| 179 |
source_filename=Path(audio_path).name,
|
|
@@ -206,7 +227,7 @@ def generate_transcript(
|
|
| 206 |
# never against a pre-detection guess.
|
| 207 |
has_key = bool((api_key or "").strip())
|
| 208 |
translation_choices = _compute_translation_choices(has_key, transcript.language)
|
| 209 |
-
default_targets = ["English
|
| 210 |
translate_choices_update = gr.update(choices=translation_choices, value=default_targets)
|
| 211 |
|
| 212 |
srt_path = _write_text_file(generate_srt(transcript.segments), tmp_dir, "transcript.srt")
|
|
@@ -236,6 +257,59 @@ def generate_transcript(
|
|
| 236 |
return outputs
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
# ---------------------------------------------------------------------------
|
| 240 |
# Stage 2: Generate Translations
|
| 241 |
# ---------------------------------------------------------------------------
|
|
@@ -324,8 +398,8 @@ def generate_translations(
|
|
| 324 |
yield _make_outputs(section_states)
|
| 325 |
|
| 326 |
|
| 327 |
-
def
|
| 328 |
-
"""Clear cached transcript/translations and everything on screen."""
|
| 329 |
ui_reset = [
|
| 330 |
"Upload an audio file and click **Generate Transcript** to begin.",
|
| 331 |
"",
|
|
@@ -340,6 +414,11 @@ def reset_session_state():
|
|
| 340 |
return ui_reset
|
| 341 |
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
# ---------------------------------------------------------------------------
|
| 344 |
# UI layout
|
| 345 |
# ---------------------------------------------------------------------------
|
|
@@ -349,15 +428,17 @@ with gr.Blocks(title="EchoScript") as demo:
|
|
| 349 |
transcript_state = gr.State(value=None)
|
| 350 |
signature_state = gr.State(value=None)
|
| 351 |
translations_state = gr.State(value={})
|
|
|
|
|
|
|
| 352 |
|
| 353 |
gr.Markdown(
|
| 354 |
"""
|
| 355 |
# EchoScript
|
| 356 |
|
| 357 |
-
**Upload Audio →
|
| 358 |
-
|
| 359 |
|
| 360 |
-
<sub>build: 2026-07-
|
| 361 |
"""
|
| 362 |
)
|
| 363 |
|
|
@@ -378,27 +459,45 @@ with gr.Blocks(title="EchoScript") as demo:
|
|
| 378 |
end_input = gr.Textbox(label="End Time (optional)", placeholder="HH:MM:SS")
|
| 379 |
gr.Markdown("Leave blank: entire file")
|
| 380 |
|
| 381 |
-
gr.Markdown("###
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
| 387 |
)
|
| 388 |
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
with gr.Column(scale=2):
|
| 404 |
gr.Markdown("### Results Dashboard")
|
|
@@ -414,6 +513,22 @@ with gr.Blocks(title="EchoScript") as demo:
|
|
| 414 |
)
|
| 415 |
transcript_download = gr.DownloadButton("Download TXT")
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
with gr.Tab("Translations"):
|
| 418 |
translations_placeholder = gr.Markdown(
|
| 419 |
"Generate a transcript first to see the languages available to "
|
|
@@ -454,7 +569,7 @@ with gr.Blocks(title="EchoScript") as demo:
|
|
| 454 |
srt_download = gr.DownloadButton("Download SRT")
|
| 455 |
vtt_download = gr.DownloadButton("Download VTT")
|
| 456 |
|
| 457 |
-
# Outputs
|
| 458 |
transcript_stage_outputs = [
|
| 459 |
dashboard_output,
|
| 460 |
transcript_box,
|
|
@@ -491,6 +606,18 @@ with gr.Blocks(title="EchoScript") as demo:
|
|
| 491 |
],
|
| 492 |
outputs=transcript_stage_outputs,
|
| 493 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
|
| 495 |
# Outputs for Stage 2 (Generate Translations): just the per-language
|
| 496 |
# sections plus the translation cache.
|
|
@@ -532,7 +659,5 @@ with gr.Blocks(title="EchoScript") as demo:
|
|
| 532 |
outputs=[translate_choices_input],
|
| 533 |
)
|
| 534 |
|
| 535 |
-
reset_button.click(fn=reset_session_state, outputs=transcript_stage_outputs)
|
| 536 |
-
|
| 537 |
if __name__ == "__main__":
|
| 538 |
demo.launch()
|
|
|
|
| 1 |
"""EchoScript v1.0 UI.
|
| 2 |
|
| 3 |
+
Two independent, mutually exclusive processing pipelines, chosen per
|
| 4 |
+
click -- not two steps of one pipeline:
|
| 5 |
+
|
| 6 |
+
Transcript & Translations pipeline:
|
| 7 |
+
Upload Audio -> Select Audio Window (optional)
|
| 8 |
+
-> Detect Language & Generate Canonical Transcript
|
| 9 |
+
-> Preview Transcript + available translation languages
|
| 10 |
+
-> Generate Translations -> Copy / Download
|
| 11 |
+
|
| 12 |
+
Phonetic Transcription (IPA) pipeline:
|
| 13 |
+
Upload Audio -> Select Audio Window (optional)
|
| 14 |
+
-> Generate Phonetic Transcription -> Copy / Download
|
| 15 |
+
|
| 16 |
+
These never run together from the same click. That's deliberate: the
|
| 17 |
+
Transcript pipeline treats the Transcript as the single source of truth
|
| 18 |
+
for every translation and subtitle it produces, and the Phonetics
|
| 19 |
+
pipeline reads the audio directly with no dependency on -- or influence
|
| 20 |
+
from -- the Transcript at all (see services/phonetics.py for why that
|
| 21 |
+
independence matters). Mixing them into one combined action would mean
|
| 22 |
+
either running Whisper when someone only wanted IPA phones, or running
|
| 23 |
+
the phone recognizer when someone only wanted a transcript -- both
|
| 24 |
+
wasted work, and it would blur which of the two "touches the audio"
|
| 25 |
+
for a given result.
|
| 26 |
+
|
| 27 |
+
Within the Transcript pipeline, "Generate Transcript" and "Generate
|
| 28 |
+
Translations" remain two distinct actions:
|
| 29 |
|
| 30 |
1. "Generate Transcript" -- audio, time window, and an optional source-
|
| 31 |
language hint go in; a canonical Transcript comes out (detected
|
| 32 |
language, confidence, duration, word count, full text). This is the
|
| 33 |
+
only step that touches the audio in this pipeline. Once it's done,
|
| 34 |
+
the translation-language picker appears, scoped to the language that
|
| 35 |
+
was *actually* detected (or forced) -- never a pre-detection guess.
|
| 36 |
2. "Generate Translations" -- pick which languages to translate the
|
| 37 |
transcript into (the list depends on whether an Anthropic API key is
|
| 38 |
present: more languages with a key, the offline-safe set without one)
|
| 39 |
+
and click again. Always derived from the cached Transcript, never
|
| 40 |
+
from the audio.
|
| 41 |
|
| 42 |
Caching: a `gr.State` holds the last Transcript plus the exact (audio,
|
| 43 |
window, source-language) signature that produced it -- clicking "Generate
|
|
|
|
| 46 |
language code, scoped to the current transcript, so adding one more
|
| 47 |
language to "Generate Translations" doesn't redo the others, and
|
| 48 |
deselecting a language doesn't drop it from the cache (just hides it).
|
| 49 |
+
The Phonetics pipeline has its own, separate cache/signature pair, keyed
|
| 50 |
+
only on (audio, window) -- it has no source-language concept at all.
|
| 51 |
|
| 52 |
Services are instantiated lazily (on first use) rather than at import
|
| 53 |
time, so the app can start up without needing model weights on disk yet.
|
|
|
|
| 63 |
|
| 64 |
from models.transcript import Transcript
|
| 65 |
from services.audio import AudioError, extract_window, resolve_window, validate_extension
|
| 66 |
+
from services.phonetics import PhoneticsError, transcribe_phonetics
|
| 67 |
from services.subtitles import generate_srt, generate_vtt
|
| 68 |
from services.transcription import SUPPORTED_LANGUAGES, TranscriptionService
|
| 69 |
from services.translation import (
|
|
|
|
| 172 |
cached_signature,
|
| 173 |
cached_translations: dict,
|
| 174 |
):
|
| 175 |
+
"""Transcript & Translations mode. Never touches the phonetics engine."""
|
| 176 |
if not audio_path:
|
| 177 |
raise gr.Error("Please upload an audio file first.")
|
| 178 |
|
|
|
|
| 194 |
except AudioError as exc:
|
| 195 |
raise gr.Error(str(exc)) from exc
|
| 196 |
|
| 197 |
+
# The only step that touches the audio in this mode.
|
|
|
|
| 198 |
transcript = get_transcription_service().transcribe(
|
| 199 |
working_path,
|
| 200 |
source_filename=Path(audio_path).name,
|
|
|
|
| 227 |
# never against a pre-detection guess.
|
| 228 |
has_key = bool((api_key or "").strip())
|
| 229 |
translation_choices = _compute_translation_choices(has_key, transcript.language)
|
| 230 |
+
default_targets = ["English"] if "English" in translation_choices else []
|
| 231 |
translate_choices_update = gr.update(choices=translation_choices, value=default_targets)
|
| 232 |
|
| 233 |
srt_path = _write_text_file(generate_srt(transcript.segments), tmp_dir, "transcript.srt")
|
|
|
|
| 257 |
return outputs
|
| 258 |
|
| 259 |
|
| 260 |
+
# ---------------------------------------------------------------------------
|
| 261 |
+
# Alternate Stage 1: Generate Phonetic Transcription (IPA)
|
| 262 |
+
#
|
| 263 |
+
# Mutually exclusive with "Generate Transcript" -- this mode never touches
|
| 264 |
+
# Whisper, never produces a Transcript, and therefore never feeds
|
| 265 |
+
# translations or subtitles. It exists precisely so the Transcript can
|
| 266 |
+
# stay the single source of truth for everything downstream of it: if you
|
| 267 |
+
# want IPA phones, you get *only* IPA phones from this click, not a
|
| 268 |
+
# transcript-plus-phonetics bundle. See services/phonetics.py for why this
|
| 269 |
+
# needs to read the audio directly rather than derive from a transcript.
|
| 270 |
+
# ---------------------------------------------------------------------------
|
| 271 |
+
|
| 272 |
+
def generate_phonetics(
|
| 273 |
+
audio_path: Optional[str],
|
| 274 |
+
start_value: str,
|
| 275 |
+
end_value: str,
|
| 276 |
+
cached_phonetics: Optional[str],
|
| 277 |
+
cached_phonetics_signature,
|
| 278 |
+
):
|
| 279 |
+
if not audio_path:
|
| 280 |
+
raise gr.Error("Please upload an audio file first.")
|
| 281 |
+
|
| 282 |
+
try:
|
| 283 |
+
validate_extension(audio_path)
|
| 284 |
+
start, end = resolve_window(start_value, end_value)
|
| 285 |
+
except AudioError as exc:
|
| 286 |
+
raise gr.Error(str(exc)) from exc
|
| 287 |
+
|
| 288 |
+
signature = (audio_path, start, end)
|
| 289 |
+
if cached_phonetics is not None and cached_phonetics_signature == signature:
|
| 290 |
+
# Same audio and window as last time -- reuse rather than
|
| 291 |
+
# re-running the phone recognizer.
|
| 292 |
+
phonetics_text = cached_phonetics
|
| 293 |
+
else:
|
| 294 |
+
working_path = audio_path
|
| 295 |
+
if start is not None or end is not None:
|
| 296 |
+
try:
|
| 297 |
+
working_path = extract_window(audio_path, start, end)
|
| 298 |
+
except AudioError as exc:
|
| 299 |
+
raise gr.Error(str(exc)) from exc
|
| 300 |
+
|
| 301 |
+
try:
|
| 302 |
+
phonetics_text = transcribe_phonetics(working_path)
|
| 303 |
+
except PhoneticsError as exc:
|
| 304 |
+
phonetics_text = f"\u26a0\ufe0f Phonetic transcription failed: {exc}"
|
| 305 |
+
cached_phonetics_signature = signature
|
| 306 |
+
|
| 307 |
+
tmp_dir = Path(tempfile.mkdtemp(prefix="echoscript_"))
|
| 308 |
+
phonetics_file = _write_text_file(phonetics_text, tmp_dir, "phonetics.txt")
|
| 309 |
+
|
| 310 |
+
return [phonetics_text, phonetics_file, phonetics_text, cached_phonetics_signature]
|
| 311 |
+
|
| 312 |
+
|
| 313 |
# ---------------------------------------------------------------------------
|
| 314 |
# Stage 2: Generate Translations
|
| 315 |
# ---------------------------------------------------------------------------
|
|
|
|
| 398 |
yield _make_outputs(section_states)
|
| 399 |
|
| 400 |
|
| 401 |
+
def reset_transcript_state():
|
| 402 |
+
"""Clear cached transcript/translations and everything on screen for that mode."""
|
| 403 |
ui_reset = [
|
| 404 |
"Upload an audio file and click **Generate Transcript** to begin.",
|
| 405 |
"",
|
|
|
|
| 414 |
return ui_reset
|
| 415 |
|
| 416 |
|
| 417 |
+
def reset_phonetics_state():
|
| 418 |
+
"""Clear cached phonetics and everything on screen for that mode."""
|
| 419 |
+
return ["", None, None, None]
|
| 420 |
+
|
| 421 |
+
|
| 422 |
# ---------------------------------------------------------------------------
|
| 423 |
# UI layout
|
| 424 |
# ---------------------------------------------------------------------------
|
|
|
|
| 428 |
transcript_state = gr.State(value=None)
|
| 429 |
signature_state = gr.State(value=None)
|
| 430 |
translations_state = gr.State(value={})
|
| 431 |
+
phonetics_state = gr.State(value=None)
|
| 432 |
+
phonetics_signature_state = gr.State(value=None)
|
| 433 |
|
| 434 |
gr.Markdown(
|
| 435 |
"""
|
| 436 |
# EchoScript
|
| 437 |
|
| 438 |
+
**Upload Audio → Choose Transcript or Phonetics (independent pipelines) → Preview &
|
| 439 |
+
Choose Languages (Transcript mode only) → Generate Translations → Copy / Download**
|
| 440 |
|
| 441 |
+
<sub>build: 2026-07-06 22:10 UTC · Transcript and Phonetics are now independent, mutually exclusive pipelines</sub>
|
| 442 |
"""
|
| 443 |
)
|
| 444 |
|
|
|
|
| 459 |
end_input = gr.Textbox(label="End Time (optional)", placeholder="HH:MM:SS")
|
| 460 |
gr.Markdown("Leave blank: entire file")
|
| 461 |
|
| 462 |
+
gr.Markdown("### What do you want to generate?")
|
| 463 |
+
gr.Markdown(
|
| 464 |
+
"These are two independent pipelines -- choose one per click. "
|
| 465 |
+
"**Transcript** is the source of truth for translations, subtitles, "
|
| 466 |
+
"and editing. **Phonetics** reads the audio directly and has no "
|
| 467 |
+
"connection to the transcript at all -- it won't 'correct' toward "
|
| 468 |
+
"real words the way a transcript does, and picking it here never "
|
| 469 |
+
"runs (or requires) the transcript pipeline."
|
| 470 |
)
|
| 471 |
|
| 472 |
+
with gr.Tabs():
|
| 473 |
+
with gr.Tab("Transcript & Translations"):
|
| 474 |
+
language_input = gr.Dropdown(
|
| 475 |
+
choices=SOURCE_LANGUAGE_CHOICES,
|
| 476 |
+
value="Auto Detect",
|
| 477 |
+
label="Source Language",
|
| 478 |
+
info="A hint for transcription, not a guess at translation targets.",
|
| 479 |
+
)
|
| 480 |
+
api_key_input = gr.Textbox(
|
| 481 |
+
label="Anthropic API Key (optional)",
|
| 482 |
+
type="password",
|
| 483 |
+
placeholder="sk-ant-...",
|
| 484 |
+
info=(
|
| 485 |
+
"Provide your own key to translate into many more languages via "
|
| 486 |
+
"Claude. Without one, translation uses local offline models "
|
| 487 |
+
"(English, German, Persian, Spanish, and more). Used for this "
|
| 488 |
+
"session only -- never stored."
|
| 489 |
+
),
|
| 490 |
+
)
|
| 491 |
+
generate_transcript_button = gr.Button("Generate Transcript", variant="primary")
|
| 492 |
+
reset_transcript_button = gr.Button("Reset Transcript (clear cache)", size="sm")
|
| 493 |
|
| 494 |
+
with gr.Tab("Phonetic Transcription (IPA)"):
|
| 495 |
+
gr.Markdown(
|
| 496 |
+
"Produces the exact IPA sounds heard in the audio -- "
|
| 497 |
+
"independent of language, and independent of the transcript."
|
| 498 |
+
)
|
| 499 |
+
generate_phonetics_button = gr.Button("Generate Phonetic Transcription", variant="primary")
|
| 500 |
+
reset_phonetics_button = gr.Button("Reset Phonetics (clear cache)", size="sm")
|
| 501 |
|
| 502 |
with gr.Column(scale=2):
|
| 503 |
gr.Markdown("### Results Dashboard")
|
|
|
|
| 513 |
)
|
| 514 |
transcript_download = gr.DownloadButton("Download TXT")
|
| 515 |
|
| 516 |
+
with gr.Tab("Phonetics (IPA)"):
|
| 517 |
+
gr.Markdown(
|
| 518 |
+
"The exact sounds heard in the audio, written as IPA phones -- "
|
| 519 |
+
"independent of any language. This is **not** derived from the "
|
| 520 |
+
"transcript: it comes from a universal phone recognizer reading "
|
| 521 |
+
"the audio directly, so it won't correct itself toward real words "
|
| 522 |
+
"the way the transcript does."
|
| 523 |
+
)
|
| 524 |
+
phonetics_box = gr.Textbox(
|
| 525 |
+
label="Phonetic transcription (IPA)",
|
| 526 |
+
lines=10,
|
| 527 |
+
interactive=True,
|
| 528 |
+
buttons=["copy"],
|
| 529 |
+
)
|
| 530 |
+
phonetics_download = gr.DownloadButton("Download TXT")
|
| 531 |
+
|
| 532 |
with gr.Tab("Translations"):
|
| 533 |
translations_placeholder = gr.Markdown(
|
| 534 |
"Generate a transcript first to see the languages available to "
|
|
|
|
| 569 |
srt_download = gr.DownloadButton("Download SRT")
|
| 570 |
vtt_download = gr.DownloadButton("Download VTT")
|
| 571 |
|
| 572 |
+
# Outputs for the Transcript & Translations mode (and its Reset).
|
| 573 |
transcript_stage_outputs = [
|
| 574 |
dashboard_output,
|
| 575 |
transcript_box,
|
|
|
|
| 606 |
],
|
| 607 |
outputs=transcript_stage_outputs,
|
| 608 |
)
|
| 609 |
+
reset_transcript_button.click(fn=reset_transcript_state, outputs=transcript_stage_outputs)
|
| 610 |
+
|
| 611 |
+
# Outputs for the Phonetics mode (and its Reset) -- entirely separate
|
| 612 |
+
# from the transcript pipeline; sharing only the audio/window inputs.
|
| 613 |
+
phonetics_stage_outputs = [phonetics_box, phonetics_download, phonetics_state, phonetics_signature_state]
|
| 614 |
+
|
| 615 |
+
generate_phonetics_button.click(
|
| 616 |
+
fn=generate_phonetics,
|
| 617 |
+
inputs=[audio_input, start_input, end_input, phonetics_state, phonetics_signature_state],
|
| 618 |
+
outputs=phonetics_stage_outputs,
|
| 619 |
+
)
|
| 620 |
+
reset_phonetics_button.click(fn=reset_phonetics_state, outputs=phonetics_stage_outputs)
|
| 621 |
|
| 622 |
# Outputs for Stage 2 (Generate Translations): just the per-language
|
| 623 |
# sections plus the translation cache.
|
|
|
|
| 659 |
outputs=[translate_choices_input],
|
| 660 |
)
|
| 661 |
|
|
|
|
|
|
|
| 662 |
if __name__ == "__main__":
|
| 663 |
demo.launch()
|
models/transcript.py
CHANGED
|
@@ -8,6 +8,16 @@ 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
|
|
|
|
| 8 |
|
| 9 |
Audio -> Transcript -> Outputs (allowed)
|
| 10 |
Audio -> Translation (never)
|
| 11 |
+
|
| 12 |
+
Phonetic transcription (services/phonetics.py) is a separate, mutually
|
| 13 |
+
exclusive pipeline, not a step alongside this one: the person chooses
|
| 14 |
+
either "Generate Transcript" (this model, feeding translations/subtitles)
|
| 15 |
+
or "Generate Phonetic Transcription" (reads the audio directly, IPA
|
| 16 |
+
output, no Transcript involved at all) -- never both from the same audio
|
| 17 |
+
in the same app.py action. This keeps Transcript the single, unambiguous
|
| 18 |
+
source of truth for every translation, with no parallel audio-reading
|
| 19 |
+
path that could make you wonder which one a downstream artifact came
|
| 20 |
+
from. See services/phonetics.py and app.py's mode selector for why.
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
requirements.txt
CHANGED
|
@@ -6,3 +6,4 @@ sacremoses>=0.1
|
|
| 6 |
torch>=2.0
|
| 7 |
anthropic>=0.40
|
| 8 |
huggingface_hub>=0.24
|
|
|
|
|
|
| 6 |
torch>=2.0
|
| 7 |
anthropic>=0.40
|
| 8 |
huggingface_hub>=0.24
|
| 9 |
+
allosaurus>=1.0.2
|
services/phonetics.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phonetic transcription: Audio -> IPA phones, independent of language.
|
| 2 |
+
|
| 3 |
+
This is deliberately NOT derived from the Transcript. Every other service
|
| 4 |
+
in this codebase follows "Audio -> Transcript -> everything else" (see
|
| 5 |
+
models/transcript.py's module docstring) -- phonetics is the one
|
| 6 |
+
intentional exception, because the whole point is a representation of
|
| 7 |
+
the raw acoustics that owes nothing to any language's orthography or to
|
| 8 |
+
Whisper's language-modeled guess at "what words were probably said".
|
| 9 |
+
|
| 10 |
+
Whisper's transcript is produced by a model that's biased toward
|
| 11 |
+
producing valid words in some language -- it fills in gaps using
|
| 12 |
+
linguistic context. What's implemented here is a *phone recognizer*:
|
| 13 |
+
a model that outputs the IPA symbols for the sounds it hears, using a
|
| 14 |
+
universal (language-agnostic) phone inventory, with no dictionary, no
|
| 15 |
+
grammar, and no language identity involved at all. Two speakers of
|
| 16 |
+
different languages making the same mouth sounds get the same IPA
|
| 17 |
+
output from this service; they would NOT get the same Whisper transcript.
|
| 18 |
+
|
| 19 |
+
Backend: Allosaurus (https://github.com/xinjli/allosaurus), a universal
|
| 20 |
+
phone recognizer trained across ~2000 languages specifically to avoid
|
| 21 |
+
being biased toward any single language's phoneme set. Its default
|
| 22 |
+
inference mode (lang_id="ipa") is exactly this: no target-language
|
| 23 |
+
assumption at all.
|
| 24 |
+
|
| 25 |
+
Note: only .wav is accepted by Allosaurus directly. mp3/m4a/flac inputs
|
| 26 |
+
are transcoded to a temporary wav first (see _ensure_wav below) --
|
| 27 |
+
this is a format conversion, not a re-interpretation of content, so it
|
| 28 |
+
doesn't violate the "independent of language" property.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import subprocess
|
| 34 |
+
import tempfile
|
| 35 |
+
from functools import lru_cache
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Optional
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class PhoneticsError(RuntimeError):
|
| 41 |
+
"""Raised when phonetic transcription can't be produced for this audio."""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@lru_cache(maxsize=1)
|
| 45 |
+
def _get_recognizer():
|
| 46 |
+
"""Load (and cache) the Allosaurus universal phone recognizer.
|
| 47 |
+
|
| 48 |
+
Cached at module level deliberately: this is a large model with no
|
| 49 |
+
per-request state (unlike TranslationService's API key), so it's safe
|
| 50 |
+
and desirable to load it once and reuse it across every call in the
|
| 51 |
+
process, the same way TranscriptionService's Whisper model is reused.
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
from allosaurus.app import read_recognizer
|
| 55 |
+
except ImportError as exc:
|
| 56 |
+
raise PhoneticsError(
|
| 57 |
+
"The 'allosaurus' package is not installed. Add it to "
|
| 58 |
+
"requirements.txt and reinstall to enable phonetic transcription."
|
| 59 |
+
) from exc
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
return read_recognizer()
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
raise PhoneticsError(
|
| 65 |
+
f"Failed to load the Allosaurus phone recognizer: {exc}"
|
| 66 |
+
) from exc
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _ensure_wav(audio_path: str) -> tuple[str, Optional[Path]]:
|
| 70 |
+
"""Return a path Allosaurus can read, converting to wav if necessary.
|
| 71 |
+
|
| 72 |
+
Returns (wav_path, temp_dir_to_clean_up_or_None). Allosaurus only
|
| 73 |
+
accepts .wav files; this is a lossless-in-content format conversion
|
| 74 |
+
(resample/remux), not a transcription step, so it has no bearing on
|
| 75 |
+
the language-independence of the result.
|
| 76 |
+
"""
|
| 77 |
+
if audio_path.lower().endswith(".wav"):
|
| 78 |
+
return audio_path, None
|
| 79 |
+
|
| 80 |
+
tmp_dir = Path(tempfile.mkdtemp(prefix="echoscript_phon_"))
|
| 81 |
+
wav_path = tmp_dir / "audio.wav"
|
| 82 |
+
|
| 83 |
+
result = subprocess.run(
|
| 84 |
+
["ffmpeg", "-y", "-i", str(audio_path), "-ar", "16000", "-ac", "1", str(wav_path)],
|
| 85 |
+
capture_output=True,
|
| 86 |
+
text=True,
|
| 87 |
+
)
|
| 88 |
+
if result.returncode != 0:
|
| 89 |
+
raise PhoneticsError(f"ffmpeg failed to prepare audio for phonetic analysis: {result.stderr.strip()}")
|
| 90 |
+
|
| 91 |
+
return str(wav_path), tmp_dir
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def transcribe_phonetics(audio_path: str) -> str:
|
| 95 |
+
"""Return the IPA phone sequence for this audio file, start to finish.
|
| 96 |
+
|
| 97 |
+
Operates directly on the (already time-windowed, if applicable) audio
|
| 98 |
+
file -- never on a Transcript. Uses Allosaurus's universal 'ipa' mode,
|
| 99 |
+
which makes no assumption about what language is being spoken.
|
| 100 |
+
"""
|
| 101 |
+
wav_path, cleanup_dir = _ensure_wav(audio_path)
|
| 102 |
+
try:
|
| 103 |
+
recognizer = _get_recognizer()
|
| 104 |
+
try:
|
| 105 |
+
phones = recognizer.recognize(wav_path, lang_id="ipa")
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
raise PhoneticsError(f"Allosaurus failed to process the audio: {exc}") from exc
|
| 108 |
+
return phones.strip()
|
| 109 |
+
finally:
|
| 110 |
+
if cleanup_dir is not None:
|
| 111 |
+
import shutil
|
| 112 |
+
shutil.rmtree(cleanup_dir, ignore_errors=True)
|