spych.live_translation

   1import json
   2import re
   3import threading
   4import time
   5import signal
   6import requests
   7from queue import Queue, Empty
   8from typing import Optional
   9
  10from faster_whisper import WhisperModel
  11
  12from spych.utils import Notify, load_whisper_model, resolve_whisper_device
  13from spych.live import (
  14    VADRecorder,
  15    KeystrokeListener,
  16    format_timestamp_srt,
  17    format_timestamp_txt,
  18)
  19
  20# ---------------------------------------------------------------------------
  21# Helpers
  22# ---------------------------------------------------------------------------
  23
  24
  25_LANGUAGE_NAMES: dict[str, str] = {
  26    "ar": "Arabic",
  27    "da": "Danish",
  28    "de": "German",
  29    "el": "Greek",
  30    "en": "English",
  31    "es": "Spanish",
  32    "fi": "Finnish",
  33    "fr": "French",
  34    "he": "Hebrew",
  35    "hi": "Hindi",
  36    "it": "Italian",
  37    "ja": "Japanese",
  38    "ko": "Korean",
  39    "ms": "Malay",
  40    "nl": "Dutch",
  41    "no": "Norwegian",
  42    "pl": "Polish",
  43    "pt": "Portuguese",
  44    "ru": "Russian",
  45    "sv": "Swedish",
  46    "sw": "Swahili",
  47    "tr": "Turkish",
  48    "zh": "Chinese",
  49}
  50
  51
  52def _select_whisper_model(model: str, lang_a: str, lang_b: str) -> str:
  53    """Strip a `.en` suffix when either language isn't English, since a single
  54    shared model transcribes both sides of the pair and `.en` models can't
  55    transcribe non-English speech."""
  56    if model.endswith(".en") and (lang_a != "en" or lang_b != "en"):
  57        return model[:-3]
  58    return model
  59
  60
  61def _parse_translation_json(
  62    raw: str, lang_a: str, lang_b: str
  63) -> Optional[tuple[str, str, str]]:
  64    """
  65    Usage:
  66
  67    - Parses a JSON object from an Ollama response string.
  68    - Strips markdown code fences before parsing.
  69    - Expects keys "input_language" and "output_content".
  70    - Clamps input_language to the known pair and derives output_language.
  71    - Returns (input_language, output_language, content) or None on failure.
  72
  73    Requires:
  74
  75    - `raw`:
  76        - Type: str
  77        - What: Raw response string from Ollama, may include markdown fences.
  78
  79    - `lang_a`:
  80        - Type: str
  81        - What: BCP-47 code of the first language in the pair.
  82
  83    - `lang_b`:
  84        - Type: str
  85        - What: BCP-47 code of the second language in the pair.
  86
  87    Returns:
  88
  89    - `result`:
  90        - Type: Optional[tuple[str, str, str]]
  91        - What: (input_language, output_language, translated_text), or None on failure.
  92    """
  93    text = re.sub(r"```(?:json)?\s*", "", raw).strip().rstrip("`").strip()
  94    match = re.search(r"\{.*\}", text, re.DOTALL)
  95    if not match:
  96        return None
  97    try:
  98        data = json.loads(match.group())
  99        input_language = str(data.get("input_language", "")).strip()
 100        output_content = str(data.get("output_content", "")).strip()
 101        if input_language and output_content:
 102            if input_language not in (lang_a, lang_b):
 103                input_language = lang_a
 104            output_language = lang_b if input_language == lang_a else lang_a
 105            return input_language, output_language, output_content
 106    except (json.JSONDecodeError, AttributeError, TypeError):
 107        pass
 108    return None
 109
 110
 111def _detect_and_translate(
 112    text: str,
 113    lang_a: str,
 114    lang_b: str,
 115    host: str,
 116    model: str,
 117) -> Optional[tuple[str, str, str]]:
 118    """
 119    Usage:
 120
 121    - Asks Ollama to detect which of two languages the text is in, then translate
 122      it to the other language.
 123    - Returns (input_language, output_language, translated_text) or None on failure.
 124
 125    Requires:
 126
 127    - `text`:
 128        - Type: str
 129        - What: The transcribed text to translate.
 130
 131    - `lang_a`:
 132        - Type: str
 133        - What: BCP-47 code of the first language in the pair (e.g. "en").
 134
 135    - `lang_b`:
 136        - Type: str
 137        - What: BCP-47 code of the second language in the pair (e.g. "es").
 138
 139    - `host`:
 140        - Type: str
 141        - What: Ollama HTTP base URL (e.g. "http://localhost:11434").
 142
 143    - `model`:
 144        - Type: str
 145        - What: Ollama model name to use for translation (e.g. "llama3.2").
 146
 147    Returns:
 148
 149    - `result`:
 150        - Type: Optional[tuple[str, str, str]]
 151        - What: (input_language, output_language, translated_text), or None on any error.
 152    """
 153    name_a = _LANGUAGE_NAMES.get(lang_a, lang_a)
 154    name_b = _LANGUAGE_NAMES.get(lang_b, lang_b)
 155    prompt = (
 156        f"You are translating between two people having a conversation. "
 157        f"One speaks {name_a} (code: {lang_a}) and the other speaks {name_b} (code: {lang_b}). "
 158        f"First identify the input language, then translate the text to the other language."
 159        f"The text to be translated might be in either language. Make sure to respond in the other language.\n\n"
 160        f"Translate the following:\n\n {text}\n\n"
 161    )
 162    schema = {
 163        "type": "object",
 164        "properties": {
 165            "input_language": {"type": "string", "enum": [lang_a, lang_b]},
 166            "output_language": {"type": "string", "enum": [lang_a, lang_b]},
 167            "output_content": {"type": "string"},
 168        },
 169        "required": ["input_language", "output_language", "output_content"],
 170    }
 171    try:
 172        resp = requests.post(
 173            f"{host}/api/generate",
 174            json={
 175                "model": model,
 176                "prompt": prompt,
 177                "stream": False,
 178                "format": schema,
 179            },
 180            timeout=10,
 181        )
 182        raw = resp.json().get("response", "")
 183        return _parse_translation_json(raw, lang_a, lang_b)
 184    except Exception:
 185        return None
 186
 187
 188# ---------------------------------------------------------------------------
 189# Data container for a completed translation segment
 190# ---------------------------------------------------------------------------
 191
 192
 193class TranslationSegment:
 194    """Internal container for a fully transcribed and translated speech segment."""
 195
 196    __slots__ = (
 197        "text",
 198        "translated_text",
 199        "input_language",
 200        "output_language",
 201        "start_time",
 202        "end_time",
 203        "index",
 204    )
 205
 206    def __init__(
 207        self,
 208        text: str,
 209        translated_text: str,
 210        input_language: str,
 211        output_language: str,
 212        start_time: float,
 213        end_time: float,
 214        index: int,
 215    ):
 216        self.text = text.strip()
 217        self.translated_text = translated_text.strip()
 218        self.input_language = input_language
 219        self.output_language = output_language
 220        self.start_time = start_time
 221        self.end_time = end_time
 222        self.index = index
 223
 224
 225# ---------------------------------------------------------------------------
 226# Transcription + translation worker thread
 227# ---------------------------------------------------------------------------
 228
 229
 230class TranslatingTranscriber(Notify):
 231    """
 232    Pulls (audio, start_time, end_time) tuples from audio_queue, runs
 233    faster-whisper inference with a language-hint initial_prompt, detects
 234    which language was spoken via Ollama, translates to the other, and
 235    pushes TranslationSegment objects to segment_queue.
 236
 237    Ollama failures are soft errors: the segment is emitted with
 238    translated_text = "[translation unavailable]" so the session continues.
 239    """
 240
 241    def __init__(
 242        self,
 243        audio_queue: Queue,
 244        segment_queue: Queue,
 245        model: WhisperModel,
 246        stop_event: threading.Event,
 247        lang_a: str,
 248        lang_b: str,
 249        ollama_host: str,
 250        ollama_translation_model: str,
 251        no_speech_threshold: float = 0.4,
 252        show_timestamps: bool = True,
 253    ):
 254        """
 255        Requires:
 256
 257        - `audio_queue`: Queue of (np.ndarray, float, float) tuples from VADRecorder
 258        - `segment_queue`: Queue of TranslationSegment objects consumed by TranslationWriter
 259        - `model`: A pre-initialized WhisperModel instance
 260        - `stop_event`: Shared stop signal
 261        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
 262        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
 263        - `ollama_host`: Ollama HTTP base URL
 264        - `ollama_translation_model`: Ollama model name for translation
 265
 266        Optional:
 267
 268        - `no_speech_threshold`:
 269            - Type: float
 270            - What: Segments with no_speech_prob above this are discarded
 271            - Default: 0.4
 272
 273        - `show_timestamps`:
 274            - Type: bool
 275            - What: If True, prepends relative timestamps to terminal output
 276            - Default: True
 277        """
 278        self.audio_queue = audio_queue
 279        self.segment_queue = segment_queue
 280        self.model = model
 281        self.stop_event = stop_event
 282        self.lang_a = lang_a
 283        self.lang_b = lang_b
 284        self.ollama_host = ollama_host
 285        self.ollama_translation_model = ollama_translation_model
 286        self.no_speech_threshold = no_speech_threshold
 287        self.show_timestamps = show_timestamps
 288        self.segment_index: int = 0
 289
 290    def run(self):
 291        """Blocking transcription + translation loop. Intended to be run in a dedicated thread."""
 292        name_a = _LANGUAGE_NAMES.get(self.lang_a, self.lang_a)
 293        name_b = _LANGUAGE_NAMES.get(self.lang_b, self.lang_b)
 294        initial_prompt = f"Expect only audio in {name_a} or {name_b}. Do not transcribe from other languages. Transcribe only the input audio."
 295
 296        while True:
 297            try:
 298                item = self.audio_queue.get(timeout=0.5)
 299            except Empty:
 300                if self.stop_event.is_set():
 301                    break
 302                continue
 303
 304            if item is None:
 305                break
 306
 307            audio, start_time, end_time = item
 308
 309            segments, _ = self.model.transcribe(
 310                audio,
 311                initial_prompt=initial_prompt,
 312            )
 313
 314            words = []
 315            for seg in segments:
 316                if seg.no_speech_prob > self.no_speech_threshold:
 317                    continue
 318                words.append(seg.text.strip())
 319
 320            if not words:
 321                continue
 322
 323            text = " ".join(words)
 324
 325            if self.stop_event.is_set():
 326                continue
 327
 328            result = _detect_and_translate(
 329                text=text,
 330                lang_a=self.lang_a,
 331                lang_b=self.lang_b,
 332                host=self.ollama_host,
 333                model=self.ollama_translation_model,
 334            )
 335
 336            if result is None:
 337                input_language = self.lang_a
 338                output_language = self.lang_b
 339                translated_text = "[translation unavailable]"
 340            else:
 341                input_language, output_language, translated_text = result
 342
 343            self.segment_index += 1
 344            segment = TranslationSegment(
 345                text=text,
 346                translated_text=translated_text,
 347                input_language=input_language,
 348                output_language=output_language,
 349                start_time=start_time,
 350                end_time=end_time,
 351                index=self.segment_index,
 352            )
 353
 354            if self.show_timestamps:
 355                ts = format_timestamp_txt(segment.start_time)
 356                src_line = f"{ts}({segment.input_language}) {segment.text}"
 357                tgt_line = (
 358                    f"{ts}({segment.output_language}) {segment.translated_text}"
 359                )
 360            else:
 361                src_line = f"({segment.input_language}) {segment.text}"
 362                tgt_line = (
 363                    f"({segment.output_language}) {segment.translated_text}"
 364                )
 365            print(src_line, flush=True)
 366            print(tgt_line, flush=True)
 367
 368            self.segment_queue.put(segment)
 369
 370
 371# ---------------------------------------------------------------------------
 372# VAD recorder that pauses while TTS is speaking
 373# ---------------------------------------------------------------------------
 374
 375
 376class PauseableVADRecorder(VADRecorder):
 377    """
 378    VADRecorder that holds off starting a new recording window while TTS is
 379    speaking. Before each call to record_vad(), it blocks until speaking_event
 380    is cleared, preventing the microphone from picking up the speaker's output.
 381    """
 382
 383    def __init__(self, *args, speaking_event: threading.Event, **kwargs):
 384        super().__init__(*args, **kwargs)
 385        self.speaking_event = speaking_event
 386
 387    def run(self, session_start_time: float):
 388        _TTS_TIMEOUT_S = 30.0
 389        # Watchdog: if record_vad hasn't returned in this many seconds, the
 390        # PvRecorder.read() call is likely blocked (e.g. PulseAudio suspended
 391        # the source). Abandon that cycle and start fresh.
 392        _RECORD_WATCHDOG_S = self.max_speech_duration_s + 15.0
 393        # Recycle the PvRecorder every 5 s of silence so the device never
 394        # drifts into a stale state between utterances.
 395        _INACTIVITY_TIMEOUT_S = 5.0
 396        try:
 397            while not self.stop_event.is_set():
 398                # Block while TTS is playing so the mic doesn't hear the speaker.
 399                # Safety timeout: force-clear speaking_event if TTS hangs > 30 s.
 400                wait_start = None
 401                while (
 402                    self.speaking_event.is_set()
 403                    and not self.stop_event.is_set()
 404                ):
 405                    if wait_start is None:
 406                        wait_start = time.time()
 407                    elif time.time() - wait_start > _TTS_TIMEOUT_S:
 408                        self.speaking_event.clear()
 409                        break
 410                    time.sleep(0.05)
 411                if self.stop_event.is_set():
 412                    break
 413
 414                # Combined abort: fires when the session stops OR TTS starts.
 415                abort_event = threading.Event()
 416
 417                def _watch(abort_event: threading.Event = abort_event) -> None:
 418                    while (
 419                        not self.stop_event.is_set()
 420                        and not self.speaking_event.is_set()
 421                    ):
 422                        time.sleep(0.02)
 423                    abort_event.set()
 424
 425                threading.Thread(target=_watch, daemon=True).start()
 426
 427                # Run record_vad in a daemon thread. PvRecorder.read() is a
 428                # blocking C call — if PulseAudio suspends the audio source the
 429                # read never returns and no Python event can unblock it. The
 430                # watchdog detects this and abandons the cycle so a fresh
 431                # PvRecorder is opened on the next iteration.
 432                _result: list = [None]
 433                _done = threading.Event()
 434
 435                def _record(
 436                    abort_event: threading.Event = abort_event,
 437                    _result: list = _result,
 438                    _done: threading.Event = _done,
 439                ) -> None:
 440                    try:
 441                        _result[0] = self.recorder.record_vad(
 442                            device_index=self.device_index,
 443                            speech_threshold=self.speech_threshold,
 444                            silence_threshold=self.silence_threshold,
 445                            silence_frames_threshold=self.silence_frames_threshold,
 446                            speech_pad_frames=self.speech_pad_frames,
 447                            max_speech_duration_s=self.max_speech_duration_s,
 448                            inactivity_timeout=_INACTIVITY_TIMEOUT_S,
 449                            stop_event=abort_event,
 450                        )
 451                    except Exception:
 452                        _result[0] = []
 453                    finally:
 454                        _done.set()
 455
 456                start_wall = time.time()
 457                threading.Thread(target=_record, daemon=True).start()
 458
 459                completed = _done.wait(timeout=_RECORD_WATCHDOG_S)
 460                if not completed:
 461                    # PvRecorder.read() is hung — abandon and let the daemon
 462                    # thread die at process exit. Signal the watcher to stop.
 463                    abort_event.set()
 464                    continue
 465
 466                frames = _result[0] or []
 467
 468                if self.stop_event.is_set():
 469                    break
 470                # TTS fired during recording or no speech — discard.
 471                if self.speaking_event.is_set() or not frames:
 472                    continue
 473                end_wall = time.time()
 474                start_time = start_wall - session_start_time
 475                end_time = end_wall - session_start_time
 476                self.flush(frames, start_time, end_time)
 477        finally:
 478            pass
 479
 480
 481# ---------------------------------------------------------------------------
 482# Writer / output thread
 483# ---------------------------------------------------------------------------
 484
 485
 486class TranslationWriter(Notify):
 487    """
 488    Consumes TranslationSegment objects from segment_queue and writes bilingual
 489    output to disk and the terminal.
 490
 491    Each segment produces two lines: one for the source language and one for
 492    the target language, each prefixed with a timestamp and language hint.
 493
 494    Optionally speaks the translated text via Speaker.
 495    """
 496
 497    def __init__(
 498        self,
 499        segment_queue: Queue,
 500        stop_event: threading.Event,
 501        lang_a: str,
 502        lang_b: str,
 503        speaking_event: threading.Event,
 504        output_format: str = "",
 505        output_path: str = "transcript",
 506        show_timestamps: bool = True,
 507        use_speaker: bool = True,
 508        speaker_voice: str = "",
 509    ):
 510        """
 511        Requires:
 512
 513        - `segment_queue`: Queue of TranslationSegment objects from TranslatingTranscriber
 514        - `stop_event`: Shared stop signal
 515        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
 516        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
 517        - `speaking_event`: Shared event set while TTS is playing; signals PauseableVADRecorder to hold off
 518
 519        Optional:
 520
 521        - `output_format`:
 522            - Type: str
 523            - What: Output format(s) to write; empty string disables file output
 524            - Default: "" (no file output)
 525            - Options: "txt", "srt", "both"
 526
 527        - `output_path`:
 528            - Type: str
 529            - What: Base file path (without extension)
 530            - Default: "transcript"
 531
 532        - `show_timestamps`:
 533            - Type: bool
 534            - What: If True, prepends relative timestamps to terminal and TXT output
 535            - Default: True
 536
 537        - `use_speaker`:
 538            - Type: bool
 539            - What: If True, speaks the translated text via TTS after each segment
 540            - Default: True
 541
 542        - `speaker_voice`:
 543            - Type: str
 544            - What: Wave voice name for zero-shot cloning; empty string uses the
 545              model's built-in default voice
 546            - Default: ""
 547        """
 548        self.segment_queue = segment_queue
 549        self.stop_event = stop_event
 550        self.lang_a = lang_a
 551        self.lang_b = lang_b
 552        self.speaking_event = speaking_event
 553        self.output_format = output_format
 554        self.output_path = output_path
 555        self.show_timestamps = show_timestamps
 556        self.use_speaker = use_speaker
 557        self.speaker_voice = speaker_voice
 558        self.txt_file = None
 559        self.srt_file = None
 560        self.speakers: dict[str, object] = {}
 561
 562    def run(self):
 563        """Blocking writer loop. Intended to be run in a dedicated thread."""
 564        if self.use_speaker:
 565            from spych.speaker.speaker import Speaker
 566
 567            for lang_id in (self.lang_a, self.lang_b):
 568                try:
 569                    self.speakers[lang_id] = Speaker(
 570                        voice=self.speaker_voice,
 571                        backend="chatterbox_multilingual",
 572                        language_id=lang_id,
 573                    )
 574                except Exception as e:
 575                    print(
 576                        f"[spych] TTS for {lang_id} unavailable, "
 577                        f"continuing without speaker for that language: {e}",
 578                        flush=True,
 579                    )
 580
 581        try:
 582            if self.output_format and self.output_format in ("txt", "both"):
 583                self.txt_file = open(
 584                    f"{self.output_path}.txt", "w", encoding="utf-8"
 585                )
 586            if self.output_format and self.output_format in ("srt", "both"):
 587                self.srt_file = open(
 588                    f"{self.output_path}.srt", "w", encoding="utf-8"
 589                )
 590
 591            while True:
 592                try:
 593                    segment = self.segment_queue.get(timeout=0.5)
 594                except Empty:
 595                    if self.stop_event.is_set():
 596                        break
 597                    continue
 598
 599                if segment is None:
 600                    break
 601
 602                self.write_segment(segment)
 603
 604        finally:
 605            for speaker in self.speakers.values():
 606                speaker.interrupt()
 607                speaker.wait_for_speak()
 608            if self.txt_file:
 609                self.txt_file.flush()
 610                self.txt_file.close()
 611            if self.srt_file:
 612                self.srt_file.flush()
 613                self.srt_file.close()
 614            if self.speakers:
 615                import pygame
 616
 617                try:
 618                    pygame.mixer.quit()
 619                except Exception:
 620                    pass
 621
 622    def write_segment(self, segment: TranslationSegment):
 623        """Write one bilingual segment to file outputs and queue TTS."""
 624        if self.txt_file:
 625            if self.show_timestamps:
 626                ts = format_timestamp_txt(segment.start_time)
 627                src_line = f"{ts}({segment.input_language}) {segment.text}"
 628                tgt_line = (
 629                    f"{ts}({segment.output_language}) {segment.translated_text}"
 630                )
 631            else:
 632                src_line = f"({segment.input_language}) {segment.text}"
 633                tgt_line = (
 634                    f"({segment.output_language}) {segment.translated_text}"
 635                )
 636            self.txt_file.write(src_line + "\n")
 637            self.txt_file.write(tgt_line + "\n")
 638            self.txt_file.flush()
 639
 640        if self.srt_file:
 641            srt_block = (
 642                f"{segment.index}\n"
 643                f"{format_timestamp_srt(segment.start_time)} --> "
 644                f"{format_timestamp_srt(segment.end_time)}\n"
 645                f"[{segment.input_language}] {segment.text}\n"
 646                f"[{segment.output_language}] {segment.translated_text}\n\n"
 647            )
 648            self.srt_file.write(srt_block)
 649            self.srt_file.flush()
 650
 651        speaker = self.speakers.get(segment.output_language)
 652        if speaker and segment.translated_text != "[translation unavailable]":
 653            if self.stop_event.is_set():
 654                return
 655            # Serialize TTS: both speakers share pygame.mixer.music, so we must
 656            # wait for any in-progress playback to finish before starting the next.
 657            # Poll with stop_event so Ctrl+C can interrupt this wait.
 658            for s in self.speakers.values():
 659                while s.is_speaking() and not self.stop_event.is_set():
 660                    time.sleep(0.05)
 661                if self.stop_event.is_set():
 662                    s.interrupt()
 663            if self.stop_event.is_set():
 664                return
 665            self.speaking_event.set()
 666
 667            def _on_complete():
 668                self.speaking_event.clear()
 669
 670            speaker.speak_async(
 671                segment.translated_text, on_complete=_on_complete
 672            )
 673
 674
 675# ---------------------------------------------------------------------------
 676# Main orchestrator
 677# ---------------------------------------------------------------------------
 678
 679
 680class SpychLiveTranslation(Notify):
 681    def __init__(
 682        self,
 683        lang_a: str,
 684        lang_b: str,
 685        output_format: str = "",
 686        output_path: str = "transcript",
 687        show_timestamps: bool = True,
 688        stop_key: str = "q",
 689        terminate_words: Optional[list[str]] = None,
 690        device_index: int = -1,
 691        whisper_model: str = "small",
 692        whisper_device: str = "auto",
 693        whisper_compute_type: str = "int8",
 694        no_speech_threshold: float = 0.4,
 695        speech_threshold: float = 0.5,
 696        silence_threshold: float = 0.35,
 697        silence_frames_threshold: int = 20,
 698        speech_pad_frames: int = 5,
 699        max_speech_duration_s: float = 30.0,
 700        ollama_host: str = "http://localhost:11434",
 701        ollama_translation_model: str = "llama3.2",
 702        use_speaker: bool = True,
 703        speaker_voice: str = "",
 704    ):
 705        """
 706        Usage:
 707
 708        - Initializes a bidirectional live translation session. Either participant
 709          may speak in either language; Whisper transcribes and Ollama detects
 710          which language was spoken then translates to the other.
 711        - Runs continuously until stopped by keystroke, terminate word, or Ctrl+C.
 712
 713        Requires:
 714
 715        - `lang_a`:
 716            - Type: str
 717            - What: BCP-47 code of the first language in the pair (e.g. "en")
 718
 719        - `lang_b`:
 720            - Type: str
 721            - What: BCP-47 code of the second language in the pair (e.g. "es")
 722
 723        Optional:
 724
 725        - `output_format`:
 726            - Type: str
 727            - What: Output format(s) to write; empty string disables file output
 728            - Default: "" (no file output)
 729            - Options: "txt", "srt", "both"
 730
 731        - `output_path`:
 732            - Type: str
 733            - What: Base output file path without extension
 734            - Default: "transcript"
 735
 736        - `show_timestamps`:
 737            - Type: bool
 738            - What: If True, prepends relative [HH:MM:SS] timestamps to each line
 739            - Default: True
 740
 741        - `stop_key`:
 742            - Type: str
 743            - What: The key (followed by Enter) the user types to stop recording
 744            - Default: "q"
 745
 746        - `terminate_words`:
 747            - Type: list[str] | None
 748            - What: Words that, if detected in the transcript, immediately stop the session
 749            - Default: None
 750
 751        - `device_index`:
 752            - Type: int
 753            - What: Microphone device index; -1 uses the system default
 754            - Default: -1
 755
 756        - `whisper_model`:
 757            - Type: str
 758            - What: faster-whisper model name; `.en` suffix is stripped automatically
 759              when either language is not English
 760            - Default: "small"
 761
 762        - `whisper_device`:
 763            - Type: str
 764            - What: Device for whisper inference
 765            - Default: "auto"
 766            - Options: "auto", "cpu", "cuda"
 767            - Note: "auto" selects "cuda" when Python <=3.13 and a CUDA device is
 768              available, otherwise falls back to "cpu". "cuda" requires
 769              nvidia-cublas-cu12 and nvidia-cudnn-cu12 (pip).
 770
 771        - `whisper_compute_type`:
 772            - Type: str
 773            - What: Compute precision for the whisper model
 774            - Default: "int8"
 775            - Options: "int8", "float16", "float32"
 776
 777        - `no_speech_threshold`:
 778            - Type: float
 779            - What: Whisper segments with no_speech_prob above this are discarded
 780            - Default: 0.4
 781
 782        - `speech_threshold`:
 783            - Type: float (0.0–1.0)
 784            - What: Silero probability above which a frame is considered speech onset
 785            - Default: 0.5
 786
 787        - `silence_threshold`:
 788            - Type: float (0.0–1.0)
 789            - What: Silero probability below which a frame is considered silence
 790            - Default: 0.35
 791
 792        - `silence_frames_threshold`:
 793            - Type: int
 794            - What: Consecutive silent frames required to close a speech segment
 795            - Default: 20
 796
 797        - `speech_pad_frames`:
 798            - Type: int
 799            - What: Pre-roll frames and onset confirmation count
 800            - Default: 5
 801
 802        - `max_speech_duration_s`:
 803            - Type: float
 804            - What: Hard cap on a single speech segment in seconds
 805            - Default: 30.0
 806
 807        - `ollama_host`:
 808            - Type: str
 809            - What: Ollama HTTP base URL for translation requests
 810            - Default: "http://localhost:11434"
 811
 812        - `ollama_translation_model`:
 813            - Type: str
 814            - What: Ollama model name used for translation
 815            - Default: "llama3.2"
 816
 817        - `use_speaker`:
 818            - Type: bool
 819            - What: If True, speaks each translated segment aloud via TTS
 820            - Default: True
 821
 822        - `speaker_voice`:
 823            - Type: str
 824            - What: Wave voice name for zero-shot cloning; empty string uses the
 825              model's built-in default voice
 826            - Default: ""
 827        """
 828        self.lang_a = lang_a
 829        self.lang_b = lang_b
 830        self.output_format = output_format
 831        self.output_path = output_path
 832        self.show_timestamps = show_timestamps
 833        self.stop_key = stop_key
 834        self.terminate_words = (
 835            [w.lower() for w in terminate_words] if terminate_words else []
 836        )
 837        self.device_index = device_index
 838        self.no_speech_threshold = no_speech_threshold
 839        self.speech_threshold = speech_threshold
 840        self.silence_threshold = silence_threshold
 841        self.silence_frames_threshold = silence_frames_threshold
 842        self.speech_pad_frames = speech_pad_frames
 843        self.max_speech_duration_s = max_speech_duration_s
 844        self.ollama_host = ollama_host
 845        self.ollama_translation_model = ollama_translation_model
 846        self.use_speaker = use_speaker
 847        self.speaker_voice = speaker_voice
 848
 849        resolved_model = _select_whisper_model(whisper_model, lang_a, lang_b)
 850        self.model = load_whisper_model(
 851            resolved_model,
 852            device=resolve_whisper_device(whisper_device),
 853            compute_type=whisper_compute_type,
 854        )
 855
 856        self.stop_event = threading.Event()
 857        self.speaking_event = threading.Event()
 858        self.audio_queue: Queue = Queue()
 859        self.segment_queue: Queue = Queue()
 860
 861    def start(self):
 862        """
 863        Usage:
 864
 865        - Starts the live transcription + translation session and blocks until
 866          the user stops it via the configured stop key or a terminate word
 867        - Prints a startup message indicating how to stop the session
 868
 869        Notes:
 870
 871        - Thread startup order: keystroke listener → recorder → transcriber → writer
 872        - SIGINT (Ctrl+C) is caught and redirected to the same graceful stop path
 873        """
 874        original_sigint = signal.getsignal(signal.SIGINT)
 875
 876        def handle_sigint(sig, frame):
 877            print(
 878                "\n[spych] Interrupt received. "
 879                "Finishing current segment and shutting down...",
 880                flush=True,
 881            )
 882            self.stop_event.set()
 883            signal.signal(signal.SIGINT, original_sigint)
 884
 885        signal.signal(signal.SIGINT, handle_sigint)
 886
 887        stop_instructions = [f"Press '{self.stop_key}' + Enter"]
 888        if self.terminate_words:
 889            words_display = ", ".join(f'"{w}"' for w in self.terminate_words)
 890            stop_instructions.append(f"say {words_display}")
 891        print(
 892            f"[spych] Live translation started "
 893            f"({self.lang_a}{self.lang_b}). "
 894            f"To stop: {' or '.join(stop_instructions)}.",
 895            flush=True,
 896        )
 897
 898        ks_listener = KeystrokeListener(self.stop_event, self.stop_key)
 899        ks_thread = threading.Thread(target=ks_listener.run, daemon=True)
 900        ks_thread.start()
 901
 902        session_start = time.time()
 903
 904        recorder = PauseableVADRecorder(
 905            audio_queue=self.audio_queue,
 906            stop_event=self.stop_event,
 907            device_index=self.device_index,
 908            speech_threshold=self.speech_threshold,
 909            silence_threshold=self.silence_threshold,
 910            silence_frames_threshold=self.silence_frames_threshold,
 911            speech_pad_frames=self.speech_pad_frames,
 912            max_speech_duration_s=self.max_speech_duration_s,
 913            speaking_event=self.speaking_event,
 914        )
 915        rec_thread = threading.Thread(
 916            target=recorder.run, args=(session_start,), daemon=False
 917        )
 918
 919        transcriber = TranslatingTranscriber(
 920            audio_queue=self.audio_queue,
 921            segment_queue=self.segment_queue,
 922            model=self.model,
 923            stop_event=self.stop_event,
 924            lang_a=self.lang_a,
 925            lang_b=self.lang_b,
 926            ollama_host=self.ollama_host,
 927            ollama_translation_model=self.ollama_translation_model,
 928            no_speech_threshold=self.no_speech_threshold,
 929            show_timestamps=self.show_timestamps,
 930        )
 931        trans_thread = threading.Thread(
 932            target=self.transcribe_and_check,
 933            args=(transcriber,),
 934            daemon=False,
 935        )
 936
 937        writer = TranslationWriter(
 938            segment_queue=self.segment_queue,
 939            stop_event=self.stop_event,
 940            lang_a=self.lang_a,
 941            lang_b=self.lang_b,
 942            speaking_event=self.speaking_event,
 943            output_format=self.output_format,
 944            output_path=self.output_path,
 945            show_timestamps=self.show_timestamps,
 946            use_speaker=self.use_speaker,
 947            speaker_voice=self.speaker_voice,
 948        )
 949        write_thread = threading.Thread(target=writer.run, daemon=False)
 950
 951        write_thread.start()
 952        trans_thread.start()
 953        rec_thread.start()
 954
 955        rec_thread.join()
 956
 957        self.audio_queue.put(None)
 958        trans_thread.join()
 959
 960        self.segment_queue.put(None)
 961        write_thread.join()
 962
 963        signal.signal(signal.SIGINT, original_sigint)
 964        if self.output_format:
 965            print(
 966                f"[spych] Session complete. Output saved to: {self.output_path}.*",
 967                flush=True,
 968            )
 969        else:
 970            print("[spych] Session complete.", flush=True)
 971
 972    def transcribe_and_check(self, transcriber: TranslatingTranscriber):
 973        """
 974        Runs transcriber.run() and intercepts every segment put onto segment_queue
 975        to check for terminate words.
 976        """
 977        original_put = self.segment_queue.put
 978
 979        def checked_put(segment):
 980            original_put(segment)
 981            if not self.terminate_words or not isinstance(
 982                segment, TranslationSegment
 983            ):
 984                return
 985            text_lower = segment.text.lower()
 986            for word in self.terminate_words:
 987                if word in text_lower:
 988                    print(
 989                        f'\n[spych] Terminate word "{word}" detected. '
 990                        "Finishing and shutting down...",
 991                        flush=True,
 992                    )
 993                    self.stop_event.set()
 994                    return
 995
 996        self.segment_queue.put = checked_put
 997        try:
 998            transcriber.run()
 999        finally:
