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