GameSentenceMiner 2.11.7__py3-none-any.whl → 2.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -384,7 +384,6 @@ class TextFiltering:
384
384
  block_filtered = self.latin_extended_regex.findall(block)
385
385
  else:
386
386
  block_filtered = self.latin_extended_regex.findall(block)
387
-
388
387
  if block_filtered:
389
388
  orig_text_filtered.append(''.join(block_filtered))
390
389
  else:
@@ -548,6 +547,39 @@ class ScreenshotThread(threading.Thread):
548
547
  else:
549
548
  raise ValueError('Window capture is only currently supported on Windows and macOS')
550
549
 
550
+ def __del__(self):
551
+ if self.macos_window_tracker_instance:
552
+ self.macos_window_tracker_instance.join()
553
+ elif self.windows_window_tracker_instance:
554
+ self.windows_window_tracker_instance.join()
555
+
556
+ def setup_persistent_windows_window_tracker(self):
557
+ global window_open
558
+ window_open = False
559
+ def setup_tracker():
560
+ global window_open
561
+ self.window_handle, window_title = self.get_windows_window_handle(self.screen_capture_window)
562
+
563
+ if not self.window_handle:
564
+ # print(f"Window '{screen_capture_window}' not found.")
565
+ return
566
+
567
+ set_dpi_awareness()
568
+ window_open = True
569
+ self.windows_window_tracker_instance = threading.Thread(target=self.windows_window_tracker)
570
+ self.windows_window_tracker_instance.start()
571
+ logger.opt(ansi=True).info(f'Selected window: {window_title}')
572
+
573
+ while not terminated:
574
+ if not window_open:
575
+ try:
576
+ setup_tracker()
577
+ except ValueError as e:
578
+ logger.error(f"Error setting up persistent windows window tracker: {e}")
579
+ break
580
+ time.sleep(5)
581
+
582
+
551
583
  def get_windows_window_handle(self, window_title):
552
584
  def callback(hwnd, window_title_part):
553
585
  window_title = win32gui.GetWindowText(hwnd)
@@ -570,7 +602,7 @@ class ScreenshotThread(threading.Thread):
570
602
 
571
603
  def windows_window_tracker(self):
572
604
  found = True
573
- while not terminated:
605
+ while not terminated or window_open:
574
606
  found = win32gui.IsWindow(self.window_handle)
575
607
  if not found:
576
608
  break
@@ -1086,10 +1118,11 @@ def signal_handler(sig, frame):
1086
1118
 
1087
1119
 
1088
1120
  def on_window_closed(alive):
1089
- global terminated
1121
+ global terminated, window_open
1090
1122
  if not (alive or terminated):
1091
1123
  logger.info('Window closed or error occurred, terminated!')
1092
- terminated = True
1124
+ window_open = False
1125
+ # terminated = True
1093
1126
 
1094
1127
 
1095
1128
  def on_screenshot_combo():
@@ -15,6 +15,7 @@ from enum import Enum
15
15
  import toml
16
16
  from dataclasses_json import dataclass_json
17
17
 
18
+
18
19
  OFF = 'OFF'
19
20
  # VOSK = 'VOSK'
20
21
  SILERO = 'SILERO'
@@ -71,10 +72,240 @@ class Language(Enum):
71
72
  PORTUGUESE = "pt"
72
73
  HINDI = "hi"
73
74
  ARABIC = "ar"
75
+ TURKISH = "tr"
76
+ DUTCH = "nl"
77
+ SWEDISH = "sv"
78
+ FINNISH = "fi"
79
+ DANISH = "da"
80
+ NORWEGIAN = "no"
81
+
74
82
 
75
83
  AVAILABLE_LANGUAGES = [lang.value for lang in Language]
76
84
  AVAILABLE_LANGUAGES_DICT = {lang.value: lang for lang in Language}
77
85
 