1000            self.segment_queue.put = original_put
class TranslationSegment:
194class TranslationSegment:
195    """Internal container for a fully transcribed and translated speech segment."""
196
197    __slots__ = (
198        "text",
199        "translated_text",
200        "input_language",
201        "output_language",
202        "start_time",
203        "end_time",
204        "index",
205    )
206
207    def __init__(
208        self,
209        text: str,
210        translated_text: str,
211        input_language: str,
212        output_language: str,
213        start_time: float,
214        end_time: float,
215        index: int,
216    ):
217        self.text = text.strip()
218        self.translated_text = translated_text.strip()
219        self.input_language = input_language
220        self.output_language = output_language
221        self.start_time = start_time
222        self.end_time = end_time
223        self.index = index

Internal container for a fully transcribed and translated speech segment.

TranslationSegment( text: str, translated_text: str, input_language: str, output_language: str, start_time: float, end_time: float, index: int)
207    def __init__(
208        self,
209        text: str,
210        translated_text: str,
211        input_language: str,
212        output_language: str,
213        start_time: float,
214        end_time: float,
215        index: int,
216    ):
217        self.text = text.strip()
218        self.translated_text = translated_text.strip()
219        self.input_language = input_language
220        self.output_language = output_language
221        self.start_time = start_time
222        self.end_time = end_time
223        self.index = index
text
translated_text
input_language
output_language
start_time
end_time
index
class TranslatingTranscriber(spych.utils.Notify):
231class TranslatingTranscriber(Notify):
232    """
233    Pulls (audio, start_time, end_time) tuples from audio_queue, runs
234    faster-whisper inference with a language-hint initial_prompt, detects
235    which language was spoken via Ollama, translates to the other, and
236    pushes TranslationSegment objects to segment_queue.
237
238    Ollama failures are soft errors: the segment is emitted with
239    translated_text = "[translation unavailable]" so the session continues.
240    """
241
242    def __init__(
243        self,
244        audio_queue: Queue,
245        segment_queue: Queue,
246        model: WhisperModel,
247        stop_event: threading.Event,
248        lang_a: str,
249        lang_b: str,
250        ollama_host: str,
251        ollama_translation_model: str,
252        no_speech_threshold: float = 0.4,
253        show_timestamps: bool = True,
254    ):
255        """
256        Requires:
257
258        - `audio_queue`: Queue of (np.ndarray, float, float) tuples from VADRecorder
259        - `segment_queue`: Queue of TranslationSegment objects consumed by TranslationWriter
260        - `model`: A pre-initialized WhisperModel instance
261        - `stop_event`: Shared stop signal
262        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
263        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
264        - `ollama_host`: Ollama HTTP base URL
265        - `ollama_translation_model`: Ollama model name for translation
266
267        Optional:
268
269        - `no_speech_threshold`:
270            - Type: float
271            - What: Segments with no_speech_prob above this are discarded
272            - Default: 0.4
273
274        - `show_timestamps`:
275            - Type: bool
276            - What: If True, prepends relative timestamps to terminal output
277            - Default: True
278        """
279        self.audio_queue = audio_queue
280        self.segment_queue = segment_queue
281        self.model = model
282        self.stop_event = stop_event
283        self.lang_a = lang_a
284        self.lang_b = lang_b
285        self.ollama_host = ollama_host
286        self.ollama_translation_model = ollama_translation_model
287        self.no_speech_threshold = no_speech_threshold
288        self.show_timestamps = show_timestamps
289        self.segment_index: int = 0
290
291    def run(self):
292        """Blocking transcription + translation loop. Intended to be run in a dedicated thread."""
293        name_a = _LANGUAGE_NAMES.get(self.lang_a, self.lang_a)
294        name_b = _LANGUAGE_NAMES.get(self.lang_b, self.lang_b)
295        initial_prompt = f"Expect only audio in {name_a} or {name_b}. Do not transcribe from other languages. Transcribe only the input audio."
296
297        while True:
298            try:
299                item = self.audio_queue.get(timeout=0.5)
300            except Empty:
301                if self.stop_event.is_set():
302                    break
303                continue
304
305            if item is None:
306                break
307
308            audio, start_time, end_time = item
309
310            segments, _ = self.model.transcribe(
311                audio,
312                initial_prompt=initial_prompt,
313            )
314
315            words = []
316            for seg in segments:
317                if seg.no_speech_prob > self.no_speech_threshold:
318                    continue
319                words.append(seg.text.strip())
320
321            if not words:
322                continue
323
324            text = " ".join(words)
325
326            if self.stop_event.is_set():
327                continue
328
329            result = _detect_and_translate(
330                text=text,
331                lang_a=self.lang_a,
332                lang_b=self.lang_b,
333                host=self.ollama_host,
334                model=self.ollama_translation_model,
335            )
336
337            if result is None:
338                input_language = self.lang_a
339                output_language = self.lang_b
340                translated_text = "[translation unavailable]"
341            else:
342                input_language, output_language, translated_text = result
343
344            self.segment_index += 1
345            segment = TranslationSegment(
346                text=text,
347                translated_text=translated_text,
348                input_language=input_language,
349                output_language=output_language,
350                start_time=start_time,
351                end_time=end_time,
352                index=self.segment_index,
353            )
354
355            if self.show_timestamps:
356                ts = format_timestamp_txt(segment.start_time)
357                src_line = f"{ts}({segment.input_language}) {segment.text}"
358                tgt_line = (
359                    f"{ts}({segment.output_language}) {segment.translated_text}"
360                )
361            else:
362                src_line = f"({segment.input_language}) {segment.text}"
363                tgt_line = (
364                    f"({segment.output_language}) {segment.translated_text}"
365                )
366            print(src_line, flush=True)
367            print(tgt_line, flush=True)
368
369            self.segment_queue.put(segment)

