stt_cli_client.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. """
  2. This is a command-line client for the Speech-to-Text (STT) server.
  3. It records audio from the default input device and sends it to the server for speech recognition.
  4. It can also process commands to set parameters, get parameter values, or call methods on the server.
  5. Usage:
  6. stt [--control-url CONTROL_URL] [--data-url DATA_URL] [--debug] [--norealtime] [--set-param PARAM VALUE] [--call-method METHOD [ARGS ...]] [--get-param PARAM]
  7. Options:
  8. --control-url CONTROL_URL STT Control WebSocket URL
  9. --data-url DATA_URL STT Data WebSocket URL
  10. --debug Enable debug mode
  11. --norealtime Disable real-time output
  12. --set-param PARAM VALUE Set a recorder parameter. Can be used multiple times.
  13. --call-method METHOD [ARGS ...] Call a recorder method with optional arguments.
  14. --get-param PARAM Get the value of a recorder parameter. Can be used multiple times.
  15. """
  16. from urllib.parse import urlparse
  17. from queue import Queue
  18. import subprocess
  19. import threading
  20. import websocket
  21. import argparse
  22. import pyaudio
  23. import struct
  24. import socket
  25. import shutil
  26. import queue
  27. import json
  28. import time
  29. import wave
  30. import sys
  31. import os
  32. os.environ['ALSA_LOG_LEVEL'] = 'none'
  33. # Constants
  34. CHUNK = 1024
  35. FORMAT = pyaudio.paInt16
  36. CHANNELS = 1
  37. RATE = 44100
  38. DEFAULT_CONTROL_URL = "ws://127.0.0.1:8011"
  39. DEFAULT_DATA_URL = "ws://127.0.0.1:8012"
  40. # Initialize colorama
  41. from colorama import init, Fore, Style
  42. init()
  43. # Stop websocket from spamming the log
  44. websocket.enableTrace(False)
  45. class STTWebSocketClient:
  46. def __init__(self, control_url, data_url, debug=False, file_output=None, norealtime=False, writechunks=None):
  47. self.control_url = control_url
  48. self.data_url = data_url
  49. self.control_ws = None
  50. self.data_ws_app = None
  51. self.data_ws_connected = None # WebSocket object that will be used for sending
  52. self.is_running = True
  53. self.debug = debug
  54. self.file_output = file_output
  55. self.last_text = ""
  56. self.console_width = shutil.get_terminal_size().columns
  57. self.recording_indicator = "🔴"
  58. self.norealtime = norealtime
  59. self.connection_established = threading.Event()
  60. self.message_queue = Queue()
  61. self.commands = Queue()
  62. self.stop_event = threading.Event()
  63. self.chunks_sent = 0
  64. self.last_chunk_time = time.time()
  65. self.writechunks = writechunks # Add this to store the file name for writing audio chunks
  66. self.debug_print("Initializing STT WebSocket Client")
  67. self.debug_print(f"Control URL: {control_url}")
  68. self.debug_print(f"Data URL: {data_url}")
  69. self.debug_print(f"File Output: {file_output}")
  70. self.debug_print(f"No Realtime: {norealtime}")
  71. self.debug_print(f"Write Chunks: {writechunks}")
  72. # Audio attributes
  73. self.audio_interface = None
  74. self.stream = None
  75. self.device_sample_rate = None
  76. self.input_device_index = None
  77. # Threads
  78. self.control_ws_thread = None
  79. self.data_ws_thread = None
  80. self.recording_thread = None
  81. def debug_print(self, message):
  82. if self.debug:
  83. timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
  84. thread_name = threading.current_thread().name
  85. print(f"{Fore.CYAN}[DEBUG][{timestamp}][{thread_name}] {message}{Style.RESET_ALL}", file=sys.stderr)
  86. def connect(self):
  87. if not self.ensure_server_running():
  88. self.debug_print("Cannot start STT server. Exiting.")
  89. return False
  90. try:
  91. self.debug_print("Attempting to establish WebSocket connections...")
  92. # Connect to control WebSocket
  93. self.debug_print(f"Connecting to control WebSocket at {self.control_url}")
  94. self.control_ws = websocket.WebSocketApp(self.control_url,
  95. on_message=self.on_control_message,
  96. on_error=self.on_error,
  97. on_close=self.on_close,
  98. on_open=self.on_control_open)
  99. self.control_ws_thread = threading.Thread(target=self.control_ws.run_forever)
  100. self.control_ws_thread.daemon = False
  101. self.debug_print("Starting control WebSocket thread")
  102. self.control_ws_thread.start()
  103. # Connect to data WebSocket
  104. self.debug_print(f"Connecting to data WebSocket at {self.data_url}")
  105. self.data_ws_app = websocket.WebSocketApp(self.data_url,
  106. on_message=self.on_data_message,
  107. on_error=self.on_error,
  108. on_close=self.on_close,
  109. on_open=self.on_data_open)
  110. self.data_ws_thread = threading.Thread(target=self.data_ws_app.run_forever)
  111. self.data_ws_thread.daemon = False
  112. self.debug_print("Starting data WebSocket thread")
  113. self.data_ws_thread.start()
  114. self.debug_print("Waiting for connections to be established...")
  115. if not self.connection_established.wait(timeout=10):
  116. self.debug_print("Timeout while connecting to the server.")
  117. return False
  118. self.debug_print("WebSocket connections established successfully.")
  119. return True
  120. except Exception as e:
  121. self.debug_print(f"Error while connecting to the server: {str(e)}")
  122. return False
  123. def on_control_open(self, ws):
  124. self.debug_print("Control WebSocket connection opened successfully")
  125. self.connection_established.set()
  126. self.start_command_processor()
  127. def on_data_open(self, ws):
  128. self.debug_print("Data WebSocket connection opened successfully")
  129. self.data_ws_connected = ws
  130. self.start_recording()
  131. def on_error(self, ws, error):
  132. self.debug_print(f"WebSocket error occurred: {str(error)}")
  133. self.debug_print(f"Error type: {type(error)}")
  134. def on_close(self, ws, close_status_code, close_msg):
  135. if ws == self.data_ws_connected:
  136. self.debug_print(f"Data connection closed (code {close_status_code}, msg: {close_msg})")
  137. elif ws == self.control_ws:
  138. self.debug_print(f"Control connection closed (code {close_status_code}, msg: {close_msg})")
  139. else:
  140. self.debug_print(f"Unknown connection closed (code {close_status_code}, msg: {close_msg})")
  141. self.is_running = False
  142. self.stop_event.set()
  143. def is_server_running(self):
  144. parsed_url = urlparse(self.control_url)
  145. host = parsed_url.hostname
  146. port = parsed_url.port or 80
  147. self.debug_print(f"Checking if server is running at {host}:{port}")
  148. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  149. result = s.connect_ex((host, port)) == 0
  150. self.debug_print(f"Server status check result: {'running' if result else 'not running'}")
  151. return result
  152. def ask_to_start_server(self):
  153. response = input("Would you like to start the STT server now? (y/n): ").strip().lower()
  154. return response == 'y' or response == 'yes'
  155. def start_server(self):
  156. if os.name == 'nt': # Windows
  157. subprocess.Popen('start /min cmd /c stt-server', shell=True)
  158. else: # Unix-like systems
  159. terminal_emulators = [
  160. 'gnome-terminal',
  161. 'x-terminal-emulator',
  162. 'konsole',
  163. 'xfce4-terminal',
  164. 'lxterminal',
  165. 'xterm',
  166. 'mate-terminal',
  167. 'terminator',
  168. 'tilix',
  169. 'alacritty',
  170. 'urxvt',
  171. 'eterm',
  172. 'rxvt',
  173. 'kitty',
  174. 'hyper'
  175. ]
  176. terminal = None
  177. for term in terminal_emulators:
  178. if shutil.which(term):
  179. terminal = term
  180. break
  181. if terminal:
  182. terminal_exec_options = {
  183. 'x-terminal-emulator': ['--'],
  184. 'gnome-terminal': ['--'],
  185. 'mate-terminal': ['--'],
  186. 'terminator': ['--'],
  187. 'tilix': ['--'],
  188. 'konsole': ['-e'],
  189. 'xfce4-terminal': ['-e'],
  190. 'lxterminal': ['-e'],
  191. 'alacritty': ['-e'],
  192. 'xterm': ['-e'],
  193. 'rxvt': ['-e'],
  194. 'urxvt': ['-e'],
  195. 'eterm': ['-e'],
  196. 'kitty': [],
  197. 'hyper': ['--command']
  198. }
  199. exec_option = terminal_exec_options.get(terminal, None)
  200. if exec_option is not None:
  201. subprocess.Popen([terminal] + exec_option + ['stt-server'], start_new_session=True)
  202. print(f"STT server started in a new terminal window using {terminal}.", file=sys.stderr)
  203. else:
  204. print(f"Unsupported terminal emulator '{terminal}'. Please start the STT server manually.", file=sys.stderr)
  205. else:
  206. print("No supported terminal emulator found. Please start the STT server manually.", file=sys.stderr)
  207. def ensure_server_running(self):
  208. if not self.is_server_running():
  209. print("STT server is not running.", file=sys.stderr)
  210. if self.ask_to_start_server():
  211. self.start_server()
  212. print("Waiting for STT server to start...", file=sys.stderr)
  213. for _ in range(20): # Wait up to 20 seconds
  214. if self.is_server_running():
  215. print("STT server started successfully.", file=sys.stderr)
  216. time.sleep(2) # Give the server a moment to fully initialize
  217. return True
  218. time.sleep(1)
  219. print("Failed to start STT server.", file=sys.stderr)
  220. return False
  221. else:
  222. print("STT server is required. Please start it manually.", file=sys.stderr)
  223. return False
  224. return True
  225. def on_control_message(self, ws, message):
  226. try:
  227. self.debug_print(f"Received control message: {message}")
  228. data = json.loads(message)
  229. if 'status' in data:
  230. self.debug_print(f"Message status: {data['status']}")
  231. if data['status'] == 'success':
  232. if 'parameter' in data and 'value' in data:
  233. self.debug_print(f"Parameter update: {data['parameter']} = {data['value']}")
  234. print(f"Parameter {data['parameter']} = {data['value']}")
  235. elif data['status'] == 'error':
  236. self.debug_print(f"Server error received: {data.get('message', '')}")
  237. print(f"Server Error: {data.get('message', '')}")
  238. else:
  239. self.debug_print(f"Unknown control message format: {data}")
  240. except json.JSONDecodeError:
  241. self.debug_print(f"Failed to decode JSON control message: {message}")
  242. except Exception as e:
  243. self.debug_print(f"Error processing control message: {str(e)}")
  244. def on_data_message(self, ws, message):
  245. try:
  246. self.debug_print(f"Received data message: {message}")
  247. data = json.loads(message)
  248. message_type = data.get('type')
  249. self.debug_print(f"Message type: {message_type}")
  250. if message_type == 'realtime':
  251. if data['text'] != self.last_text:
  252. self.debug_print(f"New realtime text received: {data['text']}")
  253. self.last_text = data['text']
  254. if not self.norealtime:
  255. self.update_progress_bar(self.last_text)
  256. elif message_type == 'fullSentence':
  257. self.debug_print(f"Full sentence received: {data['text']}")
  258. if self.file_output:
  259. self.debug_print("Writing to file output")
  260. sys.stderr.write('\r\033[K')
  261. sys.stderr.write(data['text'])
  262. sys.stderr.write('\n')
  263. sys.stderr.flush()
  264. print(data['text'], file=self.file_output)
  265. self.file_output.flush()
  266. else:
  267. self.finish_progress_bar()
  268. print(f"{data['text']}")
  269. self.is_running = False
  270. self.stop_event.set()
  271. elif message_type in {
  272. 'vad_detect_start',
  273. 'vad_detect_stop',
  274. 'recording_start',
  275. 'recording_stop',
  276. 'wakeword_detected',
  277. 'wakeword_detection_start',
  278. 'wakeword_detection_end',
  279. 'transcription_start'}:
  280. pass # Known message types, no action needed
  281. else:
  282. self.debug_print(f"Other message type received: {message_type}")
  283. except json.JSONDecodeError:
  284. self.debug_print(f"Failed to decode JSON data message: {message}")
  285. except Exception as e:
  286. self.debug_print(f"Error processing data message: {str(e)}")
  287. def show_initial_indicator(self):
  288. if self.norealtime:
  289. return
  290. initial_text = f"{self.recording_indicator}\b\b"
  291. sys.stderr.write(initial_text)
  292. sys.stderr.flush()
  293. def update_progress_bar(self, text):
  294. try:
  295. available_width = self.console_width - 5 # Adjust for progress bar decorations
  296. sys.stderr.write('\r\033[K') # Clear the current line
  297. words = text.split()
  298. last_chars = ""
  299. for word in reversed(words):
  300. if len(last_chars) + len(word) + 1 > available_width:
  301. break
  302. last_chars = word + " " + last_chars
  303. last_chars = last_chars.strip()
  304. colored_text = f"{Fore.YELLOW}{last_chars}{Style.RESET_ALL}{self.recording_indicator}\b\b"
  305. sys.stderr.write(colored_text)
  306. sys.stderr.flush()
  307. except Exception as e:
  308. self.debug_print(f"Error updating progress bar: {e}")
  309. def finish_progress_bar(self):
  310. try:
  311. sys.stderr.write('\r\033[K')
  312. sys.stderr.flush()
  313. except Exception as e:
  314. self.debug_print(f"Error finishing progress bar: {e}")
  315. def stop(self):
  316. self.finish_progress_bar()
  317. self.is_running = False
  318. self.stop_event.set()
  319. self.debug_print("Stopping client and cleaning up resources.")
  320. if self.control_ws:
  321. self.control_ws.close()
  322. if self.data_ws_connected:
  323. self.data_ws_connected.close()
  324. # Join threads to ensure they finish before exiting
  325. current_thread = threading.current_thread()
  326. if self.control_ws_thread and self.control_ws_thread != current_thread:
  327. self.control_ws_thread.join()
  328. if self.data_ws_thread and self.data_ws_thread != current_thread:
  329. self.data_ws_thread.join()
  330. if self.recording_thread and self.recording_thread != current_thread:
  331. self.recording_thread.join()
  332. # Clean up audio resources
  333. if self.stream:
  334. self.stream.stop_stream()
  335. self.stream.close()
  336. if self.audio_interface:
  337. self.audio_interface.terminate()
  338. def start_recording(self):
  339. self.recording_thread = threading.Thread(target=self.record_and_send_audio)
  340. self.recording_thread.daemon = False # Set to False to ensure proper shutdown
  341. self.recording_thread.start()
  342. def record_and_send_audio(self):
  343. try:
  344. if not self.setup_audio():
  345. self.debug_print("Failed to set up audio recording")
  346. raise Exception("Failed to set up audio recording.")
  347. # Initialize WAV file writer if writechunks is provided
  348. if self.writechunks:
  349. self.wav_file = wave.open(self.writechunks, 'wb')
  350. self.wav_file.setnchannels(CHANNELS)
  351. self.wav_file.setsampwidth(pyaudio.get_sample_size(FORMAT))
  352. self.wav_file.setframerate(self.device_sample_rate) # Use self.device_sample_rate
  353. self.debug_print("Starting audio recording and transmission")
  354. self.show_initial_indicator()
  355. while self.is_running and not self.stop_event.is_set():
  356. try:
  357. audio_data = self.stream.read(CHUNK)
  358. self.chunks_sent += 1
  359. current_time = time.time()
  360. elapsed = current_time - self.last_chunk_time
  361. # Write to WAV file if enabled
  362. if self.writechunks:
  363. self.wav_file.writeframes(audio_data)
  364. if self.chunks_sent % 100 == 0: # Log every 100 chunks
  365. self.debug_print(f"Sent {self.chunks_sent} chunks. Last chunk took {elapsed:.3f}s")
  366. metadata = {"sampleRate": self.device_sample_rate}
  367. metadata_json = json.dumps(metadata)
  368. metadata_length = len(metadata_json)
  369. message = struct.pack('<I', metadata_length) + metadata_json.encode('utf-8') + audio_data
  370. if self.is_running and not self.stop_event.is_set():
  371. self.debug_print(f"Sending audio chunk {self.chunks_sent}: {len(audio_data)} bytes, metadata: {metadata_json}")
  372. self.data_ws_connected.send(message, opcode=websocket.ABNF.OPCODE_BINARY)
  373. self.last_chunk_time = current_time
  374. except Exception as e:
  375. self.debug_print(f"Error sending audio data: {str(e)}")
  376. break
  377. except Exception as e:
  378. self.debug_print(f"Error in record_and_send_audio: {str(e)}")
  379. finally:
  380. self.cleanup_audio()
  381. def setup_audio(self):
  382. try:
  383. self.debug_print("Initializing PyAudio interface")
  384. self.audio_interface = pyaudio.PyAudio()
  385. self.input_device_index = None
  386. try:
  387. default_device = self.audio_interface.get_default_input_device_info()
  388. self.input_device_index = default_device['index']
  389. self.debug_print(f"Default input device found: {default_device}")
  390. except OSError as e:
  391. self.debug_print(f"No default input device found: {str(e)}")
  392. return False
  393. self.device_sample_rate = 16000
  394. self.debug_print(f"Attempting to open audio stream with sample rate {self.device_sample_rate} Hz")
  395. try:
  396. self.stream = self.audio_interface.open(
  397. format=FORMAT,
  398. channels=CHANNELS,
  399. rate=self.device_sample_rate,
  400. input=True,
  401. frames_per_buffer=CHUNK,
  402. input_device_index=self.input_device_index,
  403. )
  404. self.debug_print(f"Audio stream initialized successfully")
  405. self.debug_print(f"Audio parameters: rate={self.device_sample_rate}, channels={CHANNELS}, format={FORMAT}, chunk={CHUNK}")
  406. return True
  407. except Exception as e:
  408. self.debug_print(f"Failed to initialize audio stream: {str(e)}")
  409. return False
  410. except Exception as e:
  411. self.debug_print(f"Error in setup_audio: {str(e)}")
  412. if self.audio_interface:
  413. self.audio_interface.terminate()
  414. return False
  415. def cleanup_audio(self):
  416. self.debug_print("Cleaning up audio resources")
  417. try:
  418. if self.stream:
  419. self.debug_print("Stopping and closing audio stream")
  420. self.stream.stop_stream()
  421. self.stream.close()
  422. self.stream = None
  423. if self.audio_interface:
  424. self.debug_print("Terminating PyAudio interface")
  425. self.audio_interface.terminate()
  426. self.audio_interface = None
  427. if self.writechunks and self.wav_file:
  428. self.debug_print("Closing WAV file")
  429. self.wav_file.close()
  430. except Exception as e:
  431. self.debug_print(f"Error during audio cleanup: {str(e)}")
  432. def set_parameter(self, parameter, value):
  433. command = {
  434. "command": "set_parameter",
  435. "parameter": parameter,
  436. "value": value
  437. }
  438. self.control_ws.send(json.dumps(command))
  439. def get_parameter(self, parameter):
  440. command = {
  441. "command": "get_parameter",
  442. "parameter": parameter
  443. }
  444. self.control_ws.send(json.dumps(command))
  445. def call_method(self, method, args=None, kwargs=None):
  446. command = {
  447. "command": "call_method",
  448. "method": method,
  449. "args": args or [],
  450. "kwargs": kwargs or {}
  451. }
  452. self.control_ws.send(json.dumps(command))
  453. def start_command_processor(self):
  454. self.command_thread = threading.Thread(target=self.command_processor)
  455. self.command_thread.daemon = False # Ensure it is not a daemon thread
  456. self.command_thread.start()
  457. def command_processor(self):
  458. self.debug_print("Starting command processor thread")
  459. while not self.stop_event.is_set():
  460. try:
  461. command = self.commands.get(timeout=0.1)
  462. self.debug_print(f"Processing command: {command}")
  463. if command['type'] == 'set_parameter':
  464. self.debug_print(f"Setting parameter: {command['parameter']} = {command['value']}")
  465. self.set_parameter(command['parameter'], command['value'])
  466. elif command['type'] == 'get_parameter':
  467. self.debug_print(f"Getting parameter: {command['parameter']}")
  468. self.get_parameter(command['parameter'])
  469. elif command['type'] == 'call_method':
  470. self.debug_print(f"Calling method: {command['method']} with args: {command.get('args')} and kwargs: {command.get('kwargs')}")
  471. self.call_method(command['method'], command.get('args'), command.get('kwargs'))
  472. except queue.Empty:
  473. continue
  474. except Exception as e:
  475. self.debug_print(f"Error in command processor: {str(e)}")
  476. self.debug_print("Command processor thread stopping")
  477. def add_command(self, command):
  478. self.commands.put(command)
  479. def main():
  480. parser = argparse.ArgumentParser(description="STT Client")
  481. parser.add_argument("--control-url", default=DEFAULT_CONTROL_URL, help="STT Control WebSocket URL")
  482. parser.add_argument("--data-url", default=DEFAULT_DATA_URL, help="STT Data WebSocket URL")
  483. parser.add_argument("--debug", action="store_true", help="Enable debug mode")
  484. parser.add_argument("-nort", "--norealtime", action="store_true", help="Disable real-time output")
  485. parser.add_argument("--writechunks", metavar="FILE", help="Save recorded audio chunks to a WAV file")
  486. parser.add_argument("--set-param", nargs=2, metavar=('PARAM', 'VALUE'), action='append',
  487. help="Set a recorder parameter. Can be used multiple times.")
  488. parser.add_argument("--call-method", nargs='+', metavar='METHOD', action='append',
  489. help="Call a recorder method with optional arguments.")
  490. parser.add_argument("--get-param", nargs=1, metavar='PARAM', action='append',
  491. help="Get the value of a recorder parameter. Can be used multiple times.")
  492. args = parser.parse_args()
  493. # Check if output is being redirected
  494. if not os.isatty(sys.stdout.fileno()):
  495. file_output = sys.stdout
  496. else:
  497. file_output = None
  498. client = STTWebSocketClient(args.control_url, args.data_url, args.debug, file_output, args.norealtime, args.writechunks)
  499. def signal_handler(sig, frame):
  500. client.stop()
  501. sys.exit(0)
  502. import signal
  503. signal.signal(signal.SIGINT, signal_handler)
  504. try:
  505. if client.connect():
  506. # Process command-line parameters
  507. if args.set_param:
  508. for param, value in args.set_param:
  509. try:
  510. if '.' in value:
  511. value = float(value)
  512. else:
  513. value = int(value)
  514. except ValueError:
  515. pass # Keep as string if not a number
  516. client.add_command({
  517. 'type': 'set_parameter',
  518. 'parameter': param,
  519. 'value': value
  520. })
  521. if args.get_param:
  522. for param_list in args.get_param:
  523. param = param_list[0]
  524. client.add_command({
  525. 'type': 'get_parameter',
  526. 'parameter': param
  527. })
  528. if args.call_method:
  529. for method_call in args.call_method:
  530. method = method_call[0]
  531. args_list = method_call[1:] if len(method_call) > 1 else []
  532. client.add_command({
  533. 'type': 'call_method',
  534. 'method': method,
  535. 'args': args_list
  536. })
  537. # If command-line parameters were used (like --get-param), wait for them to be processed
  538. if args.set_param or args.get_param or args.call_method:
  539. while not client.commands.empty():
  540. time.sleep(0.1)
  541. # Start recording directly if no command-line params were provided
  542. while client.is_running:
  543. time.sleep(0.1)
  544. else:
  545. print("Failed to connect to the server.", file=sys.stderr)
  546. except Exception as e:
  547. print(f"An error occurred: {e}")
  548. finally:
  549. client.stop()
  550. if __name__ == "__main__":
  551. main()