stt_cli_client.py 26 KB

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