Pulls (audio, start_time, end_time) tuples from audio_queue, runs faster-whisper inference with a language-hint initial_prompt, detects which language was spoken via Ollama, translates to the other, and pushes TranslationSegment objects to segment_queue.

Ollama failures are soft errors: the segment is emitted with translated_text = "[translation unavailable]" so the session continues.

TranslatingTranscriber( audio_queue: queue.Queue, segment_queue: queue.Queue, model: faster_whisper.transcribe.WhisperModel, stop_event: threading.Event, lang_a: str, lang_b: str, ollama_host: str, ollama_translation_model: str, no_speech_threshold: float = 0.4, show_timestamps: bool = True)
242    def __init__(
243        self,
244        audio_queue: Queue,
245        segment_queue: Queue,
246        model: WhisperModel,
247        stop_event: threading.Event,
248        lang_a: str,
249        lang_b: str,
250        ollama_host: str,
251        ollama_translation_model: str,
252        no_speech_threshold: float = 0.4,
253        show_timestamps: bool = True,
254    ):
255        """
256        Requires:
257
258        - `audio_queue`: Queue of (np.ndarray, float, float) tuples from VADRecorder
259        - `segment_queue`: Queue of TranslationSegment objects consumed by TranslationWriter
260        - `model`: A pre-initialized WhisperModel instance
261        - `stop_event`: Shared stop signal
262        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
263        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
264        - `ollama_host`: Ollama HTTP base URL
265        - `ollama_translation_model`: Ollama model name for translation
266
267        Optional:
268
269        - `no_speech_threshold`:
270            - Type: float
271            - What: Segments with no_speech_prob above this are discarded
272            - Default: 0.4
273
274        - `show_timestamps`:
275            - Type: bool
276            - What: If True, prepends relative timestamps to terminal output
277            - Default: True
278        """
279        self.audio_queue = audio_queue
280        self.segment_queue = segment_queue
281        self.model = model
282        self.stop_event = stop_event
283        self.lang_a = lang_a
284        self.lang_b = lang_b
285        self.ollama_host = ollama_host
286        self.ollama_translation_model = ollama_translation_model
287        self.no_speech_threshold = no_speech_threshold
288        self.show_timestamps = show_timestamps
289        self.segment_index: int = 0

