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
Files changed (4) hide show
  1. app.py +167 -42
  2. models/transcript.py +10 -0
  3. requirements.txt +1 -0
  4. services/phonetics.py +112 -0
app.py CHANGED
@@ -1,25 +1,43 @@
1
  """EchoScript v1.0 UI.
2
 
3
- Two-stage workflow, matching the frozen v1.0 product spec exactly:
4
-
5
- Upload Audio -> Select Audio Window (optional)
6
- -> Detect Language & Generate Canonical Transcript
7
- -> Preview Transcript + available translation languages
8
- -> Generate Translations -> Copy / Download
9
-
10
- These are two distinct user actions, not one combined click:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, the translation-
16
- language picker appears, scoped to the language that was *actually*
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 from
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. Detection happens here too
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 Translation"] if "English Translation" in translation_choices else []
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 reset_session_state():
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 → Select Audio Window → Detect Language & Generate Transcript
358
- → Preview & Choose Languages → Generate Translations → Copy / Download**
359
 
360
- <sub>build: 2026-07-02 23:46 UTC &middot; microphone input added</sub>
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("### Processing Options")
382
- language_input = gr.Dropdown(
383
- choices=SOURCE_LANGUAGE_CHOICES,
384
- value="Auto Detect",
385
- label="Source Language",
386
- info="A hint for transcription, not a guess at translation targets.",
 
 
387
  )
388
 
389
- api_key_input = gr.Textbox(
390
- label="Anthropic API Key (optional)",
391
- type="password",
392
- placeholder="sk-ant-...",
393
- info=(
394
- "Provide your own key to translate into many more languages via Claude. "
395
- "Without one, translation uses local offline models (English, German, "
396
- "Persian, Spanish only). Used for this session only -- never stored."
397
- ),
398
- )
 
 
 
 
 
 
 
 
 
 
 
399
 
400
- generate_transcript_button = gr.Button("Generate Transcript", variant="primary")
401
- reset_button = gr.Button("Reset (clear cache)", size="sm")
 
 
 
 
 
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 shared by Stage 1 (Generate Transcript) and Reset.
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 &rarr; Choose Transcript or Phonetics (independent pipelines) &rarr; Preview &
439
+ Choose Languages (Transcript mode only) &rarr; Generate Translations &rarr; Copy / Download**
440
 
441
+ <sub>build: 2026-07-06 22:10 UTC &middot; 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)