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