Requires:

  • audio_queue: Queue of (np.ndarray, float, float) tuples from VADRecorder
  • segment_queue: Queue of TranslationSegment objects consumed by TranslationWriter
  • model: A pre-initialized WhisperModel instance
  • stop_event: Shared stop signal
  • lang_a: BCP-47 code of the first language in the pair (e.g. "en")
  • lang_b: BCP-47 code of the second language in the pair (e.g. "es")
  • ollama_host: Ollama HTTP base URL
  • ollama_translation_model: Ollama model name for translation

Optional:

  • no_speech_threshold:

    • Type: float
    • What: Segments with no_speech_prob above this are discarded
    • Default: 0.4
  • show_timestamps:

    • Type: bool
    • What: If True, prepends relative timestamps to terminal output
    • Default: True
audio_queue
segment_queue
model
stop_event
lang_a
lang_b
ollama_host
ollama_translation_model
no_speech_threshold
show_timestamps
segment_index: int
def run(self):
291    def run(self):
292        """Blocking transcription + translation loop. Intended to be run in a dedicated thread."""
293        name_a = _LANGUAGE_NAMES.get(self.lang_a, self.lang_a)
294        name_b = _LANGUAGE_NAMES.get(self.lang_b, self.lang_b)
295        initial_prompt = f"Expect only audio in {name_a} or {name_b}. Do not transcribe from other languages. Transcribe only the input audio."
296
297        while True:
298            try:
299                item = self.audio_queue.get(timeout=0.5)
300            except Empty:
301                if self.stop_event.is_set():
302                    break
303                continue
304
305            if item is None:
306                break
307
308            audio, start_time, end_time = item
309
310            segments, _ = self.model.transcribe(
311                audio,
312                initial_prompt=initial_prompt,
313            )
314
315            words = []
316            for seg in segments:
317                if seg.no_speech_prob > self.no_speech_threshold:
318                    continue
319                words.append(seg.text.strip())
320
321            if not words:
322                continue
323
324            text = " ".join(words)
325
326            if self.stop_event.is_set():
327                continue
328
329            result = _detect_and_translate(
330                text=text,
331                lang_a=self.lang_a,
332                lang_b=self.lang_b,
333                host=self.ollama_host,
334                model=self.ollama_translation_model,
335            )
336
337            if result is None:
338                input_language = self.lang_a
339                output_language = self.lang_b
340                translated_text = "[translation unavailable]"
341            else:
342                input_language, output_language, translated_text = result
343
344            self.segment_index += 1
345            segment = TranslationSegment(
346                text=text,
347                translated_text=translated_text,
348                input_language=input_language,
349                output_language=output_language,
350                start_time=start_time,
351                end_time=end_time,
352                index=self.segment_index,
353            )
354
355            if self.show_timestamps:
356                ts = format_timestamp_txt(segment.start_time)
357                src_line = f"{ts}({segment.input_language}) {segment.text}"
358                tgt_line = (
359                    f"{ts}({segment.output_language}) {segment.translated_text}"
360                )
361            else:
362                src_line = f"({segment.input_language}) {segment.text}"
363                tgt_line = (
364                    f"({segment.output_language}) {segment.translated_text}"
365                )
366            print(src_line, flush=True)
367            print(tgt_line, flush=True)
368
369            self.segment_queue.put(segment)

Blocking transcription + translation loop. Intended to be run in a dedicated thread.

Inherited Members
spych.utils.Notify
notify
class PauseableVADRecorder(spych.live.VADRecorder):
377class PauseableVADRecorder(VADRecorder):
378    """
379    VADRecorder that holds off starting a new recording window while TTS is
380    speaking. Before each call to record_vad(), it blocks until speaking_event
381    is cleared, preventing the microphone from picking up the speaker's output.
382    """
383
384    def __init__(self, *args, speaking_event: threading.Event, **kwargs):
385        super().__init__(*args, **kwargs)
386        self.speaking_event = speaking_event
387
388    def run(self, session_start_time: float):
389        _TTS_TIMEOUT_S = 30.0
390        # Watchdog: if record_vad hasn't returned in this many seconds, the
391        # PvRecorder.read() call is likely blocked (e.g. PulseAudio suspended
392        # the source). Abandon that cycle and start fresh.
393        _RECORD_WATCHDOG_S = self.max_speech_duration_s + 15.0
394        # Recycle the PvRecorder every 5 s of silence so the device never
395        # drifts into a stale state between utterances.
396        _INACTIVITY_TIMEOUT_S = 5.0
397        try:
398            while not self.stop_event.is_set():
399                # Block while TTS is playing so the mic doesn't hear the speaker.
400                # Safety timeout: force-clear speaking_event if TTS hangs > 30 s.
401                wait_start = None
402                while (
403                    self.speaking_event.is_set()
404                    and not self.stop_event.is_set()
405                ):
406                    if wait_start is None:
407                        wait_start = time.time()
408                    elif time.time() - wait_start > _TTS_TIMEOUT_S:
409                        self.speaking_event.clear()
410                        break
411                    time.sleep(0.05)
412                if self.stop_event.is_set():
413                    break
414
415                # Combined abort: fires when the session stops OR TTS starts.
416                abort_event = threading.Event()
417
418                def _watch(abort_event: threading.Event = abort_event) -> None:
419                    while (
420                        not self.stop_event.is_set()
421                        and not self.speaking_event.is_set()
422                    ):
423                        time.sleep(0.02)
424                    abort_event.set()
425
426                threading.Thread(target=_watch, daemon=True).start()
427
428                # Run record_vad in a daemon thread. PvRecorder.read() is a
429                # blocking C call — if PulseAudio suspends the audio source the
430                # read never returns and no Python event can unblock it. The
431                # watchdog detects this and abandons the cycle so a fresh
432                # PvRecorder is opened on the next iteration.
433                _result: list = [None]
434                _done = threading.Event()
435
436                def _record(
437                    abort_event: threading.Event = abort_event,
438                    _result: list = _result,
439                    _done: threading.Event = _done,
440                ) -> None:
441                    try:
442                        _result[0] = self.recorder.record_vad(
443                            device_index=self.device_index,
444                            speech_threshold=self.speech_threshold,
445                            silence_threshold=self.silence_threshold,
446                            silence_frames_threshold=self.silence_frames_threshold,
447                            speech_pad_frames=self.speech_pad_frames,
448                            max_speech_duration_s=self.max_speech_duration_s,
449                            inactivity_timeout=_INACTIVITY_TIMEOUT_S,
450                            stop_event=abort_event,
451                        )
452                    except Exception:
453                        _result[0] = []
454                    finally:
455                        _done.set()
456
457                start_wall = time.time()
458                threading.Thread(target=_record, daemon=True).start()
459
460                completed = _done.wait(timeout=_RECORD_WATCHDOG_S)
461                if not completed:
462                    # PvRecorder.read() is hung — abandon and let the daemon
463                    # thread die at process exit. Signal the watcher to stop.
464                    abort_event.set()
465                    continue
466
467                frames = _result[0] or []
468
469                if self.stop_event.is_set():
470                    break
471                # TTS fired during recording or no speech — discard.
472                if self.speaking_event.is_set() or not frames:
473                    continue
474                end_wall = time.time()
475                start_time = start_wall - session_start_time
476                end_time = end_wall - session_start_time
477                self.flush(frames, start_time, end_time)
478        finally:
479            pass

