yum-archive/yapBox
A black box for your yapping
git clone https://git.yummers.dev/yum-archive/yapBox
89e9376
master
1from datetime import datetime 2from pydub import AudioSegment 3 4import gradio as gr 5import math 6import numpy as np 7import os 8import pyaudio 9import subprocess 10import sys 11import time 12import typing 13import vad 14import wave 15 16class Logger : 17def __init__ (self ,filename ): 18self .terminal = sys .stdout 19self .log = open (filename ,"w" ) 20 21def write (self ,message ): 22self .terminal .write (message ) 23self .log .write (message ) 24 25def flush (self ): 26self .terminal .flush () 27self .log .flush () 28 29def isatty (self ): 30return False 31 32class AudioStream (): 33FORMAT = pyaudio .paInt16 34# Size of each frame (audio sample), in bytes. If you change FORMAT, make 35# sure this stays up to date! 36FRAME_SZ = 2 37# Frames per second. 38FPS = 16000 39CHANNELS = 1 40def __init__ (self ): 41pass 42 43def getSamples (self )-> bytes : 44raise NotImplementedError ("getSamples is not implemented!" ) 45 46class MicStream (AudioStream ): 47CHUNK_SZ = 1024 48 49def __init__ (self ,which_mic :str ,fps :int = AudioStream .FPS ): 50self .p = pyaudio .PyAudio () 51self .stream = None 52self .sample_rate = None 53# Each time pyaudio gives us audio data, it's in the form of a chunk of 54# samples. We keep these in a list to keep the audio callback as light 55# as possible. Whenever downstream layers want data, we collapse the 56# list into a single array of data (a bytes object). 57self .chunks = [] 58# If set, incoming frames are simply discarded. 59self .paused = False 60self .fps = fps 61 62f"Finding mic { which_mic } " ,file = sys .stderr ) 63 64got_match = False 65device_index = - 1 66if not got_match : 67info = self .p .get_host_api_info_by_index (0 ) 68numdevices = info .get ('deviceCount' ) 69for i in range (0 ,numdevices ): 70if (self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('maxInputChannels' ))> 0 : 71device_name = self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('name' ) 72if which_mic in device_name : 73f"Got matching mic: { device_name } " , 74file = sys .stderr ) 75device_index = i 76got_match = True 77break 78if not got_match : 79raise KeyError (f"Mic { which_mic } not found" ) 80 81info = self .p .get_device_info_by_host_api_device_index (0 ,device_index ) 82f"Found mic { which_mic } : { info [ 'name' ] } " ,file = sys .stderr ) 83self .sample_rate = int (info ['defaultSampleRate' ]) 84f"Mic sample rate: { self . sample_rate } " ,file = sys .stderr ) 85 86self .stream = self .p .open ( 87rate = self .sample_rate , 88channels = self .CHANNELS , 89format = self .FORMAT , 90input = True , 91frames_per_buffer = MicStream .CHUNK_SZ , 92input_device_index = device_index , 93stream_callback = self .onAudioFramesAvailable ) 94 95self .stream .start_stream () 96 97AudioStream .__init__ (self ) 98 99def pause (self ,state :bool = True ): 100self .paused = state 101 102def dumpMicDevices (self ): 103info = self .p .get_host_api_info_by_index (0 ) 104numdevices = info .get ('deviceCount' ) 105 106for i in range (0 ,numdevices ): 107if (self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('maxInputChannels' ))> 0 : 108device_name = self .p .get_device_info_by_host_api_device_index (0 ,i ).get ('name' ) 109"Input Device id " ,i ," - " ,device_name ) 110 111def getMicDevices ()-> typing .List [str ]: 112p = pyaudio .PyAudio () 113info = p .get_host_api_info_by_index (0 ) 114numdevices = info .get ('deviceCount' ) 115 116result = [] 117for i in range (0 ,numdevices ): 118if (p .get_device_info_by_host_api_device_index (0 ,i ).get ('maxInputChannels' ))> 0 : 119device_name = p .get_device_info_by_host_api_device_index (0 ,i ).get ('name' ) 120result .append (device_name ) 121return result 122 123def onAudioFramesAvailable (self , 124frames , 125frame_count , 126time_info , 127status_flags ): 128if self .paused : 129# Don't literally pause, just start returning silence. This allows 130# the `min_segment_age_s` check to work while paused. 131n_frames = int (frame_count * self .fps / 132float (self .sample_rate )) 133self .chunks .append (np .zeros (n_frames , 134dtype = np .int16 ).tobytes ()) 135return (frames ,pyaudio .paContinue ) 136 137decimated = b'' 138# In pyaudio, a `frame` is a single sample of audio data. 139frame_len = self .FRAME_SZ 140next_frame = 0.0 141# The mic probably has a higher sample rate than Whisper wants, so 142# decrease the sample rate by dropping samples. Note that this 143# algorithm only works if the mic's rate is higher than whisper's 144# expected rate. 145keep_every = float (self .sample_rate )/ self .fps 146for i in range (frame_count ): 147if i >= next_frame : 148decimated += frames [i * frame_len :(i + 1 )* frame_len ] 149next_frame += keep_every 150self .chunks .append (decimated ) 151 152return (frames ,pyaudio .paContinue ) 153 154# Get audio data and the corresponding timestamp. 155def getSamples (self )-> bytes : 156chunks = self .chunks 157self .chunks = [] 158result = b'' .join (chunks ) 159return result 160 161class DiskStream (AudioStream ): 162def __init__ (self ,path :str ): 163fmt = None 164if path .endswith (".mp3" ): 165fmt = "mp3" 166elif path .endswith (".wav" ): 167fmt = "wav" 168else : 169raise NotImplementedError (f"Requested file type { path } " + \ 170"is not supported" ) 171f"Loading audio data" ,file = sys .stderr ) 172audio = AudioSegment .from_file (path ,format = fmt ) 173audio = audio .set_channels (1 ) 174audio = audio .set_frame_rate (16000 ) 175frames = np .array (audio .get_array_of_samples ()) 176frames = np .int16 (frames ).tobytes () 177 178self .frames = frames 179self .fps = 16000 180 181def getSamples (self )-> bytes : 182frames = self .frames 183self .frames = b'' 184return frames 185 186if len (frames )< nframes : 187frames += np .zeros (nframes - len (frames ),dtype = np .int16 ).tobytes () 188 189return frames 190 191class AudioCollector : 192def __init__ (self ,stream :AudioStream ): 193self .stream = stream 194self .frames = b'' 195# Note: by design, this is the only spot where we anchor our timestamps 196# against the real world. This is done to make it possible to profile 197# test cases which read from disk (at much faster than real speed) in 198# the same way that we profile real-time data. 199self .wall_ts = time .time () 200 201def getAudio (self )-> bytes : 202frames = self .stream .getSamples () 203if frames : 204self .frames += frames 205return self .frames 206 207def dropAudioPrefix (self ,dur_s :float )-> bytes : 208n_bytes = int (dur_s * self .stream .fps )* self .stream .FRAME_SZ 209n_bytes = min (n_bytes ,len (self .frames )) 210cut_portion = self .frames [:n_bytes ] 211self .frames = self .frames [n_bytes :] 212self .wall_ts += float (n_bytes / self .stream .FRAME_SZ )/ self .stream .fps 213return cut_portion 214 215def dropAudioPrefixByFrames (self ,dur_frames :int )-> bytes : 216n_bytes = dur_frames * self .stream .FRAME_SZ 217n_bytes = min (n_bytes ,len (self .frames )) 218cut_portion = self .frames [:n_bytes ] 219self .frames = self .frames [n_bytes :] 220self .wall_ts += float (n_bytes / self .stream .FRAME_SZ )/ self .stream .fps 221return cut_portion 222 223def keepLast (self ,dur_s :float )-> bytes : 224drop_len = max (0 ,self .duration ()- dur_s ) 225return self .dropAudioPrefix (drop_len ) 226 227def dropAudio (self ): 228self .wall_ts += self .duration () 229cut_portion = self .frames 230self .frames = b'' 231return cut_portion 232 233def duration (self ): 234return len (self .frames )/ (self .stream .fps * self .stream .FRAME_SZ ) 235 236def begin (self ): 237return self .wall_ts 238 239def now (self ): 240return self .begin ()+ self .duration () 241 242class AudioCollectorFilter : 243def __init__ (self ,parent :AudioCollector ): 244self .parent = parent 245self .stream = self .parent .stream 246 247def getAudio (self )-> bytes : 248return self .parent .getAudio () 249def dropAudioPrefix (self ,dur_s :float ): 250return self .parent .dropAudioPrefix (dur_s ) 251def dropAudioPrefixByFrames (self ,dur_frames :int ): 252return self .parent .dropAudioPrefixByFrames (dur_frames ) 253def keepLast (self ,dur_s ): 254return self .parent .keepLast (dur_s ) 255def dropAudio (self ): 256return self .parent .dropAudio () 257def duration (self ): 258return self .parent .duration () 259def begin (self ): 260return self .parent .begin () 261def now (self ): 262return self .parent .now () 263 264class NormalizingAudioCollector (AudioCollectorFilter ): 265def __init__ (self ,parent :AudioCollector ): 266AudioCollectorFilter .__init__ (self ,parent ) 267 268def getAudio (self )-> bytes : 269audio = self .parent .getAudio () 270 271audio = AudioSegment (audio ,sample_width = self .stream .FRAME_SZ , 272frame_rate = self .stream .fps ,channels = self .stream .CHANNELS ) 273audio = audio .normalize () 274 275frames = np .array (audio .get_array_of_samples ()) 276frames = np .int16 (frames ).tobytes () 277 278return frames 279 280class CompressingAudioCollector (AudioCollectorFilter ): 281def __init__ (self ,parent :AudioCollector ): 282AudioCollectorFilter .__init__ (self ,parent ) 283 284def getAudio (self )-> bytes : 285audio = self .parent .getAudio () 286 287audio = AudioSegment (audio , 288sample_width = self .stream .FRAME_SZ , 289frame_rate = self .stream .fps , 290channels = self .stream .CHANNELS ) 291# subtle compression has a slight positive effect on my benchmark 292audio = audio .compress_dynamic_range (threshold =- 10 ,ratio = 2.0 ) 293 294frames = np .array (audio .get_array_of_samples ()) 295frames = np .int16 (frames ).tobytes () 296 297return frames 298 299class AudioSegmenter : 300def __init__ (self , 301min_silence_ms = 250 , 302max_speech_s = 5 , 303stream :AudioStream = None ): 304self .vad_options = vad .VadOptions ( 305min_silence_duration_ms = min_silence_ms , 306max_speech_duration_s = max_speech_s ) 307self .stream = stream 308pass 309 310def segmentAudio (self ,audio :bytes ): 311audio = np .frombuffer (audio , 312dtype = np .int16 ).flatten ().astype (np .float32 )/ 32768.0 313return vad .get_speech_timestamps (audio ,vad_options = self .vad_options ) 314 315# Returns the stable cutoff (if any) and whether there are any segments. 316def getStableCutoff (self ,audio :bytes )-> typing .Tuple [int ,bool ]: 317min_delta_frames = int ((self .vad_options .min_silence_duration_ms * 318self .stream .fps )/ 1000 ) 319cutoff = None 320 321last_end = None 322segments = self .segmentAudio (audio ) 323 324for i in range (len (segments )): 325s = segments [i ] 326#print(f"s: {s}") 327#print(f"last_end: {last_end}") 328 329if last_end : 330delta_frames = s ['start' ]- last_end 331#print(f"delta frames: {delta_frames}") 332if delta_frames > min_delta_frames : 333cutoff = s ['start' ] 334else : 335last_end = s ['end' ] 336 337if i == len (segments )- 1 : 338now = int (len (audio )/ self .stream .FRAME_SZ ) 339delta_frames = now - s ['end' ] 340if delta_frames > min_delta_frames : 341cutoff = now - int (min_delta_frames / 2 ) 342 343return (cutoff ,len (segments )> 0 ) 344 345def install_in_venv (pkgs :typing .List [str ])-> bool : 346pkgs_str = " " .join (pkgs ) 347f"Installing { pkgs_str } " ) 348pip_proc = subprocess .Popen ( 349f"Resources/Python/python.exe -m pip install { pkgs_str } --no-warn-script-location" .split (), 350stdout = subprocess .PIPE , 351stderr = subprocess .PIPE ) 352pip_stdout ,pip_stderr = pip_proc .communicate () 353pip_stdout = pip_stdout .decode ("utf-8" ) 354pip_stderr = pip_stderr .decode ("utf-8" ) 355pip_stdout ,file = sys .stderr ) 356pip_stderr ,file = sys .stderr ) 357if pip_proc .returncode != 0 : 358f"`pip install { pkgs_str } ` exited with { pip_proc . returncode } " , 359file = sys .stderr ) 360return False 361return True 362 363def saveAudio (audio :bytes ,path :str ,stream :AudioStream ): 364with wave .open (path ,'wb' )as wf : 365f"Saving audio to { path } " ,file = sys .stderr ) 366wf .setnchannels (stream .CHANNELS ) 367wf .setsampwidth (stream .FRAME_SZ ) 368wf .setframerate (stream .fps ) 369wf .writeframes (audio ) 370 371def concatenateWavFiles (output_path ): 372# List all .wav files in the CWD 373wav_files = [f for f in os .listdir ('.' )if f .endswith ('.wav' )] 374 375# Initialize parameters for wave file 376params = None 377 378# Open the output file 379with wave .open (output_path ,'wb' )as output_wav : 380for wav_file in wav_files : 381if os .path .abspath (wav_file )== os .path .abspath (output_path ): 382f"Skip adding output file ( { wav_file } ) to itself" ) 383continue 384f"Processing { wav_file } " ) 385with wave .open (wav_file ,'rb' )as input_wav : 386# Check if parameters are the same for each file 387if params is None : 388params = input_wav .getparams () 389output_wav .setparams (params ) 390 391# Read and write frames 392frames = input_wav .readframes (input_wav .getnframes ()) 393output_wav .writeframes (frames ) 394 395class AppControl : 396run = True 397app_ctrl = AppControl () 398 399def recordAudio ( 400mic_device :str , 401min_volume :float = - 1.3 , 402max_volume :float = - 0.8 403 ): 404app_ctrl .run = True 405 406stream = MicStream (mic_device ) 407stream_hd = MicStream (mic_device ,fps = 44100 ) 408 409collector = AudioCollector (stream ) 410#collector = NormalizingAudioCollector(collector) 411collector = CompressingAudioCollector (collector ) 412 413collector_hd = AudioCollector (stream_hd ) 414#collector_hd = NormalizingAudioCollector(collector_hd) 415collector_hd = CompressingAudioCollector (collector_hd ) 416 417min_silence_ms = 250 418max_speech_s = 30 419segmenter = AudioSegmenter ( 420min_silence_ms = min_silence_ms , 421max_speech_s = max_speech_s , 422stream = stream ) 423 424while app_ctrl .run : 425audio = collector .getAudio () 426collector_hd .getAudio () 427stable_cutoff ,has_audio = segmenter .getStableCutoff (audio ) 428 429#print(f"has audio: {has_audio}") 430#print(f"stable cutoff: {stable_cutoff}") 431 432if has_audio and stable_cutoff : 433commit_audio = collector .dropAudioPrefixByFrames (stable_cutoff ) 434f"stable cutoff: { stable_cutoff } " ) 435hd_cutoff = int (math .floor (stable_cutoff * stream_hd .fps / 436stream .fps )) 437f"hd cutoff: { hd_cutoff } " ) 438commit_audio_hd = collector_hd .dropAudioPrefixByFrames (hd_cutoff ) 439f"hd audio len: { len ( commit_audio_hd ) } " ) 440 441# Calculate naive measure of volume 442audio_v = AudioSegment (commit_audio_hd , 443sample_width = stream_hd .FRAME_SZ , 444frame_rate = stream_hd .fps , 445channels = stream_hd .CHANNELS ) 446audio_v = np .array (audio_v .get_array_of_samples ()) 447audio_v = np .int16 (audio_v ) 448audio_v = np .sqrt (np .mean (np .square (audio_v ))) 449audio_v /= np .sqrt (len (commit_audio_hd )/ stream_hd .FRAME_SZ ) 450audio_v = math .log (audio_v ,10 ) 451f"volume: { audio_v } " ) 452# cutoff is a fine-tuned value based on volumes seen while in vr 453# (index mic) 454if audio_v < min_volume or audio_v > max_volume : 455# Discard sample 456"Discarding too-quiet/too-loud segment" ) 457collector .keepLast (1.0 ) 458collector_hd .keepLast (1.0 ) 459continue 460 461 462ts = datetime .fromtimestamp (time .time ()) 463filename = str (ts .strftime ('%Y_%m_%d__%H-%M-%S' ))+ ".wav" 464saveAudio (commit_audio_hd ,filename ,stream_hd ) 465 466if not has_audio : 467#print("VAD detects no audio, skip transcription", file=sys.stderr) 468collector .keepLast (1.0 ) 469collector_hd .keepLast (1.0 ) 470"Stopped recording" ) 471 472class Segment : 473def __init__ (self , 474transcript :str , 475start_ts :float , 476end_ts :float , 477wall_ts :float , 478avg_logprob :float , 479no_speech_prob :float , 480compression_ratio :float ): 481self .transcript = transcript 482# start_ts, end_ts are timestamps in seconds relative to `wall_ts`. 483self .start_ts = start_ts 484self .end_ts = end_ts 485# wall_ts is the time.time() at which the oldest audio sample leading 486# to this transcript was collected. 487self .wall_ts = wall_ts 488self .avg_logprob = avg_logprob 489self .no_speech_prob = no_speech_prob 490self .compression_ratio = compression_ratio 491 492def __str__ (self ): 493ts = f"(ts: { self . start_ts } - { self . end_ts } ) " 494 495wall_ts_start = datetime .utcfromtimestamp (self .start_ts + self .wall_ts ).strftime ('%H:%M:%S' ) 496wall_ts_end = datetime .utcfromtimestamp (self .end_ts + self .wall_ts ).strftime ('%H:%M:%S' ) 497wall_ts = f"(wall ts: { wall_ts_start } - { wall_ts_end } ) " 498 499no_speech = f"(no_speech: { self . no_speech_prob } ) " 500avg_logprob = f"(avg_logprob: { self . avg_logprob } ) " 501return f" { self . transcript } " + ts + wall_ts + no_speech + avg_logprob 502 503def pipInstall (pkgs :typing .List [str ])-> bool : 504pkgs_str = " " .join (pkgs ) 505f"Installing { pkgs_str } " ) 506env = os .environ .copy () 507# cwd is set at top of __main__. We set PATH to ensure that installed 508# Python packages have access to any binaries that come with them. 509env ["PATH" ]= os .getcwd ()+ "/Python/Scripts;" + env ['PATH' ] 510pip_proc = subprocess .Popen ( 511f"./Python/python.exe -m pip install { pkgs_str } --no-warn-script-location" .split (), 512stdout = subprocess .PIPE , 513stderr = subprocess .PIPE , 514env = env ) 515pip_stdout ,pip_stderr = pip_proc .communicate () 516pip_stdout = pip_stdout .decode ("utf-8" ) 517pip_stderr = pip_stderr .decode ("utf-8" ) 518pip_stdout ,file = sys .stderr ) 519pip_stderr ,file = sys .stderr ) 520if pip_proc .returncode != 0 : 521f"`pip install { pkgs_str } ` exited with { pip_proc . returncode } " , 522file = sys .stderr ) 523return False 524return True 525 526class Whisper : 527def __init__ (self , 528collector :AudioCollector ): 529self .collector = collector 530 531import torch 532from transformers import pipeline 533 534whisper_model = "openai/whisper-large-v2" 535f"Loading pipeline for { whisper_model } ..." ) 536self .pipe = pipeline ( 537"automatic-speech-recognition" , 538model = "distil-whisper/distil-large-v2" , 539torch_dtype = torch .float16 , 540device = "cuda" , 541 ) 542f"Done." ) 543 544def transcribe (self ,frames :bytes = None )-> typing .List [Segment ]: 545if frames is None : 546frames = self .collector .getAudio () 547# Convert from signed 16-bit int [-32768, 32767] to signed 32-bit float on 548# [-1, 1]. 549audio = np .frombuffer (frames , 550dtype = np .int16 ).flatten ().astype (np .float32 )/ 32768.0 551 552t0 = time .time () 553res = self .pipe ( 554audio , 555chunk_length_s = 30 , 556batch_size = 1 ) 557 558result = [Segment (res ["text" ], 5590 , 5600 , 561self .collector .begin (), 5620 , 5630 , 5640 )] 565 566t1 = time .time () 567f"Transcription latency (s): { t1 - t0 } : { result [ 0 ]. transcript } " ) 568return result 569 570def getOutput ()-> str : 571sys .stdout .flush () 572with open ("output.log" ,"r" )as f : 573return f .read () 574 575def stopApp (): 576"Requesting app stop" ) 577app_ctrl .run = False 578 579def transcribeAudio (concatenated_path :str ): 580# Step 1: Install Whisper requirements 581"Installing Whisper dependencies, this will take several minutes" ) 582with open ("whisper_requirements.txt" ,"r" )as file : 583requirements = file .read ().splitlines () 584if not pipInstall (requirements ): 585return 586 587# Step 2: Iterate over .wav files in the current working directory 588"Loading Whisper model, this will take several minutes" ) 589whisper = Whisper (None ) 590for wav_file in os .listdir ('.' ): 591if wav_file .endswith ('.wav' ): 592if wav_file .endswith (os .path .basename (concatenated_path )): 593"Skipping concatenated file" ) 594continue 595 596# Step 3: Transcription pipeline 597# TODO parameterize high fidelity framerate 598f"Transcribing { wav_file } " ) 599disk_stream = DiskStream (wav_file ) 600collector = CompressingAudioCollector (AudioCollector (disk_stream )) 601whisper .collector = collector 602 603transcript_filename = wav_file .replace ('.wav' ,'.txt' ) 604if os .path .exists (transcript_filename ): 605f"Transcript already exists - skipping" ) 606continue 607 608# Transcribe the audio 609segments = whisper .transcribe () 610 611# Step 4: Save transcriptions 612with open (transcript_filename ,'w' )as txt_file : 613for segment in segments : 614txt_file .write (segment .transcript + '\n' ) 615f"Transcript generated at { transcript_filename } " ) 616 617if __name__ == "__main__" : 618abspath = os .path .abspath (__file__ ) 619dname = os .path .dirname (abspath ) 620os .chdir (dname ) 621 622sys .stdout = Logger ("output.log" ) 623 624f"Set cwd to { os . getcwd () } " ,file = sys .stderr ) 625 626with gr .Blocks ()as demo : 627mic_choices = MicStream .getMicDevices () 628mic_device = gr .Dropdown (choices = mic_choices ,label = "Microphone" ) 629min_volume = gr .Number (label = "Minimum volume" ,value =- 1.3 ) 630max_volume = gr .Number (label = "Maximum volume" ,value =- 0.8 ) 631record_audio = gr .Button ("Record audio" ) 632stop_recording = gr .Button ("Stop recording" ) 633transcribe_audio = gr .Button ("Transcribe audio" ) 634concatenated_path = gr .Text (label = "Combined audio filename" ,value = "combined.wav" ) 635min_length = gr .Number (label = "Minimum length (seconds)" ,value = 3.0 ) 636concatenate_audio = gr .Button ("Combine audio files" ) 637 638dbg_output = gr .Text (label = "Output" ) 639 640record_audio .click (recordAudio , [mic_device ,min_volume ,max_volume ], 641dbg_output ) 642stop_recording .click (stopApp , [],dbg_output ) 643 644transcribe_audio .click (transcribeAudio , [concatenated_path ],dbg_output ) 645 646concatenate_audio .click (concatenateWavFiles , [concatenated_path ], 647dbg_output ) 648 649demo .load (getOutput ,None ,dbg_output ,every = 0.5 ) 650demo .launch () 651sys .exit (0 ) 652 653concatenateWavFiles ("concatenated.wav" ) 654sys .exit (0 ) 655