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

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 = '')
496    def __init__(
497        self,
498        segment_queue: Queue,
499        stop_event: threading.Event,
500        lang_a: str,
501        lang_b: str,
502        speaking_event: threading.Event,
503        output_format: str = "",
504        output_path: str = "transcript",
505        show_timestamps: bool = True,
506        use_speaker: bool = True,
507        speaker_voice: str = "",
508    ):
509        """
510        Requires:
511
512        - `segment_queue`: Queue of TranslationSegment objects from TranslatingTranscriber
513        - `stop_event`: Shared stop signal
514        - `lang_a`: BCP-47 code of the first language in the pair (e.g. "en")
515        - `lang_b`: BCP-47 code of the second language in the pair (e.g. "es")
516        - `speaking_event`: Shared event set while TTS is playing; signals PauseableVADRecorder to hold off
517
518        Optional:
519
520        - `output_format`:
521            - Type: str
522            - What: Output format(s) to write; empty string disables file output
523            - Default: "" (no file output)
524            - Options: "txt", "srt", "both"
525
526        - `output_path`:
527            - Type: str
528            - What: Base file path (without extension)
529            - Default: "transcript"
530
531        - `show_timestamps`:
532            - Type: bool
533            - What: If True, prepends relative timestamps to terminal and TXT output
534            - Default: True
535
536        - `use_speaker`:
537            - Type: bool
538            - What: If True, speaks the translated text via TTS after each segment
539            - Default: True
540
541        - `speaker_voice`:
542            - Type: str
543            - What: Wave voice name for zero-shot cloning; empty string uses the
544              model's built-in default voice
545            - Default: ""
546        """
547        self.segment_queue = segment_queue
548        self.stop_event = stop_event
549        self.lang_a = lang_a
550        self.lang_b = lang_b
551        self.speaking_event = speaking_event
552        self.output_format = output_format
553        self.output_path = output_path
554        self.show_timestamps = show_timestamps
555        self.use_speaker = use_speaker
556        self.speaker_voice = speaker_voice
557        self.txt_file = None
558        self.srt_file = None
559        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):
561    def run(self):
562        """Blocking writer loop. Intended to be run in a dedicated thread."""
563        if self.use_speaker:
564            from spych.speaker.speaker import Speaker
565
566            for lang_id in (self.lang_a, self.lang_b):
567                try:
568                    self.speakers[lang_id] = Speaker(
569                        voice=self.speaker_voice,
570                        backend="chatterbox_multilingual",
571                        language_id=lang_id,
572                    )
573                except Exception as e:
574                    print(
575                        f"[spych] TTS for {lang_id} unavailable, "
576                        f"continuing without speaker for that language: {e}",
577                        flush=True,
578                    )
579
580        try:
581            if self.output_format and self.output_format in ("txt", "both"):
582                self.txt_file = open(
583                    f"{self.output_path}.txt", "w", encoding="utf-8"
584                )
585            if self.output_format and self.output_format in ("srt", "both"):
586                self.srt_file = open(
587                    f"{self.output_path}.srt", "w", encoding="utf-8"
588                )
589
590            while True:
591                try:
592                    segment = self.segment_queue.get(timeout=0.5)
593                except Empty:
594                    if self.stop_event.is_set():
595                        break
596                    continue
597
598                if segment is None:
599                    break
600
601                self.write_segment(segment)
602
603        finally:
604            for speaker in self.speakers.values():
605                speaker.interrupt()
606                speaker.wait_for_speak()
607            if self.txt_file:
608                self.txt_file.flush()
609                self.txt_file.close()
610            if self.srt_file:
611                self.srt_file.flush()
612                self.srt_file.close()
613            if self.speakers:
614                import pygame
615
616                try:
617                    pygame.mixer.quit()
618                except Exception:
619                    pass

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

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

Write one bilingual segment to file outputs and queue TTS.

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