VADRecorder that holds off starting a new recording window while TTS is speaking. Before each call to record_vad(), it blocks until speaking_event is cleared, preventing the microphone from picking up the speaker's output.

PauseableVADRecorder(*args, speaking_event: threading.Event, **kwargs)
384    def __init__(self, *args, speaking_event: threading.Event, **kwargs):
385        super().__init__(*args, **kwargs)
386        self.speaking_event = speaking_event

Requires:

  • audio_queue:

    • Type: Queue
    • What: Thread-safe queue receiving (audio_array, start_time, end_time) tuples
  • stop_event:

    • Type: threading.Event
    • What: When set, the recording loop exits cleanly after the current frame

Optional:

  • device_index:

    • Type: int
    • What: PvRecorder microphone device index; -1 uses system default
    • Default: -1
  • speech_threshold:

    • Type: float (0.0–1.0)
    • What: Silero probability above which a frame is considered speech onset
    • Default: 0.5
    • Note: Higher values reduce false positives in noisy environments but may miss soft or distant speech
  • silence_threshold:

    • Type: float (0.0–1.0)
    • What: Silero probability below which a frame is considered silence during an active speech segment; lower than speech_threshold to create hysteresis
    • Default: 0.35
    • Note: Must be less than speech_threshold; the gap between the two defines the hysteresis band that prevents rapid on/off toggling
  • silence_frames_threshold:

    • Type: int
    • What: Consecutive below-silence-threshold frames required to close a speech segment and flush it to the queue
    • Default: 20 (~640ms at 32ms/frame)
    • Note: Lower values reduce output latency but may split sentences on natural mid-speech pauses; increase for slower or more deliberate speech
  • speech_pad_frames:

    • Type: int
    • What: Frames held in pre-roll before onset confirmation; also the number of consecutive speech frames required to confirm onset
    • Default: 5 (~160ms)
  • max_speech_duration_s:

    • Type: float
    • What: Hard cap on a single speech segment in seconds; forces a flush even if the speaker has not paused, bounding memory growth
    • Default: 30.0
speaking_event
def run(self, session_start_time: float):
388    def run(self, session_start_time: float):
389        _TTS_TIMEOUT_S = 30.0
390        # Watchdog: if record_vad hasn't returned in this many seconds, the
391        # PvRecorder.read() call is likely blocked (e.g. PulseAudio suspended
392        # the source). Abandon that cycle and start fresh.
393        _RECORD_WATCHDOG_S = self.max_speech_duration_s + 15.0
394        # Recycle the PvRecorder every 5 s of silence so the device never
395        # drifts into a stale state between utterances.
396        _INACTIVITY_TIMEOUT_S = 5.0
397        try:
398            while not self.stop_event.is_set():
399                # Block while TTS is playing so the mic doesn't hear the speaker.
400                # Safety timeout: force-clear speaking_event if TTS hangs > 30 s.
401                wait_start = None
402                while (
403                    self.speaking_event.is_set()
404                    and not self.stop_event.is_set()
405                ):
406                    if wait_start is None:
407                        wait_start = time.time()
408                    elif time.time() - wait_start > _TTS_TIMEOUT_S:
409                        self.speaking_event.clear()
410                        break
411                    time.sleep(0.05)
412                if self.stop_event.is_set():
413                    break
414
415                # Combined abort: fires when the session stops OR TTS starts.
416                abort_event = threading.Event()
417
418                def _watch(abort_event: threading.Event = abort_event) -> None:
419                    while (
420                        not self.stop_event.is_set()
421                        and not self.speaking_event.is_set()
422                    ):
423                        time.sleep(0.02)
424                    abort_event.set()
425
426                threading.Thread(target=_watch, daemon=True).start()
427
428                # Run record_vad in a daemon thread. PvRecorder.read() is a
429                # blocking C call — if PulseAudio suspends the audio source the
430                # read never returns and no Python event can unblock it. The
431                # watchdog detects this and abandons the cycle so a fresh
432                # PvRecorder is opened on the next iteration.
433                _result: list = [None]
434                _done = threading.Event()
435
436                def _record(
437                    abort_event: threading.Event = abort_event,
438                    _result: list = _result,
439                    _done: threading.Event = _done,
440                ) -> None:
441                    try:
442                        _result[0] = self.recorder.record_vad(
443                            device_index=self.device_index,
444                            speech_threshold=self.speech_threshold,
445                            silence_threshold=self.silence_threshold,
446                            silence_frames_threshold=self.silence_frames_threshold,
447                            speech_pad_frames=self.speech_pad_frames,
448                            max_speech_duration_s=self.max_speech_duration_s,
449                            inactivity_timeout=_INACTIVITY_TIMEOUT_S,
450                            stop_event=abort_event,
451                        )
452                    except Exception:
453                        _result[0] = []
454                    finally:
455                        _done.set()
456
457                start_wall = time.time()
458                threading.Thread(target=_record, daemon=True).start()
459
460                completed = _done.wait(timeout=_RECORD_WATCHDOG_S)
461                if not completed:
462                    # PvRecorder.read() is hung — abandon and let the daemon
463                    # thread die at process exit. Signal the watcher to stop.
464                    abort_event.set()
465                    continue
466
467                frames = _result[0] or []
468
469                if self.stop_event.is_set():
470                    break
471                # TTS fired during recording or no speech — discard.
472                if self.speaking_event.is_set() or not frames:
473                    continue
474                end_wall = time.time()
475                start_time = start_wall - session_start_time
476                end_time = end_wall - session_start_time
477                self.flush(frames, start_time, end_time)
478        finally:
479            pass

Blocking recording loop. Intended to be run inside a dedicated thread.

Requires:

  • session_start_time:
    • Type: float
    • What: Unix timestamp of session start, used to compute relative segment timestamps

Notes:

  • record_vad() from utils handles a single complete utterance capture. This loop calls it repeatedly, tracking session-relative timestamps and checking stop_event between utterances so the session can be cleanly terminated at any utterance boundary.
  • Silero model loading and frame inference are fully encapsulated in record_vad(); this method only handles orchestration.
class TranslationWriter(spych.utils.Notify):
487class TranslationWriter(Notify):
488    """
489    Consumes TranslationSegment objects from segment_queue and writes bilingual
490    output to disk and the terminal.
491
492    Each segment produces two lines: one for the source language and one for
493    the target language, each prefixed with a timestamp and language hint.
494
495    Optionally speaks the translated text via Speaker.
496    """
497
498    def __init__(
499        self,
500        segment_queue: Queue,
501        stop_event: threading.Event,
502        lang_a: str,
503        lang_b: str,
504        speaking_event: threading.Event,
505        output_format: str = "",
506        output_path: str = "transcript",
507        show_timestamps: bool = True,
508        use_speaker: bool = True,
509        speaker_voice: str = "",
510    ):
511        """
512        Requires:
513
514        - `segment_queue`: Queue of TranslationSegment objects from TranslatingTranscriber
515        - `stop_event`: Shared stop signal
516        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
517        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
518        - `speaking_event`: Shared event set while TTS is playing; signals PauseableVADRecorder to hold off
519
520        Optional:
521
522        - `output_format`:
523            - Type: str
524            - What: Output format(s) to write; empty string disables file output
525            - Default: "" (no file output)
526            - Options: "txt", "srt", "both"
527
528        - `output_path`:
529            - Type: str
530            - What: Base file path (without extension)
531            - Default: "transcript"
532
533        - `show_timestamps`:
534            - Type: bool
535            - What: If True, prepends relative timestamps to terminal and TXT output
536            - Default: True
537
538        - `use_speaker`:
539            - Type: bool
540            - What: If True, speaks the translated text via TTS after each segment
541            - Default: True
542
543        - `speaker_voice`:
544            - Type: str
545            - What: Wave voice name for zero-shot cloning; empty string uses the
546              model's built-in default voice
547            - Default: ""
548        """
549        self.segment_queue = segment_queue
550        self.stop_event = stop_event
551        self.lang_a = lang_a
552        self.lang_b = lang_b
553        self.speaking_event = speaking_event
554        self.output_format = output_format
555        self.output_path = output_path
556        self.show_timestamps = show_timestamps
557        self.use_speaker = use_speaker
558        self.speaker_voice = speaker_voice
559        self.txt_file = None
560        self.srt_file = None
561        self.speakers: dict[str, object] = {}
562
563    def run(self):
564        """Blocking writer loop. Intended to be run in a dedicated thread."""
565        if self.use_speaker:
566            from spych.speaker.speaker import Speaker
567
568            for lang_id in (self.lang_a, self.lang_b):
569                try:
570                    self.speakers[lang_id] = Speaker(
571                        voice=self.speaker_voice,
572                        backend="chatterbox_multilingual",
573                        language_id=lang_id,
574                    )
575                except Exception as e:
576                    print(
577                        f"[spych] TTS for {lang_id} unavailable, "
578                        f"continuing without speaker for that language: {e}",
579                        flush=True,
580                    )
581
582        try:
583            if self.output_format and self.output_format in ("txt", "both"):
584                self.txt_file = open(
585                    f"{self.output_path}.txt", "w", encoding="utf-8"
586                )
587            if self.output_format and self.output_format in ("srt", "both"):
588                self.srt_file = open(
589                    f"{self.output_path}.srt", "w", encoding="utf-8"
590                )
591
592            while True:
593                try:
594                    segment = self.segment_queue.get(timeout=0.5)
595                except Empty:
596                    if self.stop_event.is_set():
597                        break
598                    continue
599
600                if segment is None:
601                    break
602
603                self.write_segment(segment)
604
605        finally:
606            for speaker in self.speakers.values():
607                speaker.interrupt()
608                speaker.wait_for_speak()
609            if self.txt_file:
610                self.txt_file.flush()
611                self.txt_file.close()
612            if self.srt_file:
613                self.srt_file.flush()
614                self.srt_file.close()
615            if self.speakers:
616                import pygame
617
618                try:
619                    pygame.mixer.quit()
620                except Exception:
621                    pass
622
623    def write_segment(self, segment: TranslationSegment):
624        """Write one bilingual segment to file outputs and queue TTS."""
625        if self.txt_file:
626            if self.show_timestamps:
627                ts = format_timestamp_txt(segment.start_time)
628                src_line = f"{ts}({segment.input_language}) {segment.text}"
629                tgt_line = (
630                    f"{ts}({segment.output_language}) {segment.translated_text}"
631                )
632            else:
633                src_line = f"({segment.input_language}) {segment.text}"
634                tgt_line = (
635                    f"({segment.output_language}) {segment.translated_text}"
636                )
637            self.txt_file.write(src_line + "\n")
638            self.txt_file.write(tgt_line + "\n")
639            self.txt_file.flush()
640
641        if self.srt_file:
642            srt_block = (
643                f"{segment.index}\n"
644                f"{format_timestamp_srt(segment.start_time)} --> "
645                f"{format_timestamp_srt(segment.end_time)}\n"
646                f"[{segment.input_language}] {segment.text}\n"
647                f"[{segment.output_language}] {segment.translated_text}\n\n"
648            )
649            self.srt_file.write(srt_block)
650            self.srt_file.flush()
651
652        speaker = self.speakers.get(segment.output_language)
653        if speaker and segment.translated_text != "[translation unavailable]":
654            if self.stop_event.is_set():
655                return
656            # Serialize TTS: both speakers share pygame.mixer.music, so we must
657            # wait for any in-progress playback to finish before starting the next.
658            # Poll with stop_event so Ctrl+C can interrupt this wait.
659            for s in self.speakers.values():
660                while s.is_speaking() and not self.stop_event.is_set():
661                    time.sleep(0.05)
662                if self.stop_event.is_set():
663                    s.interrupt()
664            if self.stop_event.is_set():
665                return
666            self.speaking_event.set()
667
668            def _on_complete():
669                self.speaking_event.clear()
670
671            speaker.speak_async(
672                segment.translated_text, on_complete=_on_complete
673            )

