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