86
+ class CommonLanguages(str, Enum):
87
+ """
88
+ An Enum of the world's most common languages, based on total speaker count.
89
+
90
+ The enum member is the common English name (e.g., ENGLISH) and its
91
+ value is the ISO 639-1 two-letter code (e.g., 'en').
92
+
93
+ Inheriting from `str` allows for direct comparison and use in functions
94
+ that expect a string, e.g., `CommonLanguages.FRENCH == 'fr'`.
95
+
96
+ This list is curated from Wikipedia's "List of languages by total number of speakers"
97
+ and contains over 200 entries to provide broad but practical coverage.
98
+ """
99
+ ENGLISH = 'en'
100
+ AFRIKAANS = 'af'
101
+ AKAN = 'ak'
102
+ ALBANIAN = 'sq'
103
+ ALGERIAN_SPOKEN_ARABIC = 'arq'
104
+ AMHARIC = 'am'
105
+ ARMENIAN = 'hy'
106
+ ASSAMESE = 'as'
107
+ BAMBARA = 'bm'
108
+ BASQUE = 'eu'
109
+ BELARUSIAN = 'be'
110
+ BENGALI = 'bn'
111
+ BHOJPURI = 'bho'
112
+ BOSNIAN = 'bs'
113
+ BODO = 'brx'
114
+ BULGARIAN = 'bg'
115
+ BURMESE = 'my'
116
+ CAPE_VERDEAN_CREOLE = 'kea'
117
+ CATALAN = 'ca'
118
+ CEBUANO = 'ceb'
119
+ CHHATTISGARHI = 'hns'
120
+ CHITTAGONIAN = 'ctg'
121
+ CROATIAN = 'hr'
122
+ CZECH = 'cs'
123
+ DANISH = 'da'
124
+ DECCAN = 'dcc'
125
+ DOGRI = 'doi'
126
+ DZONGKHA = 'dz'
127
+ DUTCH = 'nl'
128
+ EGYPTIAN_SPOKEN_ARABIC = 'arz'
129
+ ESTONIAN = 'et'
130
+ EWE = 'ee'
131
+ FAROESE = 'fo'
132
+ FIJIAN = 'fj'
133
+ FINNISH = 'fi'
134
+ FRENCH = 'fr'
135
+ GALICIAN = 'gl'
136
+ GAN_CHINESE = 'gan'
137
+ GEORGIAN = 'ka'
138
+ GERMAN = 'de'
139
+ GREEK = 'el'
140
+ GREENLANDIC = 'kl'
141
+ GUJARATI = 'gu'
142
+ HAITIAN_CREOLE = 'ht'
143
+ HAUSA = 'ha'
144
+ HAKKA_CHINESE = 'hak'
145
+ HARYANVI = 'bgc'
146
+ HEBREW = 'he'
147
+ HINDI = 'hi'
148
+ HUNGARIAN = 'hu'
149
+ ICELANDIC = 'is'
150
+ IGBO = 'ig'
151
+ INDONESIAN = 'id'
152
+ IRANIAN_PERSIAN = 'fa'
153
+ IRISH = 'ga'
154
+ ITALIAN = 'it'
155
+ JAVANESE = 'jv'
156
+ JAMAICAN_PATOIS = 'jam'
157
+ JAPANESE = 'ja'
158
+ KANNADA = 'kn'
159
+ KASHMIRI = 'ks'
160
+ KAZAKH = 'kk'
161
+ KHMER = 'km'
162
+ KONGO = 'kg'
163
+ KONKANI = 'kok'
164
+ KOREAN = 'ko'
165
+ KURDISH = 'kmr'
166
+ LAO = 'lo'
167
+ LATVIAN = 'lv'
168
+ LINGALA = 'ln'
169
+ LITHUANIAN = 'lt'
170
+ LUBA_KASAI = 'lua'
171
+ LUXEMBOURGISH = 'lb'
172
+ MACEDONIAN = 'mk'
173
+ MADURESE = 'mad'
174
+ MAGAHI = 'mag'
175
+ MAITHILI = 'mai'
176
+ MALAGASY = 'mg'
177
+ MALAYALAM = 'ml'
178
+ MALTESE = 'mt'
179
+ MANDARIN_CHINESE = 'zh'
180
+ MANIPURI = 'mni'
181
+ MARATHI = 'mr'
182
+ MAORI = 'mi'
183
+ MAURITIAN_CREOLE = 'mfe'
184
+ MIN_NAN_CHINESE = 'nan'
185
+ MINANGKABAU = 'min'
186
+ MONGOLIAN = 'mn'
187
+ MONTENEGRIN = 'cnr'
188
+ MOROCCAN_SPOKEN_ARABIC = 'ary'
189
+ NDEBELE = 'nr'
190
+ NEPALI = 'ne'
191
+ NIGERIAN_PIDGIN = 'pcm'
192
+ NORTHERN_KURDISH = 'kmr'
193
+ NORTHERN_PASHTO = 'pbu'
194
+ NORTHERN_UZBEK = 'uz'
195
+ NORWEGIAN = 'no'
196
+ ODIA = 'or'
197
+ PAPIAMENTO = 'pap'
198
+ POLISH = 'pl'
199
+ PORTUGUESE = 'pt'
200
+ ROMANIAN = 'ro'
201
+ RWANDA = 'rw'
202
+ RUSSIAN = 'ru'
203
+ SAMOAN = 'sm'
204
+ SANTALI = 'sat'
205
+ SARAIKI = 'skr'
206
+ SCOTTISH_GAELIC = 'gd'
207
+ SEYCHELLOIS_CREOLE = 'crs'
208
+ SERBIAN = 'sr'
209
+ SHONA = 'sn'
210
+ SINDHI = 'sd'
211
+ SINHALA = 'si'
212
+ SLOVAK = 'sk'
213
+ SLOVENIAN = 'sl'
214
+ SOMALI = 'so'
215
+ SOTHO = 'st'
216
+ SOUTH_AZERBAIJANI = 'azb'
217
+ SOUTHERN_PASHTO = 'ps'
218
+ SPANISH = 'es'
219
+ STANDARD_ARABIC = 'ar'
220
+ SUDANESE_SPOKEN_ARABIC = 'apd'
221
+ SUNDANESE = 'su'
222
+ SWAHILI = 'sw'
223
+ SWATI = 'ss'
224
+ SWEDISH = 'sv'
225
+ SYLHETI = 'syl'
226
+ TAGALOG = 'tl'
227
+ TAMIL = 'ta'
228
+ TELUGU = 'te'
229
+ THAI = 'th'
230
+ TIGRINYA = 'ti'
231
+ TIBETAN = 'bo'
232
+ TONGAN = 'to'
233
+ TSONGA = 'ts'
234
+ TSWANA = 'tn'
235
+ TWI = 'twi'
236
+ UKRAINIAN = 'uk'
237
+ URDU = 'ur'
238
+ UYGHUR = 'ug'
239
+ VENDA = 've'
240
+ VIETNAMESE = 'vi'
241
+ WELSH = 'cy'
242
+ WESTERN_PUNJABI = 'pnb'
243
+ WOLOF = 'wo'
244
+ WU_CHINESE = 'wuu'
245
+ XHOSA = 'xh'
246
+ YORUBA = 'yo'
247
+ YUE_CHINESE = 'yue'
248
+ ZULU = 'zu'
249
+
250
+
251
+ # Helper methods
252
+ @classmethod
253
+ def get_all_codes(cls) -> list[str]:
254
+ """Returns a list of all language codes (e.g., ['en', 'zh', 'hi'])."""
255
+ return [lang.value for lang in cls]
256
+
257
+ @classmethod
258
+ def get_all_names(cls) -> list[str]:
259
+ """Returns a list of all language names (e.g., ['ENGLISH', 'MANDARIN_CHINESE'])."""
260
+ return [lang.name for lang in cls]
261
+
262
+ @classmethod
263
+ def get_all_names_pretty(cls) -> list[str]:
264
+ """Returns a list of all language names formatted for display (e.g., ['English', 'Mandarin Chinese'])."""
265
+ return [lang.name.replace('_', ' ').title() for lang in cls]
266
+
267
+ @classmethod
268
+ def get_choices(cls) -> list[tuple[str, str]]:
269
+ """
270
+ Returns a list of (value, label) tuples for use in web framework
271
+ choice fields (e.g., Django, Flask).
272
+
273
+ Example: [('en', 'English'), ('zh', 'Mandarin Chinese')]
274
+ """
275
+ return [(lang.value, lang.name.replace('_', ' ').title()) for lang in cls]
276
+
277
+ # Method to lookup language by it's name
278
+ @classmethod
279
+ def from_name(cls, name: str) -> 'CommonLanguages':
280
+ """
281
+ Looks up a language by its name (e.g., 'ENGLISH') and returns the corresponding enum member.
282
+ Raises ValueError if not found.
283
+ """
284
+ try:
285
+ return cls[name.upper()]
286
+ except KeyError:
287
+ raise ValueError(f"Language '{name}' not found in CommonLanguages")
288
+
289
+ # Method to lookup language by its code
290
+ @classmethod
291
+ def from_code(cls, code: str) -> 'CommonLanguages':
292
+ """
293
+ Looks up a language by its code (e.g., 'en') and returns the corresponding enum member.
294
+ Raises ValueError if not found.
295
+ """
296
+ for lang in cls:
297
+ if lang.value == code:
298
+ return lang
299
+ raise ValueError(f"Language code '{code}' not found in CommonLanguages")
300
+
301
+ @classmethod
302
+ def name_from_code(cls, code: str) -> str:
303
+ """
304
+ Returns the name of the language given its code (e.g., 'en' -> 'ENGLISH').
305
+ Raises ValueError if not found.
306
+ """
307
+ return cls.from_code(code).name
308
+
78
309
  @dataclass_json