Consumes TranslationSegment objects from segment_queue and writes bilingual output to disk and the terminal.

Each segment produces two lines: one for the source language and one for the target language, each prefixed with a timestamp and language hint.

Optionally speaks the translated text via Speaker.

TranslationWriter( segment_queue: queue.Queue, stop_event: threading.Event, lang_a: str, lang_b: str, speaking_event: threading.Event, output_format: str = '', output_path: str = 'transcript', show_timestamps: bool = True, use_speaker: bool = True, speaker_voice: str = '')
498    def __init__(
499        self,
500        segment_queue: Queue,
501        stop_event: threading.Event,
502        lang_a: str,
503        lang_b: str,
504        speaking_event: threading.Event,
505        output_format: str = "",
506        output_path: str = "transcript",
507        show_timestamps: bool = True,
508        use_speaker: bool = True,
509        speaker_voice: str = "",
510    ):
511        """
512        Requires:
513
514        - `segment_queue`: Queue of TranslationSegment objects from TranslatingTranscriber
515        - `stop_event`: Shared stop signal
516        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
517        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
518        - `speaking_event`: Shared event set while TTS is playing; signals PauseableVADRecorder to hold off
519
520        Optional:
521
522        - `output_format`:
523            - Type: str
524            - What: Output format(s) to write; empty string disables file output
525            - Default: "" (no file output)
526            - Options: "txt", "srt", "both"
527
528        - `output_path`:
529            - Type: str
530            - What: Base file path (without extension)
531            - Default: "transcript"
532
533        - `show_timestamps`:
534            - Type: bool
535            - What: If True, prepends relative timestamps to terminal and TXT output
536            - Default: True
537
538        - `use_speaker`:
539            - Type: bool
540            - What: If True, speaks the translated text via TTS after each segment
541            - Default: True
542
543        - `speaker_voice`:
544            - Type: str
545            - What: Wave voice name for zero-shot cloning; empty string uses the
546              model's built-in default voice
547            - Default: ""
548        """
549        self.segment_queue = segment_queue
550        self.stop_event = stop_event
551        self.lang_a = lang_a
552        self.lang_b = lang_b
553        self.speaking_event = speaking_event
554        self.output_format = output_format
555        self.output_path = output_path
556        self.show_timestamps = show_timestamps
557        self.use_speaker = use_speaker
558        self.speaker_voice = speaker_voice
559        self.txt_file = None
560        self.srt_file = None
561        self.speakers: dict[str, object] = {}

Requires:

  • segment_queue: Queue of TranslationSegment objects from TranslatingTranscriber
  • stop_event: Shared stop signal
  • lang_a: BCP-47 code of the first language in the pair (e.g. "en")
  • lang_b: BCP-47 code of the second language in the pair (e.g. "es")
  • speaking_event: Shared event set while TTS is playing; signals PauseableVADRecorder to hold off

Optional:

  • output_format:

    • Type: str
    • What: Output format(s) to write; empty string disables file output
    • Default: "" (no file output)
    • Options: "txt", "srt", "both"
  • output_path:

    • Type: str
    • What: Base file path (without extension)
    • Default: "transcript"
  • show_timestamps:

    • Type: bool
    • What: If True, prepends relative timestamps to terminal and TXT output
    • Default: True
  • use_speaker:

    • Type: bool
    • What: If True, speaks the translated text via TTS after each segment
    • Default: True
  • speaker_voice:

    • Type: str
    • What: Wave voice name for zero-shot cloning; empty string uses the model's built-in default voice
    • Default: ""
segment_queue
stop_event
lang_a
lang_b
speaking_event
output_format
output_path
show_timestamps
use_speaker
speaker_voice
txt_file
srt_file
speakers: dict[str, object]
def run(self):
563    def run(self):
564        """Blocking writer loop. Intended to be run in a dedicated thread."""
565        if self.use_speaker:
566            from spych.speaker.speaker import Speaker
567
568            for lang_id in (self.lang_a, self.lang_b):
569                try:
570                    self.speakers[lang_id] = Speaker(
571                        voice=self.speaker_voice,
572                        backend="chatterbox_multilingual",
573                        language_id=lang_id,
574                    )
575                except Exception as e:
576                    print(
577                        f"[spych] TTS for {lang_id} unavailable, "
578                        f"continuing without speaker for that language: {e}",
579                        flush=True,
580                    )
581
582        try:
583            if self.output_format and self.output_format in ("txt", "both"):
584                self.txt_file = open(
585                    f"{self.output_path}.txt", "w", encoding="utf-8"
586                )
587            if self.output_format and self.output_format in ("srt", "both"):
588                self.srt_file = open(
589                    f"{self.output_path}.srt", "w", encoding="utf-8"
590                )
591
592            while True:
593                try:
594                    segment = self.segment_queue.get(timeout=0.5)
595                except Empty:
596                    if self.stop_event.is_set():
597                        break
598                    continue
599
600                if segment is None:
601                    break
602
603                self.write_segment(segment)
604
605        finally:
606            for speaker in self.speakers.values():
607                speaker.interrupt()
608                speaker.wait_for_speak()
609            if self.txt_file:
610                self.txt_file.flush()
611                self.txt_file.close()
612            if self.srt_file:
613                self.srt_file.flush()
614                self.srt_file.close()
615            if self.speakers:
616                import pygame
617
618                try:
619                    pygame.mixer.quit()
620                except Exception:
621                    pass

Blocking writer loop. Intended to be run in a dedicated thread.

def write_segment(self, segment: TranslationSegment):
623    def write_segment(self, segment: TranslationSegment):
624        """Write one bilingual segment to file outputs and queue TTS."""
625        if self.txt_file:
626            if self.show_timestamps:
627                ts = format_timestamp_txt(segment.start_time)
628                src_line = f"{ts}({segment.input_language}) {segment.text}"
629                tgt_line = (
630                    f"{ts}({segment.output_language}) {segment.translated_text}"
631                )
632            else:
633                src_line = f"({segment.input_language}) {segment.text}"
634                tgt_line = (
635                    f"({segment.output_language}) {segment.translated_text}"
636                )
637            self.txt_file.write(src_line + "\n")
638            self.txt_file.write(tgt_line + "\n")
639            self.txt_file.flush()
640
641        if self.srt_file:
642            srt_block = (
643                f"{segment.index}\n"
644                f"{format_timestamp_srt(segment.start_time)} --> "
645                f"{format_timestamp_srt(segment.end_time)}\n"
646                f"[{segment.input_language}] {segment.text}\n"
647                f"[{segment.output_language}] {segment.translated_text}\n\n"
648            )
649            self.srt_file.write(srt_block)
650            self.srt_file.flush()
651
652        speaker = self.speakers.get(segment.output_language)
653        if speaker and segment.translated_text != "[translation unavailable]":
654            if self.stop_event.is_set():
655                return
656            # Serialize TTS: both speakers share pygame.mixer.music, so we must
657            # wait for any in-progress playback to finish before starting the next.
658            # Poll with stop_event so Ctrl+C can interrupt this wait.
659            for s in self.speakers.values():
660                while s.is_speaking() and not self.stop_event.is_set():
661                    time.sleep(0.05)
662                if self.stop_event.is_set():
663                    s.interrupt()
664            if self.stop_event.is_set():
665                return
666            self.speaking_event.set()
667
668            def _on_complete():
669                self.speaking_event.clear()
670
671            speaker.speak_async(
672                segment.translated_text, on_complete=_on_complete
673            )

Write one bilingual segment to file outputs and queue TTS.

