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
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.
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
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.
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 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
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
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.
382 def __init__(self, *args, speaking_event: threading.Event, **kwargs): 383 super().__init__(*args, **kwargs) 384 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
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.
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.
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 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: ""
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.
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
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
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:
-
- 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: ""
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
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.