79
310
  @dataclass
80
311
  class General:
@@ -86,6 +317,13 @@ class General:
86
317
  open_multimine_on_startup: bool = True
87
318
  texthook_replacement_regex: str = ""
88
319
  texthooker_port: int = 55000
320
+ native_language: str = CommonLanguages.ENGLISH.value
321
+
322
+ def get_native_language_name(self) -> str:
323
+ try:
324
+ return CommonLanguages.name_from_code(self.native_language)
325
+ except ValueError:
326
+ return "Unknown"
89
327
 
90
328
 
91
329
  @dataclass_json
@@ -253,7 +491,6 @@ class Advanced:
253
491
  multi_line_sentence_storage_field: str = ''
254
492
  ocr_websocket_port: int = 9002
255
493
  texthooker_communication_websocket_port: int = 55001
256
- use_anki_note_creation_time: bool = True
257
494
 
258
495
  def __post_init__(self):
259
496
  if self.plaintext_websocket_port == -1:
@@ -284,6 +521,16 @@ class Ai:
284
521
  self.provider = AI_GEMINI
285
522
  if self.provider == 'groq':
286
523
  self.provider = AI_GROQ
524
+
525
+
526
+ # Experimental Features section, will change often
527
+ @dataclass_json
528
+ @dataclass
529
+ class WIP:
530
+ overlay_websocket_port: int = 55499
531
+ overlay_websocket_send: bool = False
532
+
533
+
287
534
 