Inherited Members
spych.utils.Notify
notify
class SpychLiveTranslation(spych.utils.Notify):
 681class SpychLiveTranslation(Notify):
 682    def __init__(
 683        self,
 684        lang_a: str,
 685        lang_b: str,
 686        output_format: str = "",
 687        output_path: str = "transcript",
 688        show_timestamps: bool = True,
 689        stop_key: str = "q",
 690        terminate_words: Optional[list[str]] = None,
 691        device_index: int = -1,
 692        whisper_model: str = "small",
 693        whisper_device: str = "auto",
 694        whisper_compute_type: str = "int8",
 695        no_speech_threshold: float = 0.4,
 696        speech_threshold: float = 0.5,
 697        silence_threshold: float = 0.35,
 698        silence_frames_threshold: int = 20,
 699        speech_pad_frames: int = 5,
 700        max_speech_duration_s: float = 30.0,
 701        ollama_host: str = "http://localhost:11434",
 702        ollama_translation_model: str = "llama3.2",
 703        use_speaker: bool = True,
 704        speaker_voice: str = "",
 705    ):
 706        """
 707        Usage:
 708
 709        - Initializes a bidirectional live translation session. Either participant
 710          may speak in either language; Whisper transcribes and Ollama detects
 711          which language was spoken then translates to the other.
 712        - Runs continuously until stopped by keystroke, terminate word, or Ctrl+C.
 713
 714        Requires:
 715
 716        - `lang_a`:
 717            - Type: str
 718            - What: BCP-47 code of the first language in the pair (e.g. "en")
 719
 720        - `lang_b`:
 721            - Type: str
 722            - What: BCP-47 code of the second language in the pair (e.g. "es")
 723
 724        Optional:
 725
 726        - `output_format`:
 727            - Type: str
 728            - What: Output format(s) to write; empty string disables file output
 729            - Default: "" (no file output)
 730            - Options: "txt", "srt", "both"
 731
 732        - `output_path`:
 733            - Type: str
 734            - What: Base output file path without extension
 735            - Default: "transcript"
 736
 737        - `show_timestamps`:
 738            - Type: bool
 739            - What: If True, prepends relative [HH:MM:SS] timestamps to each line
 740            - Default: True
 741
 742        - `stop_key`:
 743            - Type: str
 744            - What: The key (followed by Enter) the user types to stop recording
 745            - Default: "q"
 746
 747        - `terminate_words`:
 748            - Type: list[str] | None
 749            - What: Words that, if detected in the transcript, immediately stop the session
 750            - Default: None
 751
 752        - `device_index`:
 753            - Type: int
 754            - What: Microphone device index; -1 uses the system default
 755            - Default: -1
 756
 757        - `whisper_model`:
 758            - Type: str
 759            - What: faster-whisper model name; `.en` suffix is stripped automatically
 760              when either language is not English
 761            - Default: "small"
 762
 763        - `whisper_device`:
 764            - Type: str
 765            - What: Device for whisper inference
 766            - Default: "auto"
 767            - Options: "auto", "cpu", "cuda"
 768            - Note: "auto" selects "cuda" when Python <=3.13 and a CUDA device is
 769              available, otherwise falls back to "cpu". "cuda" requires
 770              nvidia-cublas-cu12 and nvidia-cudnn-cu12 (pip).
 771
 772        - `whisper_compute_type`:
 773            - Type: str
 774            - What: Compute precision for the whisper model
 775            - Default: "int8"
 776            - Options: "int8", "float16", "float32"
 777
 778        - `no_speech_threshold`:
 779            - Type: float
 780            - What: Whisper segments with no_speech_prob above this are discarded
 781            - Default: 0.4
 782
 783        - `speech_threshold`:
 784            - Type: float (0.0–1.0)
 785            - What: Silero probability above which a frame is considered speech onset
 786            - Default: 0.5
 787
 788        - `silence_threshold`:
 789            - Type: float (0.0–1.0)
 790            - What: Silero probability below which a frame is considered silence
 791            - Default: 0.35
 792
 793        - `silence_frames_threshold`:
 794            - Type: int
 795            - What: Consecutive silent frames required to close a speech segment
 796            - Default: 20
 797
 798        - `speech_pad_frames`:
 799            - Type: int
 800            - What: Pre-roll frames and onset confirmation count
 801            - Default: 5
 802
 803        - `max_speech_duration_s`:
 804            - Type: float
 805            - What: Hard cap on a single speech segment in seconds
 806            - Default: 30.0
 807
 808        - `ollama_host`:
 809            - Type: str
 810            - What: Ollama HTTP base URL for translation requests
 811            - Default: "http://localhost:11434"
 812
 813        - `ollama_translation_model`:
 814            - Type: str
 815            - What: Ollama model name used for translation
 816            - Default: "llama3.2"
 817
 818        - `use_speaker`:
 819            - Type: bool
 820            - What: If True, speaks each translated segment aloud via TTS
 821            - Default: True
 822
 823        - `speaker_voice`:
 824            - Type: str
 825            - What: Wave voice name for zero-shot cloning; empty string uses the
 826              model's built-in default voice
 827            - Default: ""
 828        """
 829        self.lang_a = lang_a
 830        self.lang_b = lang_b
 831        self.output_format = output_format
 832        self.output_path = output_path
 833        self.show_timestamps = show_timestamps
 834        self.stop_key = stop_key
 835        self.terminate_words = (
 836            [w.lower() for w in terminate_words] if terminate_words else []
 837        )
 838        self.device_index = device_index
 839        self.no_speech_threshold = no_speech_threshold
 840        self.speech_threshold = speech_threshold
 841        self.silence_threshold = silence_threshold
 842        self.silence_frames_threshold = silence_frames_threshold
 843        self.speech_pad_frames = speech_pad_frames
 844        self.max_speech_duration_s = max_speech_duration_s
 845        self.ollama_host = ollama_host
 846        self.ollama_translation_model = ollama_translation_model
 847        self.use_speaker = use_speaker
 848        self.speaker_voice = speaker_voice
 849
 850        resolved_model = _select_whisper_model(whisper_model, lang_a, lang_b)
 851        self.model = load_whisper_model(
 852            resolved_model,
 853            device=resolve_whisper_device(whisper_device),
 854            compute_type=whisper_compute_type,
 855        )
 856
 857        self.stop_event = threading.Event()
 858        self.speaking_event = threading.Event()
 859        self.audio_queue: Queue = Queue()
 860        self.segment_queue: Queue = Queue()
 861
 862    def start(self):
 863        """
 864        Usage:
 865
 866        - Starts the live transcription + translation session and blocks until
 867          the user stops it via the configured stop key or a terminate word
 868        - Prints a startup message indicating how to stop the session
 869
 870        Notes:
 871
 872        - Thread startup order: keystroke listener → recorder → transcriber → writer
 873        - SIGINT (Ctrl+C) is caught and redirected to the same graceful stop path
 874        """
 875        original_sigint = signal.getsignal(signal.SIGINT)
 876
 877        def handle_sigint(sig, frame):
 878            print(
 879                "\n[spych] Interrupt received. "
 880                "Finishing current segment and shutting down...",
 881                flush=True,
 882            )
 883            self.stop_event.set()
 884            signal.signal(signal.SIGINT, original_sigint)
 885
 886        signal.signal(signal.SIGINT, handle_sigint)
 887
 888        stop_instructions = [f"Press '{self.stop_key}' + Enter"]
 889        if self.terminate_words:
 890            words_display = ", ".join(f'"{w}"' for w in self.terminate_words)
 891            stop_instructions.append(f"say {words_display}")
 892        print(
 893            f"[spych] Live translation started "
 894            f"({self.lang_a}{self.lang_b}). "
 895            f"To stop: {' or '.join(stop_instructions)}.",
 896            flush=True,
 897        )
 898
 899        ks_listener = KeystrokeListener(self.stop_event, self.stop_key)
 900        ks_thread = threading.Thread(target=ks_listener.run, daemon=True)
 901        ks_thread.start()
 902
 903        session_start = time.time()
 904
 905        recorder = PauseableVADRecorder(
 906            audio_queue=self.audio_queue,
 907            stop_event=self.stop_event,
 908            device_index=self.device_index,
 909            speech_threshold=self.speech_threshold,
 910            silence_threshold=self.silence_threshold,
 911            silence_frames_threshold=self.silence_frames_threshold,
 912            speech_pad_frames=self.speech_pad_frames,
 913            max_speech_duration_s=self.max_speech_duration_s,
 914            speaking_event=self.speaking_event,
 915        )
 916        rec_thread = threading.Thread(
 917            target=recorder.run, args=(session_start,), daemon=False
 918        )
 919
 920        transcriber = TranslatingTranscriber(
 921            audio_queue=self.audio_queue,
 922            segment_queue=self.segment_queue,
 923            model=self.model,
 924            stop_event=self.stop_event,
 925            lang_a=self.lang_a,
 926            lang_b=self.lang_b,
 927            ollama_host=self.ollama_host,
 928            ollama_translation_model=self.ollama_translation_model,
 929            no_speech_threshold=self.no_speech_threshold,
 930            show_timestamps=self.show_timestamps,
 931        )
 932        trans_thread = threading.Thread(
 933            target=self.transcribe_and_check,
 934            args=(transcriber,),
 935            daemon=False,
 936        )
 937
 938        writer = TranslationWriter(
 939            segment_queue=self.segment_queue,
 940            stop_event=self.stop_event,
 941            lang_a=self.lang_a,
 942            lang_b=self.lang_b,
 943            speaking_event=self.speaking_event,
 944            output_format=self.output_format,
 945            output_path=self.output_path,
 946            show_timestamps=self.show_timestamps,
 947            use_speaker=self.use_speaker,
 948            speaker_voice=self.speaker_voice,
 949        )
 950        write_thread = threading.Thread(target=writer.run, daemon=False)
 951
 952        write_thread.start()
 953        trans_thread.start()
 954        rec_thread.start()
 955
 956        rec_thread.join()
 957
 958        self.audio_queue.put(None)
 959        trans_thread.join()
 960
 961        self.segment_queue.put(None)
 962        write_thread.join()
 963
 964        signal.signal(signal.SIGINT, original_sigint)
 965        if self.output_format:
 966            print(
 967                f"[spych] Session complete. Output saved to: {self.output_path}.*",
 968                flush=True,
 969            )
 970        else:
 971            print("[spych] Session complete.", flush=True)
 972
 973    def transcribe_and_check(self, transcriber: TranslatingTranscriber):
 974        """
 975        Runs transcriber.run() and intercepts every segment put onto segment_queue
 976        to check for terminate words.
 977        """
 978        original_put = self.segment_queue.put
 979
 980        def checked_put(segment):
 981            original_put(segment)
 982            if not self.terminate_words or not isinstance(
 983                segment, TranslationSegment
 984            ):
 985                return
 986            text_lower = segment.text.lower()
 987            for word in self.terminate_words:
 988                if word in text_lower:
 989                    print(
 990                        f'\n[spych] Terminate word "{word}" detected. '
 991                        "Finishing and shutting down...",
 992                        flush=True,
 993                    )
 994                    self.stop_event.set()
 995                    return
 996
 997        self.segment_queue.put = checked_put
 998        try:
 999            transcriber.run()
1000        finally:
1001            self.segment_queue.put = original_put
SpychLiveTranslation( lang_a: str, lang_b: str, output_format: str = '', output_path: str = 'transcript', show_timestamps: bool = True, stop_key: str = 'q', terminate_words: list[str] | None = None, device_index: int = -1, whisper_model: str = 'small', whisper_device: str = 'auto', whisper_compute_type: str = 'int8', no_speech_threshold: float = 0.4, speech_threshold: float = 0.5, silence_threshold: float = 0.35, silence_frames_threshold: int = 20, speech_pad_frames: int = 5, max_speech_duration_s: float = 30.0, ollama_host: str = 'http://localhost:11434', ollama_translation_model: str = 'llama3.2', use_speaker: bool = True, speaker_voice: str = '')
682    def __init__(
683        self,
684        lang_a: str,
685        lang_b: str,
686        output_format: str = "",
687        output_path: str = "transcript",
688        show_timestamps: bool = True,
689        stop_key: str = "q",
690        terminate_words: Optional[list[str]] = None,
691        device_index: int = -1,
692        whisper_model: str = "small",
693        whisper_device: str = "auto",
694        whisper_compute_type: str = "int8",
695        no_speech_threshold: float = 0.4,
696        speech_threshold: float = 0.5,
697        silence_threshold: float = 0.35,
698        silence_frames_threshold: int = 20,
699        speech_pad_frames: int = 5,
700        max_speech_duration_s: float = 30.0,
701        ollama_host: str = "http://localhost:11434",
702        ollama_translation_model: str = "llama3.2",
703        use_speaker: bool = True,
704        speaker_voice: str = "",
705    ):
706        """
707        Usage:
708
709        - Initializes a bidirectional live translation session. Either participant
710          may speak in either language; Whisper transcribes and Ollama detects
711          which language was spoken then translates to the other.
712        - Runs continuously until stopped by keystroke, terminate word, or Ctrl+C.
713
714        Requires:
715
716        - `lang_a`:
717            - Type: str
718            - What: BCP-47 code of the first language in the pair (e.g. "en")
719
720        - `lang_b`:
721            - Type: str
722            - What: BCP-47 code of the second language in the pair (e.g. "es")
723
724        Optional:
725
726        - `output_format`:
727            - Type: str
728            - What: Output format(s) to write; empty string disables file output
729            - Default: "" (no file output)
730            - Options: "txt", "srt", "both"
731
732        - `output_path`:
733            - Type: str
734            - What: Base output file path without extension
735            - Default: "transcript"
736
737        - `show_timestamps`:
738            - Type: bool
739            - What: If True, prepends relative [HH:MM:SS] timestamps to each line
740            - Default: True
741
742        - `stop_key`:
743            - Type: str
744            - What: The key (followed by Enter) the user types to stop recording
745            - Default: "q"
746
747        - `terminate_words`:
748            - Type: list[str] | None
749            - What: Words that, if detected in the transcript, immediately stop the session
750            - Default: None
751
752        - `device_index`:
753            - Type: int
754            - What: Microphone device index; -1 uses the system default
755            - Default: -1
756
757        - `whisper_model`:
758            - Type: str
759            - What: faster-whisper model name; `.en` suffix is stripped automatically
760              when either language is not English
761            - Default: "small"
762
763        - `whisper_device`:
764            - Type: str
765            - What: Device for whisper inference
766            - Default: "auto"
767            - Options: "auto", "cpu", "cuda"
768            - Note: "auto" selects "cuda" when Python <=3.13 and a CUDA device is
769              available, otherwise falls back to "cpu". "cuda" requires
770              nvidia-cublas-cu12 and nvidia-cudnn-cu12 (pip).
771
772        - `whisper_compute_type`:
773            - Type: str
774            - What: Compute precision for the whisper model
775            - Default: "int8"
776            - Options: "int8", "float16", "float32"
777
778        - `no_speech_threshold`:
779            - Type: float
780            - What: Whisper segments with no_speech_prob above this are discarded
781            - Default: 0.4
782
783        - `speech_threshold`:
784            - Type: float (0.0–1.0)
785            - What: Silero probability above which a frame is considered speech onset
786            - Default: 0.5
787
788        - `silence_threshold`:
789            - Type: float (0.0–1.0)
790            - What: Silero probability below which a frame is considered silence
791            - Default: 0.35
792
793        - `silence_frames_threshold`:
794            - Type: int
795            - What: Consecutive silent frames required to close a speech segment
796            - Default: 20
797
798        - `speech_pad_frames`:
799            - Type: int
800            - What: Pre-roll frames and onset confirmation count
801            - Default: 5
802
803        - `max_speech_duration_s`:
804            - Type: float
805            - What: Hard cap on a single speech segment in seconds
806            - Default: 30.0
807
808        - `ollama_host`:
809            - Type: str
810            - What: Ollama HTTP base URL for translation requests
811            - Default: "http://localhost:11434"
812
813        - `ollama_translation_model`:
814            - Type: str
815            - What: Ollama model name used for translation
816            - Default: "llama3.2"
817
818        - `use_speaker`:
819            - Type: bool
820            - What: If True, speaks each translated segment aloud via TTS
821            - Default: True
822
823        - `speaker_voice`:
824            - Type: str
825            - What: Wave voice name for zero-shot cloning; empty string uses the
826              model's built-in default voice
827            - Default: ""
828        """
829        self.lang_a = lang_a
830        self.lang_b = lang_b
831        self.output_format = output_format
832        self.output_path = output_path
833        self.show_timestamps = show_timestamps
834        self.stop_key = stop_key
835        self.terminate_words = (
836            [w.lower() for w in terminate_words] if terminate_words else []
837        )
838        self.device_index = device_index
839        self.no_speech_threshold = no_speech_threshold
840        self.speech_threshold = speech_threshold
841        self.silence_threshold = silence_threshold
842        self.silence_frames_threshold = silence_frames_threshold
843        self.speech_pad_frames = speech_pad_frames
844        self.max_speech_duration_s = max_speech_duration_s
845        self.ollama_host = ollama_host
846        self.ollama_translation_model = ollama_translation_model
847        self.use_speaker = use_speaker
848        self.speaker_voice = speaker_voice
849
850        resolved_model = _select_whisper_model(whisper_model, lang_a, lang_b)
851        self.model = load_whisper_model(
852            resolved_model,
853            device=resolve_whisper_device(whisper_device),
854            compute_type=whisper_compute_type,
855        )
856
857        self.stop_event = threading.Event()
858        self.speaking_event = threading.Event()
859        self.audio_queue: Queue = Queue()
860        self.segment_queue: Queue = Queue()

