yum-slop/TaSTT
Free self-hosted STT for VRChat.
git clone https://git.yummers.dev/yum-slop/TaSTT
a7f9b7b
master
1import app_config 2import argparse 3import io 4import keybind_event_machine 5from logger import log ,log_err 6from math import floor ,ceil 7import msvcrt 8import os 9import pygame 10from pythonosc import udp_client 11import sentencepiece as spm 12import steamvr 13from shared_thread_data import SharedThreadData 14import stt 15import sys 16import threading 17import time 18 19# Initialize pygame mixer 20pygame .mixer .init () 21 22TESTS_ENABLED = True 23 24# 0 = quiet, 1 = verbose, 2 = very verbose 25LOG_LEVEL = 0 26 27APP_ROOT = os .path .dirname (os .path .abspath (__file__ )) 28PROJECT_ROOT = os .path .dirname (APP_ROOT ) 29 30def get_tokenizer (): 31model_path = os .path .join (PROJECT_ROOT ,"custom_unigram_tokenizer_65k" ,"unigram.model" ) 32log (f"Loading SentencePiece tokenizer from: { model_path } " ) 33sp = spm .SentencePieceProcessor () 34sp .load (model_path ) 35log (f"Successfully loaded SentencePiece model. Vocab size: { sp . get_piece_size () } " ) 36return sp 37 38def parse_args (): 39parser = argparse .ArgumentParser () 40parser .add_argument ("--config" ,type = str ,help = "Path to config file (YAML)." ,required = True ) 41return parser .parse_args () 42 43def assert_equal (a ,b ): 44err_msg = f" { a } != { b } " 45assert a == b ,err_msg 46 47# Turn a whitespace-delimited string into a list of strings no longer than 48# `cols`. 49# Preferentially breaks strings at whitespace boundaries. Preserves whitespace 50# between words, except if that whitespace comes between lines. Breaks words 51# longer than `cols` with a hyphen. 52def wrap_line (line :str ,cols ): 53# First, split line into alternating chunks of words and whitespace. 54def get_sequences (line ): 55is_space = False 56sequences = [] 57seq_start = 0 58seq_end = - 1 59for i in range (0 ,len (line )): 60if line [i ].isspace (): 61if is_space : 62seq_end = i 63continue 64# We were looking at text, now we see whitespace. 65seq = line [seq_start :seq_end + 1 ] 66if len (seq )> 0 : 67sequences .append (seq ) 68seq_start = i 69seq_end = i 70is_space = True 71else : 72if not is_space : 73seq_end = i 74continue 75# We were looking at whitespace, now we see text. 76seq = line [seq_start :seq_end + 1 ] 77if len (seq )> 0 : 78sequences .append (seq ) 79seq_start = i 80seq_end = i 81is_space = False 82sequences .append (line [seq_start :seq_end + 1 ]) 83return sequences 84if TESTS_ENABLED : 85assert_equal (get_sequences ("foo" ), ["foo" ]) 86assert_equal (get_sequences ("foo bar" ), ["foo" ," " ,"bar" ]) 87assert_equal (get_sequences (" foo bar" ), [" " ,"foo" ," " ,"bar" ]) 88assert_equal (get_sequences (" foo bar" ), [" " ,"foo" ," " ,"bar" ]) 89 90# Next, greedily construct lines out of those sequences. 91# Whitespace gets treated specially. If it would push us over the limit, we 92# end the line and drop the whitespace. 93sequences = get_sequences (line ) 94def coalesce_sequences (sequences ,cols ): 95cur_line = "" 96lines = [] 97for seq in sequences : 98if len (cur_line )+ len (seq )<= cols : 99cur_line += seq 100continue 101if seq .isspace (): 102lines .append (cur_line ) 103cur_line = "" 104continue 105if len (cur_line )> 0 : 106lines .append (cur_line ) 107# Edge case: text sequence is longer than a line. 108while len (seq )> cols : 109seq_prefix = seq [0 :cols - 1 ]+ "-" 110seq = seq [cols - 1 :] 111lines .append (seq_prefix ) 112cur_line = seq 113if len (cur_line )> 0 : 114lines .append (cur_line ) 115return lines 116if TESTS_ENABLED : 117assert_equal (coalesce_sequences (get_sequences ("foo bar" ),3 ), ["foo" ,"bar" ]) 118assert_equal (coalesce_sequences (get_sequences ("foo bar" ),4 ), ["foo " ,"bar" ]) 119assert_equal (coalesce_sequences (get_sequences ("foo bar" ),4 ), ["foo" ,"bar" ]) 120assert_equal (coalesce_sequences (get_sequences ("foobar" ),3 ), ["fo-" ,"ob-" ,"ar" ]) 121assert_equal (coalesce_sequences (get_sequences ("f obar" ),3 ), ["f " ,"ob-" ,"ar" ]) 122 123lines = coalesce_sequences (sequences ,cols ) 124 125# Next, pad each line with whitespace. 126def pad_lines (lines ,cols ): 127for i in range (0 ,len (lines )): 128lines [i ]+= ' ' * (cols - len (lines [i ])) 129return lines 130if TESTS_ENABLED : 131assert_equal (pad_lines (["foo" ,"ba" ],4 ), ["foo " ,"ba " ]) 132assert_equal (pad_lines (["foo" ],2 ), ["foo" ]) 133 134return pad_lines (lines ,cols ) 135 136def get_blocks (lines ,tokenizer ,block_width ,num_blocks ): 137if LOG_LEVEL == 2 : 138log (f"Lines sent to tokenizer: { '' . join ( lines ) } " ) 139tokens = tokenizer .encode_as_ids ('' .join (lines )) 140if LOG_LEVEL == 2 : 141log (f"Tokens: { tokens } " ) 142pieces = [] 143for tok in tokens : 144piece = tokenizer .id_to_piece (tok ) 145pieces .append (piece ) 146if LOG_LEVEL == 2 : 147log (f"Pieces: { pieces } " ) 148 149# Group tokens into blocks and pad with empty characters. 150# Also get visual pointers - the location where each block will be rendered. 151def get_blocks (): 152blocks = [] 153visual_pointer = 0 154visual_pointers = [] 155for i in range (0 ,ceil (len (tokens )/ block_width )): 156visual_pointers .append (visual_pointer ) 157block = [] 158for j in range (0 ,block_width ): 159if i * block_width + j >= len (tokens ): 160# Pad block with empty characters. 65535 is a special token. 161block += [65535 ]* (block_width - len (block )) 162break 163block .append (tokens [i * block_width + j ]) 164visual_pointer += len (pieces [i * block_width + j ]) 165blocks .append (block ) 166return (blocks ,visual_pointers ) 167blocks ,visual_pointers = get_blocks () 168if LOG_LEVEL == 2 : 169log (f"Blocks: { blocks } " ) 170log (f"Visual pointers: { visual_pointers } " ) 171 172# Set all blocks up to the next `num_blocks` boundary to blank tokens. 173# This handles the edge case where a prior message wrote data there which 174# is covering up our new data. 175def pad_blocks (blocks ,visual_pointers ): 176cur_num_blocks = len (blocks ) 177num_pad_blocks = num_blocks - cur_num_blocks 178for i in range (0 ,num_pad_blocks ): 179blocks .append ([65535 ]* block_width ) 180visual_pointers .append (255 ) 181return blocks ,visual_pointers 182blocks ,visual_pointers = pad_blocks (blocks ,visual_pointers ) 183if LOG_LEVEL == 2 : 184log (f"Blocks (padded): { blocks } " ) 185log (f"Visual pointers (padded): { visual_pointers } " ) 186 187return blocks ,visual_pointers 188 189def calc_diff (prev_blocks ,prev_visual_pointers ,cur_blocks , 190cur_visual_pointers ): 191diff_indices = [] 192diff_blocks = [] 193diff_visual_pointers = [] 194 195for i in range (0 ,len (cur_blocks )): 196if i >= len (prev_blocks ): 197diff_blocks .append (cur_blocks [i ]) 198diff_visual_pointers .append (cur_visual_pointers [i ]) 199diff_indices .append (i ) 200continue 201if prev_blocks [i ]!= cur_blocks [i ]or prev_visual_pointers [i ]!= cur_visual_pointers [i ]: 202diff_blocks .append (cur_blocks [i ]) 203diff_visual_pointers .append (cur_visual_pointers [i ]) 204diff_indices .append (i ) 205 206return diff_indices ,diff_blocks ,diff_visual_pointers 207 208def send_data (osc_client ,indices ,blocks ,visual_pointers ): 209def split_blocks_by_byte (blocks ): 210blocks_byte00 = [] 211blocks_byte01 = [] 212for block in blocks : 213block_byte00 = [] 214block_byte01 = [] 215for datum in block : 216block_byte00 .append ((datum >> 0 )& 0xFF ) 217block_byte01 .append ((datum >> 8 )& 0xFF ) 218blocks_byte00 .append (block_byte00 ) 219blocks_byte01 .append (block_byte01 ) 220return blocks_byte00 ,blocks_byte01 221 222blocks_byte00 ,blocks_byte01 = split_blocks_by_byte (blocks ) 223if LOG_LEVEL == 2 : 224log (f"Blocks (byte 00): { blocks_byte00 } " ) 225log (f"Blocks (byte 01): { blocks_byte01 } " ) 226 227def send_osc (osc_client ,addr ,data ): 228osc_client .send_message (addr ,data ) 229 230for i in range (0 ,len (blocks )): 231lp_int = indices [i ] 232lp_param = "_Unigram_Letter_Grid_OSC_Pointer" 233addr = "/avatar/parameters/" + lp_param 234send_osc (osc_client ,addr ,lp_int ) 235 236vp_float = (- 127.5 + visual_pointers [i ])/ 127.5 237vp_param = f"_Unigram_Letter_Grid_OSC_Visual_Pointer" 238addr = "/avatar/parameters/" + vp_param 239send_osc (osc_client ,addr ,vp_float ) 240if LOG_LEVEL == 2 : 241log (f"Sending block { blocks [ i ] } at { visual_pointers [ i ] } index { indices [ i ] } " ) 242for j in range (0 ,len (blocks [i ])): 243byte00_float = (- 127.5 + blocks_byte00 [i ][j ])/ 127.5 244byte01_float = (- 127.5 + blocks_byte01 [i ][j ])/ 127.5 245byte00_param = f"_Unigram_Letter_Grid_OSC_Datum { j :02 } _Byte00" 246byte01_param = f"_Unigram_Letter_Grid_OSC_Datum { j :02 } _Byte01" 247addr = "/avatar/parameters/" + byte00_param 248send_osc (osc_client ,addr ,byte00_float ) 249addr = "/avatar/parameters/" + byte01_param 250send_osc (osc_client ,addr ,byte01_float ) 251time .sleep (0.34 ) 252 253def getOscClient (ip = "127.0.0.1" ,port = 9000 ): 254return udp_client .SimpleUDPClient (ip ,port ) 255 256class InputState : 257def __init__ (self ): 258self .page = 0 259# Initialize the known state of the board to empty array. This will cause 260# our paging logic to re-send everything the first time around. 261self .blocks = [] 262self .visual_pointers = [] 263pass 264 265def handle_input (state :InputState ,line :str ,tokenizer ,osc_client ,cfg ): 266line_wrapped = wrap_line (line ,cfg ["cols" ]) 267if TESTS_ENABLED : 268for line in line_wrapped : 269assert_equal (len (line ),cfg ["cols" ]) 270if LOG_LEVEL == 2 : 271log (f"Wrapped lines: { line_wrapped } " ) 272 273# Get several blank lines whenever we roll over. 274# It's better for the reader to have some continuity when the board pages 275# over. If we simply replaced the entire screen, it would be harder to 276# understand. 277line_rollover = cfg ["rows" ]- 2 278blank_line = ' ' * cfg ["cols" ] 279# We show a full page, then only `line_rollover` additional lines per page. 280end_ptr = cfg ["rows" ] 281which_page = 0 282while end_ptr < len (line_wrapped ): 283end_ptr += line_rollover 284which_page += 1 285if state .page != which_page : 286state .blocks = [] 287state .visual_pointers = [] 288state .page = which_page 289line_wrapped = line_wrapped [end_ptr - cfg ["rows" ]:] 290 291# Get blocks and visual pointers. 292blocks ,visual_pointers = get_blocks (line_wrapped ,tokenizer , 293cfg ["block_width" ],cfg ["num_blocks" ]) 294 295# Note that because we only send one page of data at a time, we don't have 296# to worry about wrapping visual pointers! We will basically never run out 297# of space. 298indices ,diff_blocks ,diff_visual_pointers = calc_diff (state .blocks ,state .visual_pointers ,blocks ,visual_pointers ) 299indices = [idx % cfg ["num_blocks" ]for idx in indices ] 300# Send only one block at a time to make things snappier in interactive use 301# case. 302# TODO use a continuation (yield) instead of returning. Then we can be a 303# little lighter on the cpu. Measurements show that this script is 304# already very light but we're clearly wasting a lot of work by 305# re-tokenizing the entire input every time we send a block. 306if len (indices )== 0 : 307return 308if indices [0 ]== len (state .blocks ): 309state .blocks .append (diff_blocks [0 ]) 310state .visual_pointers .append (diff_visual_pointers [0 ]) 311elif indices [0 ]> len (state .blocks ): 312log (f"This should never happen!" ) 313sys .exit (1 ) 314else : 315state .blocks [indices [0 ]]= diff_blocks [0 ] 316state .visual_pointers [indices [0 ]]= diff_visual_pointers [0 ] 317 318send_data (osc_client , [indices [0 ]], [diff_blocks [0 ]], [diff_visual_pointers [0 ]]) 319 320def osc_thread (shared_data :SharedThreadData ): 321osc_client = getOscClient () 322 323def join_segments (a ,b ): 324if len (a )> 0 and a [- 1 ]!= ' ' : 325return a + ' ' + b 326else : 327return a + b 328 329if shared_data .cfg ["use_builtin" ]: 330last_change = time .time () 331remote_word = "" 332while not shared_data .exit_event .is_set (): 333time .sleep (0.1 ) 334local_word = "" 335with shared_data .word_lock : 336local_word = join_segments (shared_data .transcript , 337shared_data .preview ) 338local_word = local_word [- 140 :] 339if local_word == remote_word : 340continue 341if time .time ()- last_change < 1.5 : 342continue 343addr = "/chatbox/input" 344if shared_data .cfg ["enable_debug_mode" ]: 345log (f"Send { local_word } " ) 346osc_client .send_message (addr , (local_word ,True ,False )) 347last_change = time .time () 348remote_word = local_word 349else : 350# Custom chatbox 351tokenizer = get_tokenizer () 352 353# Prime the board 354log ("Priming the board" ) 355input_state = InputState () 356handle_input (input_state ,"" ,tokenizer ,osc_client ,shared_data .cfg ) 357 358while not shared_data .exit_event .is_set (): 359word_copy = "" 360with shared_data .word_lock : 361word_copy = shared_data .word 362handle_input (input_state ,word_copy ,tokenizer ,osc_client ,shared_data .cfg ) 363time .sleep (0.01 ) 364 365 366def vrInputThread (shared_data :SharedThreadData ): 367RECORD_STATE = 0 368PAUSE_STATE = 1 369state = PAUSE_STATE 370 371hand_id = shared_data .cfg ["button_hand" ] 372button_id = shared_data .cfg ["button_type" ] 373 374# Rough description of state machine: 375# Single short press: toggle transcription 376# Medium press: dismiss custom chatbox 377# Long press: update chatbox in place 378# Medium press + long press: type transcription 379 380last_rising = time .time () 381last_medium_press_end = 0 382 383waveform0 = os .path .join (PROJECT_ROOT ,"Sounds/Noise_On_Quiet.wav" ) 384waveform1 = os .path .join (PROJECT_ROOT ,"Sounds/Noise_Off_Quiet.wav" ) 385waveform2 = os .path .join (PROJECT_ROOT ,"Sounds/Dismiss_Noise_Quiet.wav" ) 386waveform3 = os .path .join (PROJECT_ROOT ,"Sounds/KB_Noise_Off_Quiet.wav" ) 387 388button_generator = steamvr .pollButtonPress (hand = hand_id ,button = button_id , 389shared_data = shared_data ) 390while not shared_data .exit_event .is_set (): 391time .sleep (0.01 ) 392try : 393event = next (button_generator ) 394except StopIteration : 395break 396 397with shared_data .word_lock : 398if not shared_data .stream or not shared_data .collector : 399continue 400 401if event .opcode == steamvr .EVENT_RISING_EDGE : 402last_rising = time .time () 403 404if state == PAUSE_STATE : 405shared_data .stream .pause (False ) 406shared_data .stream .getSamples () 407 408elif event .opcode == steamvr .EVENT_FALLING_EDGE : 409now = time .time () 410if now - last_rising > 1.5 : 411# Long press: treat as the end of transcription. 412state = PAUSE_STATE 413 414shared_data .stream .pause (True ) 415 416if last_rising - last_medium_press_end < 1.0 : 417# Type transcription 418play_sound_with_volume (waveform3 ,shared_data .cfg ) 419else : 420play_sound_with_volume (waveform1 ,shared_data .cfg ) 421 422elif now - last_rising > 0.5 : 423# Medium press 424log_err ("CLEARING" ) 425last_medium_press_end = now 426state = PAUSE_STATE 427play_sound_with_volume (waveform2 ,shared_data .cfg ) 428 429# Flush the *entire* pipeline. 430shared_data .stream .pause (True ) 431shared_data .stream .getSamples () 432shared_data .collector .dropAudio () 433shared_data .transcript = "" 434shared_data .preview = "" 435continue 436 437# Short hold 438if state == RECORD_STATE : 439log_err ("PAUSED" ) 440state = PAUSE_STATE 441 442shared_data .stream .pause (True ) 443play_sound_with_volume (waveform1 ,shared_data .cfg ) 444elif state == PAUSE_STATE : 445log_err ("RECORDING" ) 446state = RECORD_STATE 447if shared_data .cfg ["reset_on_toggle" ]: 448if shared_data .cfg ["enable_debug_mode" ]: 449log_err ("Toggle detected, dropping transcript (3)" ) 450shared_data .transcript = "" 451shared_data .preview = "" 452#audio_state.drop_transcription = True 453else : 454if shared_data .cfg ["enable_debug_mode" ]: 455log_err ("Toggle detected, committing preview text (3)" ) 456#audio_state.text += audio_state.preview_text 457 458shared_data .stream .pause (False ) 459play_sound_with_volume (waveform0 ,shared_data .cfg ) 460 461 462def kbInputThread (shared_data :SharedThreadData ): 463machine = keybind_event_machine .KeybindEventMachine (shared_data .cfg ["keybind" ]) 464last_press_time = 0 465 466# double pressing the keybind 467double_press_timeout = 0.5 468 469RECORD_STATE = 0 470PAUSE_STATE = 1 471state = PAUSE_STATE 472 473waveform0 = os .path .join (PROJECT_ROOT ,"Sounds/Noise_On_Quiet.wav" ) 474waveform1 = os .path .join (PROJECT_ROOT ,"Sounds/Noise_Off_Quiet.wav" ) 475waveform2 = os .path .join (PROJECT_ROOT ,"Sounds/Dismiss_Noise_Quiet.wav" ) 476waveform3 = os .path .join (PROJECT_ROOT ,"Sounds/KB_Noise_Off_Quiet.wav" ) 477 478while not shared_data .exit_event .is_set (): 479time .sleep (0.01 ) 480 481cur_press_time = machine .getNextPressTime () 482if cur_press_time == 0 : 483continue 484 485with shared_data .word_lock : 486if not shared_data .stream or not shared_data .collector : 487continue 488 489EVENT_SINGLE_PRESS = 0 490EVENT_DOUBLE_PRESS = 1 491if last_press_time == 0 : 492event = EVENT_SINGLE_PRESS 493elif cur_press_time - last_press_time < double_press_timeout : 494event = EVENT_DOUBLE_PRESS 495else : 496event = EVENT_SINGLE_PRESS 497last_press_time = cur_press_time 498 499if event == EVENT_DOUBLE_PRESS : 500log_err ("CLEARING" ) 501state = PAUSE_STATE 502play_sound_with_volume (waveform2 ,shared_data .cfg ) 503 504# Flush the *entire* pipeline. 505shared_data .stream .pause (True ) 506shared_data .stream .getSamples () 507shared_data .collector .dropAudio () 508shared_data .transcript = "" 509shared_data .preview = "" 510continue 511 512# Short hold 513if state == RECORD_STATE : 514log_err ("PAUSED" ) 515state = PAUSE_STATE 516shared_data .stream .pause (True ) 517play_sound_with_volume (waveform1 ,shared_data .cfg ) 518elif state == PAUSE_STATE : 519log_err ("RECORDING" ) 520state = RECORD_STATE 521if shared_data .cfg ["reset_on_toggle" ]: 522if shared_data .cfg ["enable_debug_mode" ]: 523log_err ("Toggle detected, dropping transcript (2)" ) 524shared_data .transcript = "" 525shared_data .preview = "" 526else : 527if shared_data .cfg ["enable_debug_mode" ]: 528log_err ("Toggle detected, committing preview text (2)" ) 529shared_data .stream .pause (False ) 530play_sound_with_volume (waveform0 ,shared_data .cfg ) 531 532def play_sound_with_volume (filepath ,cfg ): 533"""Play a WAV file with adjusted volume""" 534volume = cfg .get ("volume" ,30 ) 535 536try : 537sound = pygame .mixer .Sound (filepath ) 538sound .set_volume (volume * 0.01 ) 539sound .play () 540except Exception as e : 541log_err (f"Error playing sound { filepath } : { e } " ) 542 543if __name__ == "__main__" : 544cli_args = parse_args () 545cfg = app_config .getConfig (cli_args .config ) 546shared_data = SharedThreadData (cfg ) 547osc_thread = threading .Thread ( 548target = osc_thread , 549args = (shared_data ,)) 550osc_thread .start () 551 552transcribe_thread = threading .Thread ( 553target = stt .transcriptionThread , 554args = (shared_data ,)) 555transcribe_thread .start () 556 557vr_input_thd = threading .Thread (target = vrInputThread ,args = (shared_data ,)) 558vr_input_thd .start () 559 560kb_input_thd = threading .Thread (target = kbInputThread ,args = (shared_data ,)) 561kb_input_thd .start () 562 563word_is_over = False 564local_word = "" 565while True : 566time .sleep (0.1 ) 567continue 568shared_data .exit_event .set () 569osc_thread .join () 570transcribe_thread .join () 571vr_input_thd .join () 572kb_input_thd .join () 573