yum/FastTextPager
Compressed text paging over OSC.
git clone https://git.yummers.dev/yum/FastTextPager
043a447
master
1from datetime import datetime 2from faster_whisper import WhisperModel 3import json 4import langcodes 5import numpy as np 6import os 7import noisereduce as nr 8try : 9from profanity_filter import ProfanityFilter 10PROFANITY_FILTER_AVAILABLE = True 11except ImportError : 12PROFANITY_FILTER_AVAILABLE = False 13"Warning: profanity_filter module not available" ,file = sys .stderr ) 14import pyaudio 15from pydub import AudioSegment 16from shared_thread_data import SharedThreadData 17from silero_vad import load_silero_vad ,get_speech_timestamps 18import sys 19import time 20import typing 21import wave 22 23APP_ROOT = os .path .dirname (os .path .abspath (__file__ )) 24PROJECT_ROOT = os .path .dirname (APP_ROOT ) 25 26class AudioStream (): 27FORMAT = pyaudio .paInt16 28# Size of each frame (audio sample), in bytes. If you change FORMAT, make 29# sure this stays up to date! 30FRAME_SZ = 2 31# Frames per second. 32FPS = 16000 33CHANNELS = 1 34def __init__ (self ): 35pass 36 37def getSamples (self )-> bytes : 38raise NotImplementedError ("getSamples is not implemented!" ) 39 40class MicStream (AudioStream ): 41CHUNK_SZ = 1024 42 43def __init__ (self ,cfg :typing .Dict ): 44self .p = pyaudio .PyAudio () 45self .stream = None 46self .sample_rate = None 47# Each time pyaudio gives us audio data, it's in the form of a chunk of 48# samples. We keep these in a list to keep the audio callback as light 49# as possible. Whenever downstream layers want data, we collapse the 50# list into a single array of data (a bytes object). 51self .chunks = [] 52# If set, incoming frames are simply discarded. 53self .paused = False 54 55which_mic = cfg ["microphone" ] 56 57if cfg ["enable_debug_mode" ]: 58f"Finding mic { which_mic } " ,file = sys .stderr ) 59self .dumpMicDevices () 60 61got_match = False 62device_index = - 1 63if which_mic == "index" : 64target_str = "Digital Audio Interface" 65elif which_mic == "focusrite" : 66target_str = "Focusrite" 67elif which_mic == "motu" : 68target_str = "In 1-2 (MOTU M Series)" 69elif which_mic == "beyond" : 70target_str = "Microphone (Beyond)" 71else : 72if cfg ["enable_debug_mode" ]: 73f"Mic { which_mic } requested, treating it as a numerical " + 74"device ID" ,file = sys .stderr ) 75device_index = int (which_mic ) 76got_match = True 77if not got_match : 78info = self .p .get_host_api_info_by_index (0 ) 79numdevices = info .get ('deviceCount' ) 80for i in range (0 ,numdevices ): 81if (self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('maxInputChannels' ))> 0 : 82device_name = self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('name' ) 83if target_str in device_name : 84f"Got matching mic: { device_name } " , 85file = sys .stderr ) 86device_index = i 87got_match = True 88break 89if not got_match : 90raise KeyError (f"Mic { which_mic } not found" ) 91 92info = self .p .get_device_info_by_host_api_device_index (0 ,device_index ) 93if cfg ["enable_debug_mode" ]: 94f"Found mic { which_mic } : { info [ 'name' ] } " ,file = sys .stderr ) 95self .sample_rate = int (info ['defaultSampleRate' ]) 96if cfg ["enable_debug_mode" ]: 97f"Mic sample rate: { self . sample_rate } " ,file = sys .stderr ) 98 99self .stream = self .p .open ( 100rate = self .sample_rate , 101channels = AudioStream .CHANNELS , 102format = AudioStream .FORMAT , 103input = True , 104frames_per_buffer = MicStream .CHUNK_SZ , 105input_device_index = device_index , 106stream_callback = self .onAudioFramesAvailable ) 107 108self .stream .start_stream () 109 110AudioStream .__init__ (self ) 111 112def pause (self ,state :bool = True ): 113self .paused = state 114 115def dumpMicDevices (self ): 116info = self .p .get_host_api_info_by_index (0 ) 117numdevices = info .get ('deviceCount' ) 118 119for i in range (0 ,numdevices ): 120if (self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('maxInputChannels' ))> 0 : 121device_name = self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('name' ) 122"Input Device id " ,i ," - " ,device_name ) 123 124def onAudioFramesAvailable (self , 125frames , 126frame_count , 127time_info , 128status_flags ): 129if self .paused : 130# Don't literally pause, just start returning silence. This allows 131# the `min_segment_age_s` check to work while paused. 132n_frames = int (frame_count * AudioStream .FPS / 133float (self .sample_rate )) 134self .chunks .append (np .zeros (n_frames , 135dtype = np .int16 ).tobytes ()) 136return (frames ,pyaudio .paContinue ) 137 138decimated = b'' 139# In pyaudio, a `frame` is a single sample of audio data. 140frame_len = AudioStream .FRAME_SZ 141next_frame = 0.0 142# The mic probably has a higher sample rate than Whisper wants, so 143# decrease the sample rate by dropping samples. Note that this 144# algorithm only works if the mic's rate is higher than whisper's 145# expected rate. 146keep_every = float (self .sample_rate )/ AudioStream .FPS 147for i in range (frame_count ): 148if i >= next_frame : 149decimated += frames [i * frame_len :(i + 1 )* frame_len ] 150next_frame += keep_every 151self .chunks .append (decimated ) 152 153return (frames ,pyaudio .paContinue ) 154 155# Get audio data and the corresponding timestamp. 156def getSamples (self )-> bytes : 157chunks = self .chunks 158self .chunks = [] 159result = b'' .join (chunks ) 160return result 161 162class AudioCollector : 163def __init__ (self ,stream :AudioStream ): 164self .stream = stream 165self .frames = b'' 166# Note: by design, this is the only spot where we anchor our timestamps 167# against the real world. This is done to make it possible to profile 168# test cases which read from disk (at much faster than real speed) in 169# the same way that we profile real-time data. 170self .wall_ts = time .time () 171 172def getAudio (self )-> bytes : 173frames = self .stream .getSamples () 174if frames : 175self .frames += frames 176return self .frames 177 178def dropAudioPrefix (self ,dur_s :float )-> bytes : 179n_bytes = int (dur_s * AudioStream .FPS )* self .stream .FRAME_SZ 180n_bytes = min (n_bytes ,len (self .frames )) 181cut_portion = self .frames [:n_bytes ] 182self .frames = self .frames [n_bytes :] 183self .wall_ts += float (n_bytes / self .stream .FRAME_SZ )/ self .stream .FPS 184return cut_portion 185 186def dropAudioPrefixByFrames (self ,dur_frames :int )-> bytes : 187n_bytes = dur_frames * self .stream .FRAME_SZ 188n_bytes = min (n_bytes ,len (self .frames )) 189cut_portion = self .frames [:n_bytes ] 190self .frames = self .frames [n_bytes :] 191self .wall_ts += float (n_bytes / self .stream .FRAME_SZ )/ self .stream .FPS 192return cut_portion 193 194def keepLast (self ,dur_s :float )-> bytes : 195drop_len = max (0 ,self .duration ()- dur_s ) 196return self .dropAudioPrefix (drop_len ) 197 198def dropAudio (self ): 199self .wall_ts += self .duration () 200cut_portion = self .frames 201self .frames = b'' 202return cut_portion 203 204def duration (self ): 205return len (self .frames )/ (AudioStream .FPS * self .stream .FRAME_SZ ) 206 207def begin (self ): 208return self .wall_ts 209 210def now (self ): 211return self .begin ()+ self .duration () 212 213class AudioCollectorFilter : 214def __init__ (self ,parent :AudioCollector ): 215self .parent = parent 216 217def getAudio (self )-> bytes : 218return self .parent .getAudio () 219def dropAudioPrefix (self ,dur_s :float ): 220return self .parent .dropAudioPrefix (dur_s ) 221def dropAudioPrefixByFrames (self ,dur_frames :int ): 222return self .parent .dropAudioPrefixByFrames (dur_frames ) 223def keepLast (self ,dur_s ): 224return self .parent .keepLast (dur_s ) 225def dropAudio (self ): 226return self .parent .dropAudio () 227def duration (self ): 228return self .parent .duration () 229def begin (self ): 230return self .parent .begin () 231def now (self ): 232return self .parent .now () 233 234# Audio collector that enforces a minimum length on its audio data. 235class LengthEnforcingAudioCollector (AudioCollectorFilter ): 236def __init__ (self ,parent :AudioCollector ,min_duration_s :float ): 237AudioCollectorFilter .__init__ (self ,parent ) 238self .min_duration_s = min_duration_s 239 240def getAudio (self )-> bytes : 241audio = self .parent .getAudio () 242min_duration_frames = int (self .min_duration_s * AudioStream .FPS ) 243pad_len_frames = max (0 ,min_duration_frames - int (len (audio )/ 244AudioStream .FRAME_SZ )) 245pad = np .zeros (pad_len_frames ,dtype = np .int16 ).tobytes () 246return pad + audio 247 248class NormalizingAudioCollector (AudioCollectorFilter ): 249def __init__ (self ,parent :AudioCollector ): 250AudioCollectorFilter .__init__ (self ,parent ) 251 252def getAudio (self )-> bytes : 253audio = self .parent .getAudio () 254 255audio = AudioSegment (audio ,sample_width = AudioStream .FRAME_SZ , 256frame_rate = AudioStream .FPS ,channels = AudioStream .CHANNELS ) 257audio = audio .normalize () 258 259frames = np .array (audio .get_array_of_samples ()) 260frames = np .int16 (frames ).tobytes () 261 262return frames 263 264class BoostingAudioCollector (AudioCollectorFilter ): 265def __init__ (self ,parent :AudioCollector , 266target_dBFS :float , 267max_gain_dB :float , 268cfg :typing .Dict ): 269AudioCollectorFilter .__init__ (self ,parent ) 270self .target_dBFS = target_dBFS 271self .max_gain_dB = max_gain_dB 272self .cfg = cfg 273 274def getAudio (self )-> bytes : 275audio = self .parent .getAudio () 276 277audio = AudioSegment (audio ,sample_width = AudioStream .FRAME_SZ , 278frame_rate = AudioStream .FPS ,channels = AudioStream .CHANNELS ) 279gain = min (self .target_dBFS - audio .dBFS ,self .max_gain_dB ) 280if self .cfg ["enable_debug_mode" ]: 281f"Boosting audio by { gain } dB (from { audio . dBFS } to { audio . dBFS + gain } )" ,flush = True ) 282audio = audio .apply_gain (gain ) 283 284frames = np .array (audio .get_array_of_samples ()) 285frames = np .int16 (frames ).tobytes () 286 287return frames 288 289class CompressingAudioCollector (AudioCollectorFilter ): 290def __init__ (self ,parent :AudioCollector ): 291AudioCollectorFilter .__init__ (self ,parent ) 292 293def getAudio (self )-> bytes : 294audio = self .parent .getAudio () 295 296audio = AudioSegment (audio ,sample_width = AudioStream .FRAME_SZ , 297frame_rate = AudioStream .FPS ,channels = AudioStream .CHANNELS ) 298# subtle compression has a slight positive effect on my benchmark 299audio = audio .compress_dynamic_range (threshold =- 10 ,ratio = 2.0 ) 300 301frames = np .array (audio .get_array_of_samples ()) 302frames = np .int16 (frames ).tobytes () 303 304return frames 305 306class NoiseReducingAudioCollector (AudioCollectorFilter ): 307def __init__ (self ,parent :AudioCollector ,cfg :typing .Dict ): 308AudioCollectorFilter .__init__ (self ,parent ) 309self .cfg = cfg 310 311def getAudio (self )-> bytes : 312audio = self .parent .getAudio () 313audio_array = np .frombuffer (audio ,dtype = np .int16 ).astype (np .float32 ) 314 315reduced_audio = nr .reduce_noise ( 316y = audio_array , 317sr = AudioStream .FPS , 318 ) 319 320# Convert back to int16 321reduced_audio = np .clip (reduced_audio ,- 32768 ,32767 ) 322frames = np .int16 (reduced_audio ).tobytes () 323 324return frames 325 326class AudioSegmenter : 327def __init__ (self , 328min_silence_ms = 250 , 329max_speech_s = 5 , 330min_speech_duration_ms = 100 ): 331self .min_silence_ms = min_silence_ms 332self .max_speech_s = max_speech_s 333self .min_speech_duration_ms = min_speech_duration_ms 334 335# Load Silero VAD model 336self .model = load_silero_vad () 337 338self .vad_threshold = 0.3 339self .min_silence_duration_ms = min_silence_ms 340self .max_speech_duration_s = max_speech_s 341self .min_speech_duration_ms = min_speech_duration_ms 342 343def segmentAudio (self ,audio :bytes ): 344# Convert audio bytes to numpy array expected by silero-vad 345audio_array = np .frombuffer (audio , 346dtype = np .int16 ).flatten ().astype (np .float32 )/ 32768.0 347 348# Get speech timestamps using silero-vad 349# Note: silero-vad expects sample rate of 16000 Hz which matches AudioStream.FPS 350speech_timestamps = get_speech_timestamps ( 351audio_array , 352self .model , 353sampling_rate = AudioStream .FPS , 354threshold = self .vad_threshold , 355min_silence_duration_ms = self .min_silence_duration_ms , 356max_speech_duration_s = self .max_speech_duration_s , 357min_speech_duration_ms = self .min_speech_duration_ms , 358return_seconds = False # We want frame indices, not seconds 359 ) 360 361return speech_timestamps 362 363# Returns the stable cutoff (if any) and whether there are any segments. 364def getStableCutoff (self ,audio :bytes )-> typing .Tuple [int ,bool ]: 365min_delta_frames = int ((self .min_silence_duration_ms * 366AudioStream .FPS )/ 1000.0 ) 367cutoff = None 368 369last_end = None 370segments = self .segmentAudio (audio ) 371 372for i in range (len (segments )): 373s = segments [i ] 374#print(f"s: {s}") 375#print(f"last_end: {last_end}") 376 377if last_end : 378delta_frames = s ['start' ]- last_end 379#print(f"delta frames: {delta_frames}") 380if delta_frames > min_delta_frames : 381cutoff = s ['start' ] 382else : 383last_end = s ['end' ] 384 385if i == len (segments )- 1 : 386now = int (len (audio )/ AudioStream .FRAME_SZ ) 387#print(f"now: {now}") 388#print(f"min d: {min_delta_frames}") 389delta_frames = now - s ['end' ] 390if delta_frames > min_delta_frames : 391cutoff = now - int (min_delta_frames / 2 ) 392 393return (cutoff ,len (segments )> 0 ) 394 395# A segment of transcribed audio. `start_ts` and `end_ts` are floating point 396# number of seconds since the beginning of audio data. 397class Segment : 398def __init__ (self , 399transcript :str , 400start_ts :float , 401end_ts :float , 402wall_ts :float , 403avg_logprob :float , 404no_speech_prob :float , 405compression_ratio :float ): 406self .transcript = transcript 407# start_ts, end_ts are timestamps in seconds relative to `wall_ts`. 408self .start_ts = start_ts 409self .end_ts = end_ts 410# wall_ts is the time.time() at which the oldest audio sample leading 411# to this transcript was collected. 412self .wall_ts = wall_ts 413self .avg_logprob = avg_logprob 414self .no_speech_prob = no_speech_prob 415self .compression_ratio = compression_ratio 416 417def __str__ (self ): 418ts = f"(ts: { self . start_ts } - { self . end_ts } ) " 419 420wall_ts_start = datetime .utcfromtimestamp (self .start_ts + self .wall_ts ).strftime ('%H:%M:%S' ) 421wall_ts_end = datetime .utcfromtimestamp (self .end_ts + self .wall_ts ).strftime ('%H:%M:%S' ) 422wall_ts = f"(wall ts: { wall_ts_start } - { wall_ts_end } ) " 423 424no_speech = f"(no_speech: { self . no_speech_prob } ) " 425avg_logprob = f"(avg_logprob: { self . avg_logprob } ) " 426return f" { self . transcript } " + ts + wall_ts + no_speech + avg_logprob 427 428def join_segments (a ,b ): 429if len (a )> 0 and a [- 1 ]!= ' ' : 430return a + ' ' + b 431else : 432return a + b 433 434class Whisper : 435def __init__ (self , 436collector :AudioCollector , 437cfg :typing .Dict ): 438self .collector = collector 439self .model = None 440self .cfg = cfg 441 442model_str = cfg ["model" ] 443model_root = os .path .join (PROJECT_ROOT ,"Models" , 444os .path .normpath (model_str )) 445if cfg ["enable_debug_mode" ]: 446f"Model { cfg [ 'model' ] } will be saved to { model_root } " , 447file = sys .stderr ) 448 449model_device = "cuda" 450compute_type = cfg ["compute_type" ] 451if cfg ["use_cpu" ]: 452model_device = "cpu" 453compute_type = "int8" 454 455already_downloaded = os .path .exists (model_root ) 456 457if not already_downloaded : 458f"Model { model_str } not already downloaded, downloading now..." ,flush = True ) 459 460self .model = WhisperModel (model_str , 461device = model_device , 462device_index = cfg ["gpu_idx" ], 463compute_type = compute_type , 464download_root = model_root , 465local_files_only = already_downloaded ) 466 467self .context_window_chars = 200 # Keep last 200 chars of context 468self .recent_context = "" # Store recent committed text 469 470def update_context (self ,committed_text :str ): 471"""Update the context with recently committed text.""" 472self .recent_context = join_segments (self .recent_context ,committed_text ).strip () 473# Drop half of the context window. 474if len (self .recent_context )> self .context_window_chars : 475words = self .recent_context .split () 476words = words [len (words )// 2 :] 477self .recent_context = ' ' .join (words ) 478 479def transcribe (self ,frames :bytes = None )-> typing .List [Segment ]: 480if frames is None : 481frames = self .collector .getAudio () 482 483# Convert audio to float32 484audio = np .frombuffer (frames , 485dtype = np .int16 ).flatten ().astype (np .float32 )/ 32768.0 486 487# Build context-aware prompt 488prompt = self ._build_prompt () 489 490if self .cfg ["enable_debug_mode" ]: 491f"Prompt: { prompt } " ,flush = True ) 492 493t0 = time .time () 494segments ,info = self .model .transcribe ( 495audio , 496language = langcodes .find (self .cfg ["language" ]).language , 497vad_filter = True , 498temperature = 0.0 , 499without_timestamps = False , 500initial_prompt = prompt , 501beam_size = self .cfg .get ("beam_size" ,5 ), 502best_of = self .cfg .get ("best_of" ,5 ), 503condition_on_previous_text = True 504 ) 505res = [] 506for s in segments : 507# Manual touchup. I see a decent number of hallucinations sneaking 508# in with high `no_speech_prob` and modest `avg_logprob`. 509if s .no_speech_prob > 0.6 and s .avg_logprob < - 0.5 : 510if self .cfg ["enable_debug_mode" ]: 511f"Drop probable hallucination (case 1) " + 512f"(text=' { s . text } ', " + 513f"no_speech_prob= { s . no_speech_prob } , " + 514f"avg_logprob= { s . avg_logprob } )" ,file = sys .stderr ) 515continue 516# Another touchup targeted at the vexatious "thanks for watching!" 517# hallucination. This triggers a lot when listening to 518# instrumental/electronic music. 519if s .no_speech_prob > 0.15 and s .avg_logprob < - 0.7 : 520if self .cfg ["enable_debug_mode" ]: 521f"Drop probable hallucination (case 2) " + 522f"(text=' { s . text } ', " + 523f"no_speech_prob= { s . no_speech_prob } , " + 524f"avg_logprob= { s . avg_logprob } )" ,file = sys .stderr ) 525continue 526if s .avg_logprob < - 0.75 : 527if self .cfg ["enable_debug_mode" ]: 528f"Drop probable hallucination (case 3) " + 529f"(text=' { s . text } ', " + 530f"no_speech_prob= { s . no_speech_prob } , " + 531f"avg_logprob= { s . avg_logprob } )" ,file = sys .stderr ) 532continue 533if self .cfg ["enable_debug_mode" ]: 534f"s get: { s } " ) 535if s .avg_logprob < - 1.0 : 536continue 537if s .compression_ratio > 2.4 : 538continue 539res .append (Segment (s .text ,s .start ,s .end , 540self .collector .begin (), 541s .avg_logprob ,s .no_speech_prob ,s .compression_ratio )) 542t1 = time .time () 543if self .cfg ["enable_debug_mode" ]: 544f"Transcription latency (s): { t1 - t0 } " ) 545return res 546 547def _build_prompt (self )-> str : 548"""Build a context-aware prompt for Whisper.""" 549user_prompt = self .cfg ["user_prompt" ] 550context_prompt = "" 551if self .recent_context and len (self .recent_context )> 0 : 552context_prompt = f"Here is the context so far: { self . recent_context } " 553 554prompts = [user_prompt ,context_prompt ] 555prompts = [p for p in prompts if p and len (p )> 0 ] 556return " " .join (prompts ) 557 558class TranscriptCommit : 559def __init__ (self , 560delta :str , 561preview :str , 562latency_s :float = None , 563thresh_at_commit :int = None , 564audio :bytes = None , 565duration_s :float = None , 566start_ts :float = None ): 567self .delta = delta 568self .preview = preview 569self .latency_s = latency_s 570self .thresh_at_commit = thresh_at_commit 571self .audio = audio 572# Time at which the commit is generated 573self .ts = time .time () 574# Time corresponding to the start of the segment 575self .start_ts = start_ts 576# The duration of the audio segment, in seconds. 577self .duration_s = duration_s 578 579 580def saveAudio (audio :bytes ,path :str ,cfg :typing .Dict ): 581with wave .open (path ,'wb' )as wf : 582if cfg ["enable_debug_mode" ]: 583f"Saving audio to { path } " ,file = sys .stderr ) 584wf .setnchannels (AudioStream .CHANNELS ) 585wf .setsampwidth (AudioStream .FRAME_SZ ) 586wf .setframerate (AudioStream .FPS ) 587wf .writeframes (audio ) 588 589 590class SegmentLogger : 591def __init__ (self ,cfg :typing .Dict ): 592self .cfg = cfg 593self .enabled = cfg .get ("enable_segment_logging" ,False ) 594self .session_data = [] 595self .log_file = None 596 597if self .enabled : 598log_dir = os .path .join (PROJECT_ROOT ,"logs" ) 599if not os .path .exists (log_dir ): 600os .makedirs (log_dir ) 601 602# Create file 603timestamp = datetime .now ().strftime ("%Y%m%d_%H%M%S" ) 604self .log_file = os .path .join (log_dir ,f"session_debug_ { timestamp } .json" ) 605f"Segment logging enabled. Logging to: { self . log_file } " ,file = sys .stderr ) 606 607def log_segment (self ,segment :Segment ,commit_type :str = "commit" ): 608if not self .enabled : 609return 610 611segment_data = { 612"timestamp" :datetime .now ().isoformat (), 613"type" :commit_type , 614"text" :segment .transcript , 615"start_ts" :segment .start_ts , 616"end_ts" :segment .end_ts , 617"wall_ts" :segment .wall_ts , 618"avg_logprob" :segment .avg_logprob , 619"no_speech_prob" :segment .no_speech_prob , 620"compression_ratio" :segment .compression_ratio , 621"duration" :segment .end_ts - segment .start_ts 622 } 623 624self .session_data .append (segment_data ) 625 626# Write to file incrementally 627try : 628with open (self .log_file ,'w' )as f : 629json .dump ({ 630"session_start" :self .session_data [0 ]["timestamp" ]if self .session_data else None , 631"segments" :self .session_data 632 },f ,indent = 2 ) 633except Exception as e : 634f"Error writing segment log: { e } " ,file = sys .stderr ) 635 636def close (self ): 637if self .enabled and self .session_data : 638f"Session complete. Logged { len ( self . session_data ) } segments to { self . log_file } " ,file = sys .stderr ) 639 640 641class VadCommitter : 642def __init__ (self , 643cfg :typing .Dict , 644collector :AudioCollector , 645whisper :Whisper , 646segmenter :AudioSegmenter , 647segment_logger :SegmentLogger = None ): 648self .cfg = cfg 649self .collector = collector 650self .whisper = whisper 651self .segmenter = segmenter 652self .segment_logger = segment_logger 653 654def getDelta (self )-> TranscriptCommit : 655audio = self .collector .getAudio () 656stable_cutoff ,has_audio = self .segmenter .getStableCutoff (audio ) 657 658delta = "" 659commit_audio = None 660latency_s = None 661duration_s = self .collector .duration () 662start_ts = self .collector .begin () 663 664if has_audio and stable_cutoff : 665latency_s = self .collector .now ()- self .collector .begin () 666duration_s = stable_cutoff / AudioStream .FPS 667start_ts = self .collector .begin () 668 669# Get the filtered audio first, then extract the portion we need 670filtered_audio = self .collector .getAudio () 671commit_audio = filtered_audio [:stable_cutoff * AudioStream .FRAME_SZ ] 672 673# Now drop the prefix from the collector 674self .collector .dropAudioPrefixByFrames (stable_cutoff ) 675 676segments = self .whisper .transcribe (commit_audio ) 677delta = '' .join (s .transcript for s in segments ) 678 679# Update whisper's context with the committed text 680if delta .strip (): 681self .whisper .update_context (delta .strip ()) 682 683if self .segment_logger : 684for s in segments : 685self .segment_logger .log_segment (s ,"commit" ) 686 687audio = self .collector .getAudio () 688if self .cfg ["enable_debug_mode" ]: 689for s in segments : 690f"commit segment: { s } " ,file = sys .stderr ) 691if len (delta )> 0 : 692f"delta get: { delta } " ,file = sys .stderr ) 693 694if self .cfg ["save_audio" ]and len (delta )> 0 : 695ts = datetime .fromtimestamp (self .collector .now ()- latency_s ) 696filename = str (ts .strftime ('%Y_%m_%d__%H-%M-%S' ))+ delta .strip ()+ ".wav" 697audio_dir = os .path .join (PROJECT_ROOT ,"audio" ) 698if not os .path .exists (audio_dir ): 699os .makedirs (audio_dir ) 700saveAudio (commit_audio ,os .path .join (audio_dir ,filename ),self .cfg ) 701 702preview = "" 703if self .cfg ["enable_previews" ]and has_audio : 704segments = self .whisper .transcribe (audio ) 705preview = "" .join (s .transcript for s in segments ) 706 707if self .segment_logger : 708for s in segments : 709self .segment_logger .log_segment (s ,"preview" ) 710 711if not has_audio : 712self .collector .keepLast (1.0 ) 713 714return TranscriptCommit ( 715delta .strip (), 716preview .strip (), 717latency_s , 718audio = audio , 719duration_s = duration_s , 720start_ts = start_ts ) 721 722 723class StreamingPlugin : 724def __init__ (self ): 725pass 726 727def transform (self ,commit :TranscriptCommit )-> TranscriptCommit : 728return commit 729 730def stop (self ): 731pass 732 733 734class LowercasePlugin (StreamingPlugin ): 735def __init__ (self ,cfg ): 736self .cfg = cfg 737 738def transform (self ,commit :TranscriptCommit )-> TranscriptCommit : 739if self .cfg ["enable_lowercase_filter" ]: 740commit .delta = commit .delta .lower () 741commit .preview = commit .preview .lower () 742return commit 743 744 745class UppercasePlugin (StreamingPlugin ): 746def __init__ (self ,cfg ): 747self .cfg = cfg 748 749def transform (self ,commit :TranscriptCommit )-> TranscriptCommit : 750if self .cfg ["enable_uppercase_filter" ]: 751commit .delta = commit .delta .upper () 752commit .preview = commit .preview .upper () 753return commit 754 755 756class ProfanityPlugin (StreamingPlugin ): 757def __init__ (self ,cfg ): 758self .cfg = cfg 759self .filter = None 760if PROFANITY_FILTER_AVAILABLE and cfg ["enable_profanity_filter" ]: 761en_profanity_path = os .path .join (PROJECT_ROOT ,"Third_Party/Profanity/en" ) 762try : 763self .filter = ProfanityFilter (en_profanity_path ) 764self .filter .load () 765except Exception as e : 766f"Warning: Could not load profanity filter: { e } " ,file = sys .stderr ) 767self .filter = None 768 769def transform (self ,commit :TranscriptCommit )-> TranscriptCommit : 770if self .cfg ["enable_profanity_filter" ]and self .filter : 771commit .delta = self .filter .filter (commit .delta ) 772commit .preview = self .filter .filter (commit .preview ) 773return commit 774 775 776class PresentationFilter : 777def __init__ (self ): 778pass 779 780def transform (self ,transcript :str ,preview :str )-> typing .Tuple [str ,str ]: 781return transcript ,preview 782 783def stop (self ): 784pass 785 786 787class TrailingPeriodFilter (PresentationFilter ): 788def __init__ (self ,cfg ): 789self .cfg = cfg 790 791def transform (self ,transcript :str ,preview :str )-> typing .Tuple [str ,str ]: 792if self .cfg ["remove_trailing_period" ]: 793def _remove_trailing_period (s :str )-> str : 794if len (s )> 0 and s [- 1 ]== '.' and not s .endswith ("..." ): 795s = s [0 :len (s )- 1 ] 796return s 797if len (preview )== 0 : 798transcript = _remove_trailing_period (transcript ) 799else : 800preview = _remove_trailing_period (preview ) 801return transcript ,preview 802 803 804def transcriptionThread (shared_data :SharedThreadData ): 805last_stable_commit = None 806 807stream = MicStream (shared_data .cfg ) 808collector = AudioCollector (stream ) 809collector = CompressingAudioCollector (collector ) 810collector = BoostingAudioCollector (collector ,- 16.0 ,24.0 , 811shared_data .cfg ) 812collector = NoiseReducingAudioCollector (collector ,shared_data .cfg ) 813#collector = NormalizingAudioCollector(collector) 814whisper = Whisper (collector ,shared_data .cfg ) 815segmenter = AudioSegmenter (min_silence_ms = shared_data .cfg ["min_silence_duration_ms" ], 816max_speech_s = shared_data .cfg ["max_speech_duration_s" ], 817min_speech_duration_ms = shared_data .cfg ["min_speech_duration_ms" ]) 818 819segment_logger = SegmentLogger (shared_data .cfg ) 820committer = VadCommitter (shared_data .cfg ,collector ,whisper ,segmenter ,segment_logger ) 821 822plugins = [] 823# plugins.append(TranslationPlugin(shared_data.cfg)) # Not implemented yet 824plugins .append (UppercasePlugin (shared_data .cfg )) 825plugins .append (LowercasePlugin (shared_data .cfg )) 826plugins .append (ProfanityPlugin (shared_data .cfg )) 827# plugins.append(UwuPlugin(shared_data.cfg)) # Not implemented yet 828# plugins.append(BrowserSource(shared_data.cfg)) # Not implemented yet 829 830filters = [] 831filters .append (TrailingPeriodFilter (shared_data .cfg )) 832 833transcript = "" 834preview = "" 835 836with shared_data .word_lock : 837shared_data .stream = stream 838shared_data .collector = collector 839 840f"Ready to go!" ,flush = True ) 841 842while not shared_data .exit_event .is_set (): 843time .sleep (shared_data .cfg ["transcription_loop_delay_ms" ]/ 1000.0 ); 844 845op = None 846 847commit = committer .getDelta () 848 849with shared_data .word_lock : 850for plugin in plugins : 851commit = plugin .transform (commit ) 852 853if len (commit .delta )> 0 or len (commit .preview )> 0 : 854# Avoid re-sending text after long pauses 855if shared_data .cfg ["reset_after_silence_s" ]> 0 : 856silence_duration = 0 857if last_stable_commit : 858last_commit_end_ts = \ 859last_stable_commit .start_ts + \ 860last_stable_commit .duration_s 861silence_duration = commit .start_ts - last_commit_end_ts 862if silence_duration > shared_data .cfg ["reset_after_silence_s" ]: 863if shared_data .cfg ["enable_debug_mode" ]: 864f"Resetting transcript after { silence_duration } -second " 865"silence" ,file = sys .stderr ) 866shared_data .transcript = "" 867shared_data .preview = "" 868whisper .recent_context = "" # Reset context too 869if commit .delta : 870last_stable_commit = commit 871 872# Hard-cap displayed transcript length to prevent 873# runaway memory use in UI. Keep the full transcript to avoid 874# breaking OSC pager. 875if len (shared_data .transcript )>= 1024 : 876shared_data .transcript = shared_data .transcript [- 512 :] 877shared_data .transcript = \ 878join_segments (shared_data .transcript ,commit .delta ) 879shared_data .preview = commit .preview 880 881for filt in filters : 882shared_data .transcript ,shared_data .preview = \ 883filt .transform (shared_data .transcript , 884shared_data .preview ) 885 886try : 887f"Transcript: { shared_data . transcript } " ,flush = True ) 888except UnicodeEncodeError : 889"Failed to encode transcript - discarding delta" , 890file = sys .stderr ) 891continue 892try : 893f"Preview: { shared_data . preview } " ,flush = True ) 894except UnicodeEncodeError : 895"Failed to encode preview - discarding" ,file = sys .stderr ) 896 897if shared_data .cfg ["enable_debug_mode" ]: 898f"commit latency: { commit . latency_s } " ,file = sys .stderr ) 899f"commit thresh: { commit . thresh_at_commit } " , 900file = sys .stderr ) 901 902if len (shared_data .transcript )> 0 and \ 903 (not shared_data .transcript .endswith (' ' ))and \ 904 (not commit .delta .startswith (' ' )): 905commit .delta = ' ' + commit .delta 906if len (commit .delta )> 0 and \ 907 (not commit .delta .endswith (' ' ))and \ 908 (not commit .preview .startswith (' ' )): 909commit .preview = ' ' + commit .preview 910for plugin in plugins : 911plugin .stop () 912for filt in filters : 913filt .stop () 914segment_logger .close () 915