288
535
  @dataclass_json
289
536
  @dataclass
@@ -301,7 +548,15 @@ class ProfileConfig:
301
548
  vad: VAD = field(default_factory=VAD)
302
549
  advanced: Advanced = field(default_factory=Advanced)
303
550
  ai: Ai = field(default_factory=Ai)
304
-
551
+ wip: WIP = field(default_factory=WIP)
552
+
553
+
554
+ def get_field_value(self, section: str, field_name: str):
555
+ section_obj = getattr(self, section, None)
556
+ if section_obj and hasattr(section_obj, field_name):
557
+ return getattr(section_obj, field_name)
558
+ else:
559
+ raise ValueError(f"Field '{field_name}' not found in section '{section}' of ProfileConfig.")
305
560
 
306
561
  # This is just for legacy support
307
562
  def load_from_toml(self, file_path: str):
@@ -482,6 +737,7 @@ class Config:
482
737
  self.sync_shared_field(config, profile, "advanced")
483
738
  self.sync_shared_field(config, profile, "paths")
484
739
  self.sync_shared_field(config, profile, "obs")
740
+ self.sync_shared_field(config, profile, "wip")
485
741
  self.sync_shared_field(config.ai, profile.ai, "anki_field")
486
742
  self.sync_shared_field(config.ai, profile.ai, "provider")
487
743
  self.sync_shared_field(config.ai, profile.ai, "api_key")
@@ -743,4 +999,7 @@ anki_results = {}
743
999
  gsm_state = GsmAppState()
744
1000
  is_dev = is_running_from_source()
745
1001
 
1002
+ is_beangate = os.path.exists("C:/Users/Beangate")
1003
+
746
1004
  logger.debug(f"Running in development mode: {is_dev}")
1005
+ logger.debug(f"Running on Beangate's PC: {is_beangate}")
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import Optional, List
3
+ from enum import Enum
3
4
 