Usage:

  • Initializes a bidirectional live translation session. Either participant may speak in either language; Whisper transcribes and Ollama detects which language was spoken then translates to the other.
  • Runs continuously until stopped by keystroke, terminate word, or Ctrl+C.

Requires:

  • lang_a:

    • Type: str
    • What: BCP-47 code of the first language in the pair (e.g. "en")
  • lang_b:

    • Type: str
    • What: BCP-47 code of the second language in the pair (e.g. "es")

Optional:

  • output_format:

    • Type: str
    • What: Output format(s) to write; empty string disables file output
    • Default: "" (no file output)
    • Options: "txt", "srt", "both"
  • output_path:

    • Type: str
    • What: Base output file path without extension
    • Default: "transcript"
  • show_timestamps:

    • Type: bool
    • What: If True, prepends relative [HH:MM:SS] timestamps to each line
    • Default: True
  • stop_key:

    • Type: str
    • What: The key (followed by Enter) the user types to stop recording
    • Default: "q"
  • terminate_words:

    • Type: list[str] | None
    • What: Words that, if detected in the transcript, immediately stop the session
    • Default: None
  • device_index:

    • Type: int
    • What: Microphone device index; -1 uses the system default
    • Default: -1
  • whisper_model:

    • Type: str
    • What: faster-whisper model name; .en suffix is stripped automatically when either language is not English
    • Default: "small"
  • whisper_device:

    • Type: str
    • What: Device for whisper inference
    • Default: "auto"
    • Options: "auto", "cpu", "cuda"
    • Note: "auto" selects "cuda" when Python <=3.13 and a CUDA device is available, otherwise falls back to "cpu". "cuda" requires nvidia-cublas-cu12 and nvidia-cudnn-cu12 (pip).
  • whisper_compute_type:

    • Type: str
    • What: Compute precision for the whisper model
    • Default: "int8"
    • Options: "int8", "float16", "float32"
  • no_speech_threshold:

    • Type: float
    • What: Whisper segments with no_speech_prob above this are discarded
    • Default: 0.4
  • speech_threshold:

    • Type: float (0.0–1.0)
    • What: Silero probability above which a frame is considered speech onset
    • Default: 0.5
  • silence_threshold:

    • Type: float (0.0–1.0)
    • What: Silero probability below which a frame is considered silence
    • Default: 0.35
  • silence_frames_threshold:

    • Type: int
    • What: Consecutive silent frames required to close a speech segment
    • Default: 20
  • speech_pad_frames:

    • Type: int
    • What: Pre-roll frames and onset confirmation count
    • Default: 5
  • max_speech_duration_s:

    • Type: float
    • What: Hard cap on a single speech segment in seconds
    • Default: 30.0
  • ollama_host:

    • Type: str
    • What: Ollama HTTP base URL for translation requests
    • Default: "http://localhost:11434"
  • ollama_translation_model:

    • Type: str
    • What: Ollama model name used for translation
    • Default: "llama3.2"
  • use_speaker:

    • Type: bool
    • What: If True, speaks each translated segment aloud via TTS
    • Default: True
  • speaker_voice:

    • Type: str
    • What: Wave voice name for zero-shot cloning; empty string uses the model's built-in default voice
    • Default: ""
lang_a
lang_b
output_format
output_path
show_timestamps
stop_key
terminate_words
device_index
no_speech_threshold
speech_threshold
silence_threshold
silence_frames_threshold
speech_pad_frames
max_speech_duration_s
ollama_host
ollama_translation_model
use_speaker
speaker_voice
model
stop_event
speaking_event
audio_queue: queue.Queue
segment_queue: queue.Queue
def start(self):
862    def start(self):
863        """
864        Usage:
865
866        - Starts the live transcription + translation session and blocks until
867          the user stops it via the configured stop key or a terminate word
868        - Prints a startup message indicating how to stop the session
869
870        Notes:
871
872        - Thread startup order: keystroke listener → recorder → transcriber → writer
873        - SIGINT (Ctrl+C) is caught and redirected to the same graceful stop path
874        """
875        original_sigint = signal.getsignal(signal.SIGINT)
876
877        def handle_sigint(sig, frame):
878            print(
879                "\n[spych] Interrupt received. "
880                "Finishing current segment and shutting down...",
881                flush=True,
882            )
883            self.stop_event.set()
884            signal.signal(signal.SIGINT, original_sigint)
885
886        signal.signal(signal.SIGINT, handle_sigint)
887
888        stop_instructions = [f"Press '{self.stop_key}' + Enter"]
889        if self.terminate_words:
890            words_display = ", ".join(f'"{w}"' for w in self.terminate_words)
891            stop_instructions.append(f"say {words_display}")
892        print(
893            f"[spych] Live translation started "
894            f"({self.lang_a}{self.lang_b}). "
895            f"To stop: {' or '.join(stop_instructions)}.",
896            flush=True,
897        )
898
899        ks_listener = KeystrokeListener(self.stop_event, self.stop_key)
900        ks_thread = threading.Thread(target=ks_listener.run, daemon=True)
901        ks_thread.start()
902
903        session_start = time.time()
904
905        recorder = PauseableVADRecorder(
906            audio_queue=self.audio_queue,
907            stop_event=self.stop_event,
908            device_index=self.device_index,
909            speech_threshold=self.speech_threshold,
910            silence_threshold=self.silence_threshold,
911            silence_frames_threshold=self.silence_frames_threshold,
912            speech_pad_frames=self.speech_pad_frames,
913            max_speech_duration_s=self.max_speech_duration_s,
914            speaking_event=self.speaking_event,
915        )
916        rec_thread = threading.Thread(
917            target=recorder.run, args=(session_start,), daemon=False
918        )
919
920        transcriber = TranslatingTranscriber(
921            audio_queue=self.audio_queue,
922            segment_queue=self.segment_queue,
923            model=self.model,
924            stop_event=self.stop_event,
925            lang_a=self.lang_a,
926            lang_b=self.lang_b,
927            ollama_host=self.ollama_host,
928            ollama_translation_model=self.ollama_translation_model,
929            no_speech_threshold=self.no_speech_threshold,
930            show_timestamps=self.show_timestamps,
931        )
932        trans_thread = threading.Thread(
933            target=self.transcribe_and_check,
934            args=(transcriber,),
935            daemon=False,
936        )
937
938        writer = TranslationWriter(
939            segment_queue=self.segment_queue,
940            stop_event=self.stop_event,
941            lang_a=self.lang_a,
942            lang_b=self.lang_b,
943            speaking_event=self.speaking_event,
944            output_format=self.output_format,
945            output_path=self.output_path,
946            show_timestamps=self.show_timestamps,
947            use_speaker=self.use_speaker,
948            speaker_voice=self.speaker_voice,
949        )
950        write_thread = threading.Thread(target=writer.run, daemon=False)
951
952        write_thread.start()
953        trans_thread.start()
954        rec_thread.start()
955
956        rec_thread.join()
957
958        self.audio_queue.put(None)
959        trans_thread.join()
960
961        self.segment_queue.put(None)
962        write_thread.join()
963
964        signal.signal(signal.SIGINT, original_sigint)
965        if self.output_format:
966            print(
967                f"[spych] Session complete. Output saved to: {self.output_path}.*",
968                flush=True,
969            )
970        else:
971            print("[spych] Session complete.", flush=True)

Usage:

  • Starts the live transcription + translation session and blocks until the user stops it via the configured stop key or a terminate word
  • Prints a startup message indicating how to stop the session

Notes:

  • Thread startup order: keystroke listener → recorder → transcriber → writer
  • SIGINT (Ctrl+C) is caught and redirected to the same graceful stop path
def transcribe_and_check(self, transcriber: TranslatingTranscriber):
 973    def transcribe_and_check(self, transcriber: TranslatingTranscriber):
 974        """
 975        Runs transcriber.run() and intercepts every segment put onto segment_queue
 976        to check for terminate words.
 977        """
 978        original_put = self.segment_queue.put
 979
 980        def checked_put(segment):
 981            original_put(segment)
 982            if not self.terminate_words or not isinstance(
 983                segment, TranslationSegment
 984            ):
 985                return
 986            text_lower = segment.text.lower()
 987            for word in self.terminate_words:
 988                if word in text_lower:
 989                    print(
 990                        f'\n[spych] Terminate word "{word}" detected. '
 991                        "Finishing and shutting down...",
 992                        flush=True,
 993                    )
 994                    self.stop_event.set()
 995                    return
 996
 997        self.segment_queue.put = checked_put
 998        try:
 999            transcriber.run()
1000        finally:
1001            self.segment_queue.put = original_put

Runs transcriber.run() and intercepts every segment put onto segment_queue to check for terminate words.

Inherited Members
spych.utils.Notify
notify