4
5
  from dataclasses_json import dataclass_json
5
6
 
@@ -113,8 +113,8 @@ def similar(a, b):
113
113
 
114
114
 
115
115
  def lines_match(texthooker_sentence, anki_sentence):
116
- texthooker_sentence = texthooker_sentence.replace("\n", "").replace("\r", "").strip()
117
- anki_sentence = anki_sentence.replace("\n", "").replace("\r", "").strip()
116
+ texthooker_sentence = texthooker_sentence.replace("\n", "").replace("\r", "").replace(' ', '').strip()
117
+ anki_sentence = anki_sentence.replace("\n", "").replace("\r", "").replace(' ', '').strip()
118
118
  similarity = similar(texthooker_sentence, anki_sentence)
119
119
  if texthooker_sentence in anki_sentence:
120
120
  logger.debug(f"One contains the other: {texthooker_sentence} in {anki_sentence} - Similarity: {similarity}")
@@ -1,9 +1,11 @@
1
+ import sys
1
2
  import win32gui
2
3
  import win32con
3
4
  import win32api
4
5
  import keyboard
5
6
  import time
6
7
  import threading
8
+ import signal
7
9
 
8
10
  from GameSentenceMiner.util.configuration import logger
9
11
 
@@ -137,6 +139,22 @@ def mouse_monitor_loop():
137
139
  # A small delay to reduce CPU usage
138
140
  time.sleep(0.1)
139
141
 
142
+ class HandleSTDINThread(threading.Thread):
143
+ def run(self):
144
+ while True:
145
+ try:
146
+ line = input()
147
+ if "exit" in line.strip().lower():
148
+ handle_quit()
149
+ break
150
+ except EOFError:
151
+ break
152
+
153
+ def handle_quit():
154
+ if is_toggled and target_hwnd:
155
+ reset_window_state(target_hwnd)
156
+ logger.info("Exiting Window Transparency Tool.")
157
+
140
158
  # --- Main Execution Block ---
141
159
 
142
160
  if __name__ == "__main__":
@@ -155,8 +173,18 @@ if __name__ == "__main__":
155
173
  # Register the global hotkey
156
174
  keyboard.add_hotkey(hotkey, toggle_functionality)
157
175
 
176
+ # Handle SigINT/SigTERM gracefully
177
+ def signal_handler(sig, frame):
178
+ handle_quit()
179
+ sys.exit(0)
180
+
181
+ signal.signal(signal.SIGINT, signal_handler)
182
+ signal.signal(signal.SIGTERM, signal_handler)
183
+
158
184
  logger.info(f"Script running. Press '{hotkey}' on a window to toggle transparency.")
159
185
  logger.info("Press Ctrl+C in this console to exit.")
186
+
187
+ HandleSTDINThread().start()
160
188
 
161
189
  # Keep the script running to listen for the hotkey.
162
190
  # keyboard.wait() is a blocking call that waits indefinitely.
GameSentenceMiner/vad.py CHANGED
@@ -136,7 +136,7 @@ class VADProcessor(ABC):
136
136
  if get_config().vad.cut_and_splice_segments:
137
137
  self.extract_audio_and_combine_segments(input_audio, voice_activity, output_audio, padding=get_config().vad.splice_padding)
138
138
  else:
139
- ffmpeg.trim_audio(input_audio, start_time + get_config().vad.beginning_offset, end_time + get_config().audio.end_offset, output_audio, trim_beginning=get_config().vad.trim_beginning, fade_in_duration=0.05, fade_out_duration=00)
139
+ ffmpeg.trim_audio(input_audio, start_time + get_config().vad.beginning_offset, end_time + get_config().audio.end_offset, output_audio, trim_beginning=get_config().vad.trim_beginning, fade_in_duration=0.05, fade_out_duration=0)
140
140
  return VADResult(True, start_time + get_config().vad.beginning_offset, end_time + get_config().audio.end_offset, self.vad_system_name, voice_activity, output_audio)
141
141
 
142
142
  class SileroVADProcessor(VADProcessor):
@@ -268,6 +268,11 @@ async def add_event_to_texthooker(line: GameLine):
268
268
  })
269
269
  if get_config().advanced.plaintext_websocket_port:
270
270
  await plaintext_websocket_server_thread.send_text(line.text)
271
+
272
+
273
+ async def send_word_coordinates_to_overlay(boxes):
274
+ if boxes and len(boxes) > 0 and overlay_server_thread:
275
+ await overlay_server_thread.send_text(boxes)
271
276
 
272
277
 
273
278
  @app.route('/update_checkbox', methods=['POST'])
@@ -384,19 +389,18 @@ def start_web_server():
384
389
  app.run(host='0.0.0.0', port=port, debug=False) # debug=True provides helpful error messages during development
385
390
 
386
391
 
387
- websocket_server_thread = None
388
392
  websocket_queue = queue.Queue()
389
393
  paused = False
390
394
 
391
395
 
392
396
  class WebsocketServerThread(threading.Thread):
393
- def __init__(self, read, ws_port):
397
+ def __init__(self, read, get_ws_port_func):
394
398
  super().__init__(daemon=True)
395
399
  self._loop = None
396
400
  self.read = read
397
401
  self.clients = set()
398
402
  self._event = threading.Event()
399
- self.ws_port = ws_port
403
+ self.get_ws_port_func = get_ws_port_func
400
404
  self.backedup_text = []
401
405
 
402
406
  @property
@@ -437,10 +441,13 @@ class WebsocketServerThread(threading.Thread):
437
441
 
438
442
  async def send_text(self, text):
439
443
  if text:
440
- if isinstance(text, dict):
444
+ if isinstance(text, dict) or isinstance(text, list):
441
445
  text = json.dumps(text)
442
446
  return asyncio.run_coroutine_threadsafe(
443
447
  self.send_text_coroutine(text), self.loop)
448
+
449
+ def has_clients(self):
450
+ return len(self.clients) > 0
444
451
 
445
452
  def stop_server(self):
446
453
  self.loop.call_soon_threadsafe(self._stop_event.set)
@@ -454,7 +461,7 @@ class WebsocketServerThread(threading.Thread):
454
461
  try:
455
462
  self.server = start_server = websockets.serve(self.server_handler,
456
463
  "0.0.0.0",
457
- self.ws_port,
464
+ self.get_ws_port_func(),
458
465
  max_size=1000000000)
459
466
  async with start_server:
460
467
  await stop_event.wait()
@@ -469,21 +476,24 @@ def handle_exit_signal(loop):
469
476
  logger.info("Received exit signal. Shutting down...")
470
477
  for task in asyncio.all_tasks(loop):
471
478
  task.cancel()
479
+
480
+ websocket_server_thread = WebsocketServerThread(read=True, get_ws_port_func=lambda : get_config().get_field_value('advanced', 'texthooker_communication_websocket_port'))
481
+ websocket_server_thread.start()
482
+
483
+ if get_config().advanced.plaintext_websocket_port:
484
+ plaintext_websocket_server_thread = WebsocketServerThread(read=False, get_ws_port_func=lambda : get_config().get_field_value('advanced', 'plaintext_websocket_port'))
485
+ plaintext_websocket_server_thread.start()
486
+
487
+ overlay_server_thread = WebsocketServerThread(read=False, get_ws_port_func=lambda : get_config().get_field_value('wip', 'overlay_websocket_port'))
488
+ overlay_server_thread.start()
472
489
 
473
490
  async def texthooker_page_coro():
474
- global websocket_server_thread, plaintext_websocket_server_thread
491
+ global websocket_server_thread, plaintext_websocket_server_thread, overlay_server_thread
475
492
  # Run the WebSocket server in the asyncio event loop
476
493
  flask_thread = threading.Thread(target=start_web_server)
477
494
  flask_thread.daemon = True
478
495
  flask_thread.start()
479
496
 
480
- websocket_server_thread = WebsocketServerThread(read=True, ws_port=get_config().advanced.texthooker_communication_websocket_port)
481
- websocket_server_thread.start()
482
-
483
- if get_config().advanced.plaintext_websocket_port:
484
- plaintext_websocket_server_thread = WebsocketServerThread(read=False, ws_port=get_config().advanced.plaintext_websocket_port)
485
- plaintext_websocket_server_thread.start()
486
-
487
497
  # Keep the main asyncio event loop running (for the WebSocket server)
488
498
 
489
499
  def run_text_hooker_page():