From f5f24b78ca9edf692f69dcd02603d46e96c73e58 Mon Sep 17 00:00:00 2001 From: David vonThenen <12752197+dvonthenen@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:58:26 -0700 Subject: [PATCH] Fix Issue 264 - Using Shared WebSocket Class Implementation --- deepgram/audio/speaker/speaker.py | 36 +- deepgram/clients/__init__.py | 9 +- deepgram/clients/analyze/v1/async_client.py | 4 +- deepgram/clients/analyze/v1/client.py | 4 +- deepgram/clients/common/__init__.py | 12 +- deepgram/clients/common/v1/__init__.py | 11 +- .../v1/abstract_async_rest.py} | 5 +- .../common/v1/abstract_async_websocket.py | 505 +++++++++++++ .../v1/abstract_sync_rest.py} | 5 +- .../common/v1/abstract_sync_websocket.py | 498 ++++++++++++ deepgram/clients/common/v1/errors.py | 40 + deepgram/clients/common/v1/helpers.py | 53 ++ .../clients/common/v1/websocket_events.py | 19 + deepgram/clients/errors.py | 40 - deepgram/clients/helpers.py | 29 - .../clients/listen/v1/rest/async_client.py | 4 +- deepgram/clients/listen/v1/rest/client.py | 4 +- .../listen/v1/websocket/async_client.py | 709 +++++------------- .../clients/listen/v1/websocket/client.py | 709 +++++------------- .../clients/listen/v1/websocket/response.py | 3 - deepgram/clients/manage/v1/async_client.py | 2 +- deepgram/clients/manage/v1/client.py | 2 +- .../clients/selfhosted/v1/async_client.py | 2 +- deepgram/clients/selfhosted/v1/client.py | 2 +- .../clients/speak/v1/rest/async_client.py | 4 +- deepgram/clients/speak/v1/rest/client.py | 4 +- .../speak/v1/websocket/async_client.py | 541 ++++--------- deepgram/clients/speak/v1/websocket/client.py | 548 ++++---------- .../websocket/async_complete/main.py | 41 +- .../text-to-speech/websocket/complete/main.py | 25 +- 30 files changed, 1856 insertions(+), 2014 deletions(-) rename deepgram/clients/{abstract_async_client.py => common/v1/abstract_async_rest.py} (98%) create mode 100644 deepgram/clients/common/v1/abstract_async_websocket.py rename deepgram/clients/{abstract_sync_client.py => common/v1/abstract_sync_rest.py} (98%) create mode 100644 deepgram/clients/common/v1/abstract_sync_websocket.py create mode 100644 deepgram/clients/common/v1/helpers.py create mode 100644 deepgram/clients/common/v1/websocket_events.py delete mode 100644 deepgram/clients/helpers.py diff --git a/deepgram/audio/speaker/speaker.py b/deepgram/audio/speaker/speaker.py index 9a439109..100abdde 100644 --- a/deepgram/audio/speaker/speaker.py +++ b/deepgram/audio/speaker/speaker.py @@ -9,6 +9,8 @@ from typing import Optional, Callable, Union, TYPE_CHECKING import logging +import websockets + from ...utils import verboselogs from .constants import LOGGING, CHANNELS, RATE, CHUNK, TIMEOUT @@ -24,22 +26,22 @@ class Speaker: # pylint: disable=too-many-instance-attributes _logger: verboselogs.VerboseLogger _audio: "pyaudio.PyAudio" - _stream: "pyaudio.Stream" + _stream: Optional["pyaudio.Stream"] = None _chunk: int _rate: int _channels: int - _output_device_index: Optional[int] + _output_device_index: Optional[int] = None _queue: queue.Queue _exit: threading.Event - _thread: threading.Thread + _thread: Optional[threading.Thread] = None # _asyncio_loop: asyncio.AbstractEventLoop # _asyncio_thread: threading.Thread - _receiver_thread: threading.Thread + _receiver_thread: Optional[threading.Thread] = None - _loop: asyncio.AbstractEventLoop + _loop: Optional[asyncio.AbstractEventLoop] = None _push_callback_org: Optional[Callable] = None _push_callback: Optional[Callable] = None @@ -217,6 +219,17 @@ async def _start_asyncio_receiver(self): elif isinstance(message, bytes): self._logger.verbose("Received audio data...") self.add_audio_to_queue(message) + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.debug("send() exiting gracefully: %d", e.code) + except websockets.exceptions.ConnectionClosed as e: + if e.code in [1000, 1001]: + self._logger.debug("send() exiting gracefully: %d", e.code) + return + self._logger.error("_start_asyncio_receiver - ConnectionClosed: %s", str(e)) + except websockets.exceptions.WebSocketException as e: + self._logger.error( + "_start_asyncio_receiver- WebSocketException: %s", str(e) + ) except Exception as e: # pylint: disable=broad-except self._logger.error("_start_asyncio_receiver exception: %s", str(e)) @@ -266,23 +279,26 @@ def finish(self) -> bool: self._logger.notice("stopping stream...") self._stream.stop_stream() self._stream.close() - self._stream = None # type: ignore + self._stream = None self._logger.notice("stream stopped") - self._thread.join() - self._thread = None # type: ignore + if self._thread is not None: + self._logger.notice("joining thread...") + self._thread.join() + self._thread = None + self._logger.notice("thread stopped") # if self._asyncio_thread is not None: # self._logger.notice("stopping asyncio loop...") # self._asyncio_loop.call_soon_threadsafe(self._asyncio_loop.stop) # self._asyncio_thread.join() - # self._asyncio_thread = None # type: ignore + # self._asyncio_thread = None # self._logger.notice("_asyncio_thread joined") if self._receiver_thread is not None: self._logger.notice("stopping asyncio loop...") self._receiver_thread.join() - self._receiver_thread = None # type: ignore + self._receiver_thread = None self._logger.notice("_receiver_thread joined") self._queue = None # type: ignore diff --git a/deepgram/clients/__init__.py b/deepgram/clients/__init__.py index d490a9f4..01ec90ee 100644 --- a/deepgram/clients/__init__.py +++ b/deepgram/clients/__init__.py @@ -40,8 +40,13 @@ UnhandledResponse, ErrorResponse, ) -from .common import DeepgramError, DeepgramTypeError -from .errors import DeepgramModuleError, DeepgramApiError, DeepgramUnknownApiError +from .common import ( + DeepgramError, + DeepgramTypeError, + DeepgramApiError, + DeepgramUnknownApiError, +) +from .errors import DeepgramModuleError from .listen_router import Listen from .read_router import Read diff --git a/deepgram/clients/analyze/v1/async_client.py b/deepgram/clients/analyze/v1/async_client.py index 8edaa4e7..5c3e569b 100644 --- a/deepgram/clients/analyze/v1/async_client.py +++ b/deepgram/clients/analyze/v1/async_client.py @@ -9,8 +9,8 @@ from ....utils import verboselogs from ....options import DeepgramClientOptions -from ...abstract_async_client import AbstractAsyncRestClient -from ...common.v1.errors import DeepgramError, DeepgramTypeError +from ...common import AbstractAsyncRestClient +from ...common import DeepgramError, DeepgramTypeError from .helpers import is_buffer_source, is_readstream_source, is_url_source from .options import ( diff --git a/deepgram/clients/analyze/v1/client.py b/deepgram/clients/analyze/v1/client.py index efae8c3f..a90145ed 100644 --- a/deepgram/clients/analyze/v1/client.py +++ b/deepgram/clients/analyze/v1/client.py @@ -9,8 +9,8 @@ from ....utils import verboselogs from ....options import DeepgramClientOptions -from ...abstract_sync_client import AbstractSyncRestClient -from ...common.v1.errors import DeepgramError, DeepgramTypeError +from ...common import AbstractSyncRestClient +from ...common import DeepgramError, DeepgramTypeError from .helpers import is_buffer_source, is_readstream_source, is_url_source from .options import ( diff --git a/deepgram/clients/common/__init__.py b/deepgram/clients/common/__init__.py index 1ecd39d0..58c449d9 100644 --- a/deepgram/clients/common/__init__.py +++ b/deepgram/clients/common/__init__.py @@ -2,7 +2,17 @@ # Use of this source code is governed by a MIT license that can be found in the LICENSE file. # SPDX-License-Identifier: MIT -from .v1 import DeepgramError, DeepgramTypeError +from .v1 import ( + DeepgramError, + DeepgramTypeError, + DeepgramApiError, + DeepgramUnknownApiError, +) + +from .v1 import AbstractAsyncRestClient +from .v1 import AbstractSyncRestClient +from .v1 import AbstractAsyncWebSocketClient +from .v1 import AbstractSyncWebSocketClient from .v1 import ( TextSource as TextSourceLatest, diff --git a/deepgram/clients/common/v1/__init__.py b/deepgram/clients/common/v1/__init__.py index 29f3b956..7603fc0a 100644 --- a/deepgram/clients/common/v1/__init__.py +++ b/deepgram/clients/common/v1/__init__.py @@ -4,7 +4,16 @@ from .enums import Sentiment -from .errors import DeepgramError, DeepgramTypeError +from .errors import ( + DeepgramError, + DeepgramTypeError, + DeepgramApiError, + DeepgramUnknownApiError, +) +from .abstract_async_rest import AbstractAsyncRestClient +from .abstract_sync_rest import AbstractSyncRestClient +from .abstract_async_websocket import AbstractAsyncWebSocketClient +from .abstract_sync_websocket import AbstractSyncWebSocketClient from .options import ( TextSource, diff --git a/deepgram/clients/abstract_async_client.py b/deepgram/clients/common/v1/abstract_async_rest.py similarity index 98% rename from deepgram/clients/abstract_async_client.py rename to deepgram/clients/common/v1/abstract_async_rest.py index 320098d2..1a3be9cd 100644 --- a/deepgram/clients/abstract_async_client.py +++ b/deepgram/clients/common/v1/abstract_async_rest.py @@ -9,9 +9,8 @@ import httpx from .helpers import append_query_params -from ..options import DeepgramClientOptions -from .errors import DeepgramApiError, DeepgramUnknownApiError -from .common.v1.errors import DeepgramError +from ....options import DeepgramClientOptions +from .errors import DeepgramError, DeepgramApiError, DeepgramUnknownApiError class AbstractAsyncRestClient: diff --git a/deepgram/clients/common/v1/abstract_async_websocket.py b/deepgram/clients/common/v1/abstract_async_websocket.py new file mode 100644 index 00000000..0785b1b9 --- /dev/null +++ b/deepgram/clients/common/v1/abstract_async_websocket.py @@ -0,0 +1,505 @@ +# Copyright 2023-2024 Deepgram SDK contributors. All Rights Reserved. +# Use of this source code is governed by a MIT license that can be found in the LICENSE file. +# SPDX-License-Identifier: MIT +import asyncio +import json +import logging +from typing import Dict, Union, Optional, cast, Any, Callable +from datetime import datetime +import threading +from abc import ABC, abstractmethod + +import websockets +from websockets.client import WebSocketClientProtocol + +from ....audio import Speaker +from ....utils import verboselogs +from ....options import DeepgramClientOptions +from .helpers import convert_to_websocket_url, append_query_params +from .errors import DeepgramError + +from .websocket_response import ( + OpenResponse, + CloseResponse, + ErrorResponse, +) +from .websocket_events import WebSocketEvents + +ONE_SECOND = 1 +HALF_SECOND = 0.5 +DEEPGRAM_INTERVAL = 5 +PING_INTERVAL = 20 + + +class AbstractAsyncWebSocketClient(ABC): # pylint: disable=too-many-instance-attributes + """ + Abstract class for using WebSockets. + + This class provides methods to establish a WebSocket connection generically for + use in all WebSocket clients. + """ + + _logger: verboselogs.VerboseLogger + _config: DeepgramClientOptions + _endpoint: str + _websocket_url: str + + _socket: Optional[WebSocketClientProtocol] = None + + _listen_thread: Union[asyncio.Task, None] + _delegate: Optional[Speaker] = None + + _kwargs: Optional[Dict] = None + _addons: Optional[Dict] = None + _options: Optional[Dict] = None + _headers: Optional[Dict] = None + + def __init__(self, config: DeepgramClientOptions, endpoint: str = ""): + if config is None: + raise DeepgramError("Config is required") + if endpoint == "": + raise DeepgramError("endpoint is required") + + self._logger = verboselogs.VerboseLogger(__name__) + self._logger.addHandler(logging.StreamHandler()) + self._logger.setLevel(config.verbose) + + self._config = config + self._endpoint = endpoint + + self._listen_thread = None + + # events + self._exit_event = asyncio.Event() + + # set websocket url + self._websocket_url = convert_to_websocket_url(self._config.url, self._endpoint) + + def delegate_listening(self, delegate: Speaker) -> None: + """ + Delegate the listening thread to the Speaker object. + """ + self._delegate = delegate + + # pylint: disable=too-many-branches,too-many-statements + async def start( + self, + options: Optional[Dict] = None, + addons: Optional[Dict] = None, + headers: Optional[Dict] = None, + **kwargs, + ) -> bool: + """ + Starts the WebSocket connection for live transcription. + """ + self._logger.debug("AbstractAsyncWebSocketClient.start ENTER") + self._logger.info("addons: %s", addons) + self._logger.info("headers: %s", headers) + self._logger.info("kwargs: %s", kwargs) + + self._addons = addons + self._headers = headers + + # set kwargs + if kwargs is not None: + self._kwargs = kwargs + else: + self._kwargs = {} + + # set options + if options is not None: + self._options = options + else: + self._options = {} + + combined_options = self._options.copy() + if self._addons is not None: + self._logger.info("merging addons to options") + combined_options.update(self._addons) + self._logger.info("new options: %s", combined_options) + self._logger.debug("combined_options: %s", combined_options) + + combined_headers = self._config.headers.copy() + if self._headers is not None: + self._logger.info("merging headers to options") + combined_headers.update(self._headers) + self._logger.info("new headers: %s", combined_headers) + self._logger.debug("combined_headers: %s", combined_headers) + + url_with_params = append_query_params(self._websocket_url, combined_options) + + try: + self._socket = await websockets.connect( + url_with_params, + extra_headers=combined_headers, + ping_interval=PING_INTERVAL, + ) + self._exit_event.clear() + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + # delegate the listening thread to external object + if self._delegate is not None: + self._logger.notice("_delegate is enabled. this is usually the speaker") + self._delegate.set_pull_callback(self._socket.recv) + self._delegate.set_push_callback(self._process_message) + else: + self._logger.notice("create _listening thread") + self._listen_thread = asyncio.create_task(self._listening()) + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + # push open event + await self._emit( + WebSocketEvents(WebSocketEvents.Open), + OpenResponse(type=WebSocketEvents.Open), + ) + + self._logger.notice("start succeeded") + self._logger.debug("AbstractAsyncWebSocketClient.start LEAVE") + return True + except websockets.ConnectionClosed as e: + self._logger.error( + "ConnectionClosed in AbstractAsyncWebSocketClient.start: %s", e + ) + self._logger.debug("AbstractAsyncWebSocketClient.start LEAVE") + if self._config.options.get("termination_exception_connect", False): + raise + return False + except websockets.exceptions.WebSocketException as e: + self._logger.error( + "WebSocketException in AbstractAsyncWebSocketClient.start: %s", e + ) + self._logger.debug("AbstractAsyncWebSocketClient.start LEAVE") + if self._config.options.get("termination_exception_connect", False): + raise + return False + except Exception as e: # pylint: disable=broad-except + self._logger.error( + "WebSocketException in AbstractAsyncWebSocketClient.start: %s", e + ) + self._logger.debug("AbstractAsyncWebSocketClient.start LEAVE") + if self._config.options.get("termination_exception_connect", False): + raise + return False + + async def is_connected(self) -> bool: + """ + Returns the connection status of the WebSocket. + """ + return self._socket is not None + + # pylint: enable=too-many-branches,too-many-statements + + @abstractmethod + def on(self, event: WebSocketEvents, handler: Callable) -> None: + """ + Registers an event handler for the WebSocket connection. + """ + raise NotImplementedError("no on method") + + @abstractmethod + async def _emit(self, event: WebSocketEvents, *args, **kwargs) -> None: + """ + Emits an event to the WebSocket connection. + """ + raise NotImplementedError("no _emit method") + + # pylint: disable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches + async def _listening(self) -> None: + """ + Listens for messages from the WebSocket connection. + """ + self._logger.debug("AbstractAsyncWebSocketClient._listening ENTER") + + while True: + try: + if self._exit_event.is_set(): + self._logger.notice("_listening exiting gracefully") + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + return + + if self._socket is None: + self._logger.warning("socket is empty") + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + return + + message = await self._socket.recv() + + if message is None: + self._logger.info("message is None") + continue + + self._logger.spam("data type: %s", type(message)) + + if isinstance(message, bytes): + self._logger.debug("Binary data received") + await self._process_binary(message) + else: + self._logger.debug("Text data received") + await self._process_text(message) + + self._logger.notice("_listening Succeeded") + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.notice(f"_listening({e.code}) exiting gracefully") + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + return + + except websockets.exceptions.ConnectionClosed as e: + if e.code in [1000, 1001]: + self._logger.notice(f"_listening({e.code}) exiting gracefully") + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + return + + # we need to explicitly call self._signal_exit() here because we are hanging on a recv() + # note: this is different than the speak websocket client + self._logger.error( + "ConnectionClosed in AbstractAsyncWebSocketClient._listening with code %s: %s", + e.code, + e.reason, + ) + cc_error: ErrorResponse = ErrorResponse( + "ConnectionClosed in AbstractAsyncWebSocketClient._listening", + f"{e}", + "ConnectionClosed", + ) + await self._emit( + WebSocketEvents(WebSocketEvents.Error), + error=cc_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + # signal exit and close + await self._signal_exit() + + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + + if self._config.options.get("termination_exception") == "true": + raise + return + + except websockets.exceptions.WebSocketException as e: + self._logger.error( + "WebSocketException in AbstractAsyncWebSocketClient._listening: %s", + e, + ) + ws_error: ErrorResponse = ErrorResponse( + "WebSocketException in AbstractAsyncWebSocketClient._listening", + f"{e}", + "WebSocketException", + ) + await self._emit( + WebSocketEvents(WebSocketEvents.Error), + error=ws_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + # signal exit and close + await self._signal_exit() + + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + + if self._config.options.get("termination_exception") == "true": + raise + return + + except Exception as e: # pylint: disable=broad-except + self._logger.error( + "Exception in AbstractAsyncWebSocketClient._listening: %s", e + ) + e_error: ErrorResponse = ErrorResponse( + "Exception in AbstractAsyncWebSocketClient._listening", + f"{e}", + "Exception", + ) + await self._emit( + WebSocketEvents(WebSocketEvents.Error), + error=e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + # signal exit and close + await self._signal_exit() + + self._logger.debug("AbstractAsyncWebSocketClient._listening LEAVE") + + if self._config.options.get("termination_exception") == "true": + raise + return + + # pylint: enable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches + + async def _process_message(self, message: Union[str, bytes]) -> None: + if isinstance(message, bytes): + await self._process_binary(message) + else: + await self._process_text(message) + + @abstractmethod + async def _process_text(self, message: str) -> None: + raise NotImplementedError("no _process_text method") + + @abstractmethod + async def _process_binary(self, message: bytes) -> None: + raise NotImplementedError("no _process_binary method") + + @abstractmethod + async def _close_message(self) -> bool: + raise NotImplementedError("no _close_message method") + + # pylint: disable=too-many-return-statements,too-many-branches + + async def send(self, data: Union[str, bytes]) -> bool: + """ + Sends data over the WebSocket connection. + """ + self._logger.spam("AbstractAsyncWebSocketClient.send ENTER") + + if self._exit_event.is_set(): + self._logger.notice("send exiting gracefully") + self._logger.debug("AbstractAsyncWebSocketClient.send LEAVE") + return False + + if not await self.is_connected(): + self._logger.notice("is_connected is False") + self._logger.debug("AbstractAsyncWebSocketClient.send LEAVE") + return False + + if self._socket is not None: + try: + await self._socket.send(data) + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.notice(f"send() exiting gracefully: {e.code}") + self._logger.debug("AbstractAsyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return True + except websockets.exceptions.ConnectionClosed as e: + if e.code in [1000, 1001]: + self._logger.notice(f"send({e.code}) exiting gracefully") + self._logger.debug("AbstractAsyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return True + + self._logger.error("send() failed - ConnectionClosed: %s", str(e)) + self._logger.spam("AbstractAsyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False + except websockets.exceptions.WebSocketException as e: + self._logger.error("send() failed - WebSocketException: %s", str(e)) + self._logger.spam("AbstractAsyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False + except Exception as e: # pylint: disable=broad-except + self._logger.error("send() failed - Exception: %s", str(e)) + self._logger.spam("AbstractAsyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False + + self._logger.spam("send() succeeded") + self._logger.spam("AbstractAsyncWebSocketClient.send LEAVE") + return True + + self._logger.spam("send() failed. socket is None") + self._logger.spam("AbstractAsyncWebSocketClient.send LEAVE") + return False + + # pylint: enable=too-many-return-statements,too-many-branches + + async def finish(self) -> bool: + """ + Closes the WebSocket connection gracefully. + """ + self._logger.debug("AbstractAsyncWebSocketClient.finish ENTER") + + # signal exit + await self._signal_exit() + + # stop the threads + self._logger.verbose("cancelling tasks...") + try: + # Before cancelling, check if the tasks were created + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("before running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + tasks = [] + if self._listen_thread is not None: + self._listen_thread.cancel() + tasks.append(self._listen_thread) + self._logger.notice("processing _listen_thread cancel...") + + # Use asyncio.gather to wait for tasks to be cancelled + await asyncio.gather(*filter(None, tasks)) + self._logger.notice("threads joined") + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + self._logger.notice("finish succeeded") + self._logger.spam("AbstractAsyncWebSocketClient.finish LEAVE") + return True + + except asyncio.CancelledError as e: + self._logger.error("tasks cancelled error: %s", e) + self._logger.debug("AbstractAsyncWebSocketClient.finish LEAVE") + return False + + async def _signal_exit(self) -> None: + # send close event + self._logger.verbose("closing socket...") + if self._socket is not None: + self._logger.verbose("send Close...") + try: + # if the socket connection is closed, the following line might throw an error + await self._close_message() + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.notice("_signal_exit - ConnectionClosedOK: %s", e.code) + except websockets.exceptions.ConnectionClosed as e: + self._logger.error("_signal_exit - ConnectionClosed: %s", e.code) + except websockets.exceptions.WebSocketException as e: + self._logger.error("_signal_exit - WebSocketException: %s", str(e)) + except Exception as e: # pylint: disable=broad-except + self._logger.error("_signal_exit - Exception: %s", str(e)) + + # push close event + try: + await self._emit( + WebSocketEvents(WebSocketEvents.Close), + close=CloseResponse(type=WebSocketEvents.Close), + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + except Exception as e: # pylint: disable=broad-except + self._logger.error("_emit - Exception: %s", e) + + # wait for task to send + await asyncio.sleep(0.5) + + # signal exit + self._exit_event.set() + + # closes the WebSocket connection gracefully + self._logger.verbose("clean up socket...") + if self._socket is not None: + self._logger.verbose("socket.wait_closed...") + try: + await self._socket.close() + except websockets.exceptions.WebSocketException as e: + self._logger.error("socket.wait_closed failed: %s", e) + + self._socket = None diff --git a/deepgram/clients/abstract_sync_client.py b/deepgram/clients/common/v1/abstract_sync_rest.py similarity index 98% rename from deepgram/clients/abstract_sync_client.py rename to deepgram/clients/common/v1/abstract_sync_rest.py index c1a76e6c..5aba233f 100644 --- a/deepgram/clients/abstract_sync_client.py +++ b/deepgram/clients/common/v1/abstract_sync_rest.py @@ -9,9 +9,8 @@ import httpx from .helpers import append_query_params -from ..options import DeepgramClientOptions -from .errors import DeepgramApiError, DeepgramUnknownApiError -from .common.v1.errors import DeepgramError +from ....options import DeepgramClientOptions +from .errors import DeepgramError, DeepgramApiError, DeepgramUnknownApiError class AbstractSyncRestClient: diff --git a/deepgram/clients/common/v1/abstract_sync_websocket.py b/deepgram/clients/common/v1/abstract_sync_websocket.py new file mode 100644 index 00000000..5b7570a1 --- /dev/null +++ b/deepgram/clients/common/v1/abstract_sync_websocket.py @@ -0,0 +1,498 @@ +# Copyright 2023-2024 Deepgram SDK contributors. All Rights Reserved. +# Use of this source code is governed by a MIT license that can be found in the LICENSE file. +# SPDX-License-Identifier: MIT +import json +import time +import logging +from typing import Dict, Union, Optional, cast, Any, Callable +from datetime import datetime +import threading +from abc import ABC, abstractmethod + +from websockets.sync.client import connect, ClientConnection +import websockets + +from ....audio import Speaker +from ....utils import verboselogs +from ....options import DeepgramClientOptions +from .helpers import convert_to_websocket_url, append_query_params +from .errors import DeepgramError + +from .websocket_response import ( + OpenResponse, + CloseResponse, + ErrorResponse, +) +from .websocket_events import WebSocketEvents + + +ONE_SECOND = 1 +HALF_SECOND = 0.5 +DEEPGRAM_INTERVAL = 5 +PING_INTERVAL = 20 + + +class AbstractSyncWebSocketClient(ABC): # pylint: disable=too-many-instance-attributes + """ + Abstract class for using WebSockets. + + This class provides methods to establish a WebSocket connection generically for + use in all WebSocket clients. + """ + + _logger: verboselogs.VerboseLogger + _config: DeepgramClientOptions + _endpoint: str + _websocket_url: str + + _socket: Optional[ClientConnection] = None + _exit_event: threading.Event + _lock_send: threading.Lock + + _listen_thread: Union[threading.Thread, None] + _delegate: Optional[Speaker] = None + + _kwargs: Optional[Dict] = None + _addons: Optional[Dict] = None + _options: Optional[Dict] = None + _headers: Optional[Dict] = None + + def __init__(self, config: DeepgramClientOptions, endpoint: str = ""): + if config is None: + raise DeepgramError("Config is required") + if endpoint == "": + raise DeepgramError("endpoint is required") + + self._logger = verboselogs.VerboseLogger(__name__) + self._logger.addHandler(logging.StreamHandler()) + self._logger.setLevel(config.verbose) + + self._config = config + self._endpoint = endpoint + self._lock_send = threading.Lock() + + self._listen_thread = None + + # exit + self._exit_event = threading.Event() + + # set websocket url + self._websocket_url = convert_to_websocket_url(self._config.url, self._endpoint) + + def delegate_listening(self, delegate: Speaker) -> None: + """ + Delegate the listening thread to the main thread. + """ + self._delegate = delegate + + # pylint: disable=too-many-statements,too-many-branches + def start( + self, + options: Optional[Dict] = None, + addons: Optional[Dict] = None, + headers: Optional[Dict] = None, + **kwargs, + ) -> bool: + """ + Starts the WebSocket connection for live transcription. + """ + self._logger.debug("AbstractSyncWebSocketClient.start ENTER") + self._logger.info("addons: %s", addons) + self._logger.info("headers: %s", headers) + self._logger.info("kwargs: %s", kwargs) + + self._addons = addons + self._headers = headers + + # set kwargs + if kwargs is not None: + self._kwargs = kwargs + else: + self._kwargs = {} + + # set options + if options is not None: + self._options = options + else: + self._options = {} + + combined_options = self._options.copy() + if self._addons is not None: + self._logger.info("merging addons to options") + combined_options.update(self._addons) + self._logger.info("new options: %s", combined_options) + self._logger.debug("combined_options: %s", combined_options) + + combined_headers = self._config.headers.copy() + if self._headers is not None: + self._logger.info("merging headers to options") + combined_headers.update(self._headers) + self._logger.info("new headers: %s", combined_headers) + self._logger.debug("combined_headers: %s", combined_headers) + + url_with_params = append_query_params(self._websocket_url, combined_options) + try: + self._socket = connect(url_with_params, additional_headers=combined_headers) + self._exit_event.clear() + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + # delegate the listening thread to external object + if self._delegate is not None: + self._logger.notice("_delegate is enabled. this is usually the speaker") + self._delegate.set_pull_callback(self._socket.recv) + self._delegate.set_push_callback(self._process_message) + else: + self._logger.notice("create _listening thread") + self._listen_thread = threading.Thread(target=self._listening) + self._listen_thread.start() + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + # push open event + self._emit( + WebSocketEvents(WebSocketEvents.Open), + OpenResponse(type=WebSocketEvents.Open), + ) + + self._logger.notice("start succeeded") + self._logger.debug("AbstractSyncWebSocketClient.start LEAVE") + return True + except websockets.ConnectionClosed as e: + self._logger.error( + "ConnectionClosed in AbstractSyncWebSocketClient.start: %s", e + ) + self._logger.debug("AbstractSyncWebSocketClient.start LEAVE") + if self._config.options.get("termination_exception_connect", False): + raise e + return False + except websockets.exceptions.WebSocketException as e: + self._logger.error( + "WebSocketException in AbstractSyncWebSocketClient.start: %s", e + ) + self._logger.debug("AbstractSyncWebSocketClient.start LEAVE") + if self._config.options.get("termination_exception_connect", False): + raise e + return False + except Exception as e: # pylint: disable=broad-except + self._logger.error( + "WebSocketException in AbstractSyncWebSocketClient.start: %s", e + ) + self._logger.debug("AbstractSyncWebSocketClient.start LEAVE") + if self._config.options.get("termination_exception_connect", False): + raise e + return False + + def is_connected(self) -> bool: + """ + Returns the connection status of the WebSocket. + """ + return self._socket is not None + + # pylint: enable=too-many-statements,too-many-branches + + @abstractmethod + def on(self, event: WebSocketEvents, handler: Callable) -> None: + """ + Registers an event handler for the WebSocket connection. + """ + raise NotImplementedError("no on method") + + @abstractmethod + def _emit(self, event: WebSocketEvents, *args, **kwargs) -> None: + """ + Emits an event to the WebSocket connection. + """ + raise NotImplementedError("no _emit method") + + # pylint: disable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches + def _listening( + self, + ) -> None: + """ + Listens for messages from the WebSocket connection. + """ + self._logger.debug("AbstractSyncWebSocketClient._listening ENTER") + + while True: + try: + if self._exit_event.is_set(): + self._logger.notice("_listening exiting gracefully") + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + return + + if self._socket is None: + self._logger.warning("socket is empty") + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + return + + message = self._socket.recv() + + if message is None: + self._logger.info("message is None") + continue + + self._logger.spam("data type: %s", type(message)) + + if isinstance(message, bytes): + self._logger.debug("Binary data received") + self._process_binary(message) + else: + self._logger.debug("Text data received") + self._process_text(message) + + self._logger.notice("_listening Succeeded") + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.notice(f"_listening({e.code}) exiting gracefully") + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + return + + except websockets.exceptions.ConnectionClosed as e: + if e.code in [1000, 1001]: + self._logger.notice(f"_listening({e.code}) exiting gracefully") + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + return + + # we need to explicitly call self._signal_exit() here because we are hanging on a recv() + # note: this is different than the speak websocket client + self._logger.error( + "ConnectionClosed in AbstractSyncWebSocketClient._listening with code %s: %s", + e.code, + e.reason, + ) + cc_error: ErrorResponse = ErrorResponse( + "ConnectionClosed in AbstractSyncWebSocketClient._listening", + f"{e}", + "ConnectionClosed", + ) + self._emit( + WebSocketEvents(WebSocketEvents.Error), + cc_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + # signal exit and close + self._signal_exit() + + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + + if self._config.options.get("termination_exception") == "true": + raise + return + + except websockets.exceptions.WebSocketException as e: + self._logger.error( + "WebSocketException in AbstractSyncWebSocketClient._listening with: %s", + e, + ) + ws_error: ErrorResponse = ErrorResponse( + "WebSocketException in AbstractSyncWebSocketClient._listening", + f"{e}", + "WebSocketException", + ) + self._emit( + WebSocketEvents(WebSocketEvents.Error), + ws_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + # signal exit and close + self._signal_exit() + + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + + if self._config.options.get("termination_exception") == "true": + raise + return + + except Exception as e: # pylint: disable=broad-except + self._logger.error( + "Exception in AbstractSyncWebSocketClient._listening: %s", e + ) + e_error: ErrorResponse = ErrorResponse( + "Exception in AbstractSyncWebSocketClient._listening", + f"{e}", + "Exception", + ) + self._emit( + WebSocketEvents(WebSocketEvents.Error), + e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + # signal exit and close + self._signal_exit() + + self._logger.debug("AbstractSyncWebSocketClient._listening LEAVE") + + if self._config.options.get("termination_exception") == "true": + raise + return + + # pylint: enable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches + + def _process_message(self, message: Union[str, bytes]) -> None: + if isinstance(message, bytes): + self._process_binary(message) + else: + self._process_text(message) + + @abstractmethod + def _process_text(self, message: str) -> None: + raise NotImplementedError("no _process_text method") + + @abstractmethod + def _process_binary(self, message: bytes) -> None: + raise NotImplementedError("no _process_binary method") + + @abstractmethod + def _close_message(self) -> bool: + raise NotImplementedError("no _close_message method") + + # pylint: disable=too-many-return-statements,too-many-branches + def send(self, data: Union[str, bytes]) -> bool: + """ + Sends data over the WebSocket connection. + """ + self._logger.spam("AbstractSyncWebSocketClient.send ENTER") + + if self._exit_event.is_set(): + self._logger.notice("send exiting gracefully") + self._logger.debug("AbstractSyncWebSocketClient.send LEAVE") + return False + + if not self.is_connected(): + self._logger.notice("is_connected is False") + self._logger.debug("AbstractSyncWebSocketClient.send LEAVE") + return False + + if self._socket is not None: + with self._lock_send: + try: + self._socket.send(data) + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.notice(f"send() exiting gracefully: {e.code}") + self._logger.debug("AbstractSyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return True + except websockets.exceptions.ConnectionClosed as e: + if e.code in [1000, 1001]: + self._logger.notice(f"send({e.code}) exiting gracefully") + self._logger.debug("AbstractSyncWebSocketClient.send LEAVE") + if ( + self._config.options.get("termination_exception_send") + == "true" + ): + raise + return True + self._logger.error("send() failed - ConnectionClosed: %s", str(e)) + self._logger.spam("AbstractSyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False + except websockets.exceptions.WebSocketException as e: + self._logger.error("send() failed - WebSocketException: %s", str(e)) + self._logger.spam("AbstractSyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False + except Exception as e: # pylint: disable=broad-except + self._logger.error("send() failed - Exception: %s", str(e)) + self._logger.spam("AbstractSyncWebSocketClient.send LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False + + self._logger.spam("send() succeeded") + self._logger.spam("AbstractSyncWebSocketClient.send LEAVE") + return True + + self._logger.spam("send() failed. socket is None") + self._logger.spam("AbstractSyncWebSocketClient.send LEAVE") + return False + + # pylint: enable=too-many-return-statements,too-many-branches + + def finish(self) -> bool: + """ + Closes the WebSocket connection gracefully. + """ + self._logger.spam("AbstractSyncWebSocketClient.finish ENTER") + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("before running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + # signal exit + self._signal_exit() + + # stop the threads + self._logger.verbose("cancelling tasks...") + if self._listen_thread is not None: + self._listen_thread.join() + self._listen_thread = None + self._logger.notice("listening thread joined") + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("before running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + self._logger.notice("finish succeeded") + self._logger.spam("AbstractSyncWebSocketClient.finish LEAVE") + return True + + # signals the WebSocket connection to exit + def _signal_exit(self) -> None: + # closes the WebSocket connection gracefully + self._logger.notice("closing socket...") + if self._socket is not None: + self._logger.notice("sending Close...") + try: + # if the socket connection is closed, the following line might throw an error + self._close_message() + except websockets.exceptions.ConnectionClosedOK as e: + self._logger.notice("_signal_exit - ConnectionClosedOK: %s", e.code) + except websockets.exceptions.ConnectionClosed as e: + self._logger.error("_signal_exit - ConnectionClosed: %s", e.code) + except websockets.exceptions.WebSocketException as e: + self._logger.error("_signal_exit - WebSocketException: %s", str(e)) + except Exception as e: # pylint: disable=broad-except + self._logger.error("_signal_exit - Exception: %s", str(e)) + + # push close event + try: + self._emit( + WebSocketEvents(WebSocketEvents.Close), + CloseResponse(type=WebSocketEvents.Close), + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + except Exception as e: # pylint: disable=broad-except + self._logger.error("_signal_exit - Exception: %s", e) + + # wait for task to send + time.sleep(0.5) + + # signal exit + self._exit_event.set() + + # closes the WebSocket connection gracefully + self._logger.verbose("clean up socket...") + if self._socket is not None: + self._logger.verbose("socket.wait_closed...") + try: + self._socket.close() + except websockets.exceptions.WebSocketException as e: + self._logger.error("socket.wait_closed failed: %s", e) + + self._socket = None diff --git a/deepgram/clients/common/v1/errors.py b/deepgram/clients/common/v1/errors.py index 41a6947c..e0b5ac8e 100644 --- a/deepgram/clients/common/v1/errors.py +++ b/deepgram/clients/common/v1/errors.py @@ -35,3 +35,43 @@ def __init__(self, message: str): def __str__(self): return f"{self.name}: {self.message}" + + +class DeepgramApiError(Exception): + """ + Exception raised for known errors (in json response format) related to the Deepgram API. + + Attributes: + message (str): The error message describing the exception. + status (str): The HTTP status associated with the API error. + original_error (str - json): The original error that was raised. + """ + + def __init__(self, message: str, status: str, original_error=None): + super().__init__(message) + self.name = "DeepgramApiError" + self.status = status + self.message = message + self.original_error = original_error + + def __str__(self): + return f"{self.name}: {self.message} (Status: {self.status})" + + +class DeepgramUnknownApiError(DeepgramApiError): + """ + Exception raised for unknown errors related to the Deepgram API. + + Attributes: + message (str): The error message describing the exception. + status (str): The HTTP status associated with the API error. + """ + + def __init__(self, message: str, status: str): + super().__init__(message, status) + self.name = "DeepgramUnknownApiError" + self.status = status + self.message = message + + def __str__(self): + return f"{self.name}: {self.message} (Status: {self.status})" diff --git a/deepgram/clients/common/v1/helpers.py b/deepgram/clients/common/v1/helpers.py new file mode 100644 index 00000000..c7429acd --- /dev/null +++ b/deepgram/clients/common/v1/helpers.py @@ -0,0 +1,53 @@ +# Copyright 2023-2024 Deepgram SDK contributors. All Rights Reserved. +# Use of this source code is governed by a MIT license that can be found in the LICENSE file. +# SPDX-License-Identifier: MIT + +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode +from typing import Dict, Optional +import re + + +# This function appends query parameters to a URL +def append_query_params(url: str, params: Optional[Dict] = None): + """ + Appends query parameters to a URL + """ + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + if params is not None: + for key, value in params.items(): + if value is None: + continue + if isinstance(value, bool): + value = str(value).lower() + if isinstance(value, list): + for item in value: + query_params[key] = query_params.get(key, []) + [str(item)] + else: + query_params[key] = [str(value)] + + updated_query_string = urlencode(query_params, doseq=True) + updated_url = parsed_url._replace(query=updated_query_string).geturl() + return updated_url + + +# This function converts a URL to a WebSocket URL +def convert_to_websocket_url(base_url: str, endpoint: str): + """ + Converts a URL to a WebSocket URL + """ + use_ssl = True # Default to true + if re.match(r"^https?://", base_url, re.IGNORECASE): + if "http://" in base_url: + use_ssl = False # Override to false if http:// is found + base_url = base_url.replace("https://", "").replace("http://", "") + if not re.match(r"^wss?://", base_url, re.IGNORECASE): + if use_ssl: + base_url = "wss://" + base_url + else: + base_url = "ws://" + base_url + parsed_url = urlparse(base_url) + domain = parsed_url.netloc + websocket_url = urlunparse((parsed_url.scheme, domain, endpoint, "", "", "")) + return websocket_url diff --git a/deepgram/clients/common/v1/websocket_events.py b/deepgram/clients/common/v1/websocket_events.py new file mode 100644 index 00000000..4ab36a16 --- /dev/null +++ b/deepgram/clients/common/v1/websocket_events.py @@ -0,0 +1,19 @@ +# Copyright 2024 Deepgram SDK contributors. All Rights Reserved. +# Use of this source code is governed by a MIT license that can be found in the LICENSE file. +# SPDX-License-Identifier: MIT + +from aenum import StrEnum + +# Constants mapping to events from the Deepgram API + + +class WebSocketEvents(StrEnum): + """ + Enumerates the possible events that can be received from the Deepgram API + """ + + Open: str = "Open" + Close: str = "Close" + Warning: str = "Warning" + Error: str = "Error" + Unhandled: str = "Unhandled" diff --git a/deepgram/clients/errors.py b/deepgram/clients/errors.py index 291e0c1e..197b3c7e 100644 --- a/deepgram/clients/errors.py +++ b/deepgram/clients/errors.py @@ -14,43 +14,3 @@ class DeepgramModuleError(Exception): def __init__(self, message: str): super().__init__(message) self.name = "DeepgramModuleError" - - -class DeepgramApiError(Exception): - """ - Exception raised for known errors (in json response format) related to the Deepgram API. - - Attributes: - message (str): The error message describing the exception. - status (str): The HTTP status associated with the API error. - original_error (str - json): The original error that was raised. - """ - - def __init__(self, message: str, status: str, original_error=None): - super().__init__(message) - self.name = "DeepgramApiError" - self.status = status - self.message = message - self.original_error = original_error - - def __str__(self): - return f"{self.name}: {self.message} (Status: {self.status})" - - -class DeepgramUnknownApiError(Exception): - """ - Exception raised for unknown errors related to the Deepgram API. - - Attributes: - message (str): The error message describing the exception. - status (str): The HTTP status associated with the API error. - """ - - def __init__(self, message: str, status: str): - super().__init__(message, status) - self.name = "DeepgramUnknownApiError" - self.status = status - self.message = message - - def __str__(self): - return f"{self.name}: {self.message} (Status: {self.status})" diff --git a/deepgram/clients/helpers.py b/deepgram/clients/helpers.py deleted file mode 100644 index 1a634cca..00000000 --- a/deepgram/clients/helpers.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright 2023-2024 Deepgram SDK contributors. All Rights Reserved. -# Use of this source code is governed by a MIT license that can be found in the LICENSE file. -# SPDX-License-Identifier: MIT - -from urllib.parse import urlparse, parse_qs, urlencode -from typing import Dict - - -def append_query_params(url: str, params: Dict) -> str: - """ - Appends query parameters to a URL. - """ - parsed_url = urlparse(url) - query_params = parse_qs(parsed_url.query) - - for key, value in params.items(): - if value is None: - continue - if isinstance(value, bool): - value = str(value).lower() - if isinstance(value, list): - for item in value: - query_params[key] = query_params.get(key, []) + [str(item)] - else: - query_params[key] = [str(value)] - - updated_query_string = urlencode(query_params, doseq=True) - updated_url = parsed_url._replace(query=updated_query_string).geturl() - return updated_url diff --git a/deepgram/clients/listen/v1/rest/async_client.py b/deepgram/clients/listen/v1/rest/async_client.py index e5e01d29..ef9360a4 100644 --- a/deepgram/clients/listen/v1/rest/async_client.py +++ b/deepgram/clients/listen/v1/rest/async_client.py @@ -9,8 +9,8 @@ from .....utils import verboselogs from .....options import DeepgramClientOptions -from ....abstract_async_client import AbstractAsyncRestClient -from ....common.v1.errors import DeepgramError, DeepgramTypeError +from ....common import AbstractAsyncRestClient +from ....common import DeepgramError, DeepgramTypeError from .helpers import is_buffer_source, is_readstream_source, is_url_source from .options import ( diff --git a/deepgram/clients/listen/v1/rest/client.py b/deepgram/clients/listen/v1/rest/client.py index 30e15912..3accab56 100644 --- a/deepgram/clients/listen/v1/rest/client.py +++ b/deepgram/clients/listen/v1/rest/client.py @@ -9,8 +9,8 @@ from .....utils import verboselogs from .....options import DeepgramClientOptions -from ....abstract_sync_client import AbstractSyncRestClient -from ....common.v1.errors import DeepgramError, DeepgramTypeError +from ....common import AbstractSyncRestClient +from ....common import DeepgramError, DeepgramTypeError from .helpers import is_buffer_source, is_readstream_source, is_url_source from .options import ( diff --git a/deepgram/clients/listen/v1/websocket/async_client.py b/deepgram/clients/listen/v1/websocket/async_client.py index f2a687a4..6dbad20b 100644 --- a/deepgram/clients/listen/v1/websocket/async_client.py +++ b/deepgram/clients/listen/v1/websocket/async_client.py @@ -4,18 +4,15 @@ import asyncio import json import logging -from typing import Dict, Union, Optional, cast, Any +from typing import Dict, Union, Optional, cast, Any, Callable from datetime import datetime import threading -import websockets -from websockets.client import WebSocketClientProtocol - from .....utils import verboselogs from .....options import DeepgramClientOptions from ...enums import LiveTranscriptionEvents -from ..helpers import convert_to_websocket_url, append_query_params -from ....common.v1.errors import DeepgramError +from ....common import AbstractAsyncWebSocketClient +from ....common import DeepgramError from .response import ( OpenResponse, @@ -27,7 +24,7 @@ ErrorResponse, UnhandledResponse, ) -from .options import LiveOptions, ListenWebSocketOptions +from .options import ListenWebSocketOptions ONE_SECOND = 1 HALF_SECOND = 0.5 @@ -35,7 +32,9 @@ PING_INTERVAL = 20 -class AsyncListenWebSocketClient: # pylint: disable=too-many-instance-attributes +class AsyncListenWebSocketClient( + AbstractAsyncWebSocketClient +): # pylint: disable=too-many-instance-attributes """ Client for interacting with Deepgram's live transcription services over WebSockets. @@ -48,12 +47,9 @@ class AsyncListenWebSocketClient: # pylint: disable=too-many-instance-attribute _logger: verboselogs.VerboseLogger _config: DeepgramClientOptions _endpoint: str - _websocket_url: str - _socket: WebSocketClientProtocol _event_handlers: Dict[LiveTranscriptionEvents, list] - _listen_thread: Union[asyncio.Task, None] _keep_alive_thread: Union[asyncio.Task, None] _flush_thread: Union[asyncio.Task, None] _last_datagram: Optional[datetime] = None @@ -65,7 +61,7 @@ class AsyncListenWebSocketClient: # pylint: disable=too-many-instance-attribute def __init__(self, config: DeepgramClientOptions): if config is None: - raise DeepgramError("Config are required") + raise DeepgramError("Config is required") self._logger = verboselogs.VerboseLogger(__name__) self._logger.addHandler(logging.StreamHandler()) @@ -74,18 +70,20 @@ def __init__(self, config: DeepgramClientOptions): self._config = config self._endpoint = "v1/listen" - self._listen_thread = None - self._keep_alive_thread = None self._flush_thread = None + self._keep_alive_thread = None - # events - self._exit_event = asyncio.Event() + # auto flush + self._last_datagram = None + self._lock_flush = threading.Lock() # init handlers self._event_handlers = { event: [] for event in LiveTranscriptionEvents.__members__.values() } - self._websocket_url = convert_to_websocket_url(self._config.url, self._endpoint) + + # call the parent constructor + super().__init__(self._config, self._endpoint) # pylint: disable=too-many-branches,too-many-statements async def start( @@ -132,38 +130,26 @@ async def start( else: self._options = {} - combined_options = self._options - if self._addons is not None: - self._logger.info("merging addons to options") - combined_options.update(self._addons) - self._logger.info("new options: %s", combined_options) - self._logger.debug("combined_options: %s", combined_options) - - combined_headers = self._config.headers - if self._headers is not None: - self._logger.info("merging headers to options") - combined_headers.update(self._headers) - self._logger.info("new headers: %s", combined_headers) - self._logger.debug("combined_headers: %s", combined_headers) - - url_with_params = append_query_params(self._websocket_url, combined_options) - try: - self._socket = await websockets.connect( - url_with_params, - extra_headers=combined_headers, - ping_interval=PING_INTERVAL, - ) - self._exit_event.clear() + # call parent start + if ( + await super().start( + self._options, + self._addons, + self._headers, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + is False + ): + self._logger.error("AsyncListenWebSocketClient.start failed") + self._logger.debug("AsyncListenWebSocketClient.start LEAVE") + return False # debug the threads for thread in threading.enumerate(): self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # listen thread - self._listen_thread = asyncio.create_task(self._listening()) - # keepalive thread if self._config.is_keep_alive_enabled(): self._logger.notice("keepalive is enabled") @@ -183,31 +169,10 @@ async def start( self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # push open event - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Open), - OpenResponse(type=LiveTranscriptionEvents.Open), - ) - self._logger.notice("start succeeded") self._logger.debug("AsyncListenWebSocketClient.start LEAVE") return True - except websockets.ConnectionClosed as e: - self._logger.error( - "ConnectionClosed in AsyncListenWebSocketClient.start: %s", e - ) - self._logger.debug("AsyncListenWebSocketClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in AsyncListenWebSocketClient.start: %s", e - ) - self._logger.debug("AsyncListenWebSocketClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise - return False + except Exception as e: # pylint: disable=broad-except self._logger.error( "WebSocketException in AsyncListenWebSocketClient.start: %s", e @@ -217,22 +182,15 @@ async def start( raise return False - async def is_connected(self) -> bool: - """ - Returns the connection status of the WebSocket. - """ - return self._socket is not None - # pylint: enable=too-many-branches,too-many-statements - def on(self, event: LiveTranscriptionEvents, handler) -> None: + def on(self, event: LiveTranscriptionEvents, handler: Callable) -> None: """ Registers event handlers for specific events. """ self._logger.info("event subscribed: %s", event) if event in LiveTranscriptionEvents.__members__.values() and callable(handler): - if handler not in self._event_handlers[event]: - self._event_handlers[event].append(handler) + self._event_handlers[event].append(handler) # triggers the registered event handlers for a specific event async def _emit(self, event: LiveTranscriptionEvents, *args, **kwargs) -> None: @@ -264,217 +222,141 @@ async def _emit(self, event: LiveTranscriptionEvents, *args, **kwargs) -> None: self._logger.debug("AsyncListenWebSocketClient._emit LEAVE") - # pylint: disable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches - async def _listening(self) -> None: + async def _process_text(self, message: str) -> None: """ - Listens for messages from the WebSocket connection. + Processes messages received over the WebSocket connection. """ - self._logger.debug("AsyncListenWebSocketClient._listening ENTER") - - while True: - try: - if self._exit_event.is_set(): - self._logger.notice("_listening exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") - return - - if self._socket is None: - self._logger.warning("socket is empty") - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") - return + self._logger.debug("AsyncListenWebSocketClient._process_text ENTER") - message = str(await self._socket.recv()) - - if message is None: - self._logger.spam("message is None") - continue - - data = json.loads(message) - response_type = data.get("type") - self._logger.debug("response_type: %s, data: %s", response_type, data) - - match response_type: - case LiveTranscriptionEvents.Open: - open_result: OpenResponse = OpenResponse.from_json(message) - self._logger.verbose("OpenResponse: %s", open_result) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Open), - open=open_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Transcript: - msg_result: LiveResultResponse = LiveResultResponse.from_json( - message - ) - self._logger.verbose("LiveResultResponse: %s", msg_result) - - # auto flush - if self._config.is_inspecting_listen(): - inspect_res = await self._inspect(msg_result) - if not inspect_res: - self._logger.error("inspect_res failed") - - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Transcript), - result=msg_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Metadata: - meta_result: MetadataResponse = MetadataResponse.from_json( - message - ) - self._logger.verbose("MetadataResponse: %s", meta_result) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Metadata), - metadata=meta_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.SpeechStarted: - ss_result: SpeechStartedResponse = ( - SpeechStartedResponse.from_json(message) - ) - self._logger.verbose("SpeechStartedResponse: %s", ss_result) - await self._emit( - LiveTranscriptionEvents( - LiveTranscriptionEvents.SpeechStarted - ), - speech_started=ss_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.UtteranceEnd: - ue_result: UtteranceEndResponse = ( - UtteranceEndResponse.from_json(message) - ) - self._logger.verbose("UtteranceEndResponse: %s", ue_result) - await self._emit( - LiveTranscriptionEvents( - LiveTranscriptionEvents.UtteranceEnd - ), - utterance_end=ue_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Close: - close_result: CloseResponse = CloseResponse.from_json(message) - self._logger.verbose("CloseResponse: %s", close_result) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Close), - close=close_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Error: - err_error: ErrorResponse = ErrorResponse.from_json(message) - self._logger.verbose("ErrorResponse: %s", err_error) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=err_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case _: - self._logger.warning( - "Unknown Message: response_type: %s, data: %s", - response_type, - data, - ) - unhandled_error: UnhandledResponse = UnhandledResponse( - type=LiveTranscriptionEvents( - LiveTranscriptionEvents.Unhandled - ), - raw=message, - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Unhandled), - unhandled=unhandled_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") - return - - # we need to explicitly call self._signal_exit() here because we are hanging on a recv() - # note: this is different than the speak websocket client - self._logger.error( - "ConnectionClosed in AsyncListenWebSocketClient._listening with code %s: %s", - e.code, - e.reason, - ) - cc_error: ErrorResponse = ErrorResponse( - "ConnectionClosed in AsyncListenWebSocketClient._listening", - f"{e}", - "ConnectionClosed", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=cc_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise + try: + self._logger.debug("Text data received") + if len(message) == 0: + self._logger.debug("message is empty") + self._logger.debug("AsyncListenWebSocketClient._process_text LEAVE") return - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in AsyncListenWebSocketClient._listening: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in AsyncListenWebSocketClient._listening", - f"{e}", - "WebSocketException", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=ws_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return + data = json.loads(message) + response_type = data.get("type") + self._logger.debug("response_type: %s, data: %s", response_type, data) + + match response_type: + case LiveTranscriptionEvents.Open: + open_result: OpenResponse = OpenResponse.from_json(message) + self._logger.verbose("OpenResponse: %s", open_result) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Open), + open=open_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Transcript: + msg_result: LiveResultResponse = LiveResultResponse.from_json( + message + ) + self._logger.verbose("LiveResultResponse: %s", msg_result) + + # auto flush + if self._config.is_inspecting_listen(): + inspect_res = await self._inspect(msg_result) + if not inspect_res: + self._logger.error("inspect_res failed") + + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Transcript), + result=msg_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Metadata: + meta_result: MetadataResponse = MetadataResponse.from_json(message) + self._logger.verbose("MetadataResponse: %s", meta_result) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Metadata), + metadata=meta_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.SpeechStarted: + ss_result: SpeechStartedResponse = SpeechStartedResponse.from_json( + message + ) + self._logger.verbose("SpeechStartedResponse: %s", ss_result) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.SpeechStarted), + speech_started=ss_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.UtteranceEnd: + ue_result: UtteranceEndResponse = UtteranceEndResponse.from_json( + message + ) + self._logger.verbose("UtteranceEndResponse: %s", ue_result) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.UtteranceEnd), + utterance_end=ue_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Close: + close_result: CloseResponse = CloseResponse.from_json(message) + self._logger.verbose("CloseResponse: %s", close_result) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Close), + close=close_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Error: + err_error: ErrorResponse = ErrorResponse.from_json(message) + self._logger.verbose("ErrorResponse: %s", err_error) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Error), + error=err_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case _: + self._logger.warning( + "Unknown Message: response_type: %s, data: %s", + response_type, + data, + ) + unhandled_error: UnhandledResponse = UnhandledResponse( + type=LiveTranscriptionEvents(LiveTranscriptionEvents.Unhandled), + raw=message, + ) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Unhandled), + unhandled=unhandled_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + self._logger.notice("_process_text Succeeded") + self._logger.debug("AsyncListenWebSocketClient._process_text LEAVE") - except Exception as e: # pylint: disable=broad-except - self._logger.error( - "Exception in AsyncListenWebSocketClient._listening: %s", e - ) - e_error: ErrorResponse = ErrorResponse( - "Exception in AsyncListenWebSocketClient._listening", - f"{e}", - "Exception", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=e_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) + except Exception as e: # pylint: disable=broad-except + self._logger.error( + "Exception in AsyncListenWebSocketClient._process_text: %s", e + ) + e_error: ErrorResponse = ErrorResponse( + "Exception in AsyncListenWebSocketClient._process_text", + f"{e}", + "Exception", + ) + await self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Error), + error=e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) - # signal exit and close - await self._signal_exit() + # signal exit and close + await self._signal_exit() - self._logger.debug("AsyncListenWebSocketClient._listening LEAVE") + self._logger.debug("AsyncListenWebSocketClient._process_text LEAVE") - if self._config.options.get("termination_exception") == "true": - raise - return + if self._config.options.get("termination_exception") == "true": + raise + return # pylint: enable=too-many-return-statements,too-many-statements + async def _process_binary(self, message: bytes) -> None: + raise NotImplementedError("no _process_binary method should be called") + # pylint: disable=too-many-return-statements async def _keep_alive(self) -> None: """ @@ -493,78 +375,10 @@ async def _keep_alive(self) -> None: self._logger.debug("AsyncListenWebSocketClient._keep_alive LEAVE") return - if self._socket is None: - self._logger.notice("socket is None, exiting keep_alive") - self._logger.debug("AsyncListenWebSocketClient._keep_alive LEAVE") - return - # deepgram keepalive if counter % DEEPGRAM_INTERVAL == 0: await self.keep_alive() - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_keep_alive({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._keep_alive LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_keep_alive({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._keep_alive LEAVE") - return - - # we need to explicitly call self._signal_exit() here because we are hanging on a recv() - # note: this is different than the speak websocket client - self._logger.error( - "ConnectionClosed in AsyncListenWebSocketClient._keep_alive with code %s: %s", - e.code, - e.reason, - ) - cc_error: ErrorResponse = ErrorResponse( - "ConnectionClosed in AsyncListenWebSocketClient._keep_alive", - f"{e}", - "ConnectionClosed", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=cc_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncListenWebSocketClient._keep_alive LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in AsyncListenWebSocketClient._keep_alive: %s", - e, - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in AsyncListenWebSocketClient._keep_alive", - f"{e}", - "Exception", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=ws_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncListenWebSocketClient._keep_alive LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - except Exception as e: # pylint: disable=broad-except self._logger.error( "Exception in AsyncListenWebSocketClient._keep_alive: %s", e @@ -612,11 +426,6 @@ async def _flush(self) -> None: self._logger.debug("AsyncListenWebSocketClient._flush LEAVE") return - if self._socket is None: - self._logger.notice("socket is None, exiting flush") - self._logger.debug("AsyncListenWebSocketClient._flush LEAVE") - return - if self._last_datagram is None: self._logger.debug("AutoFlush last_datagram is None") continue @@ -631,68 +440,6 @@ async def _flush(self) -> None: self._last_datagram = None await self.finalize() - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._flush LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient._flush LEAVE") - return - - # we need to explicitly call self._signal_exit() here because we are hanging on a recv() - # note: this is different than the speak websocket client - self._logger.error( - "ConnectionClosed in AsyncListenWebSocketClient._flush with code %s: %s", - e.code, - e.reason, - ) - cc_error: ErrorResponse = ErrorResponse( - "ConnectionClosed in AsyncListenWebSocketClient._flush", - f"{e}", - "ConnectionClosed", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=cc_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncListenWebSocketClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in AsyncListenWebSocketClient._flush: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in AsyncListenWebSocketClient._flush", - f"{e}", - "Exception", - ) - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=ws_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncListenWebSocketClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - except Exception as e: # pylint: disable=broad-except self._logger.error( "Exception in AsyncListenWebSocketClient._flush: %s", e @@ -722,85 +469,12 @@ async def _flush(self) -> None: # pylint: enable=too-many-return-statements - # pylint: disable=too-many-return-statements - - async def send(self, data: Union[str, bytes]) -> bool: - """ - Sends data over the WebSocket connection. - """ - self._logger.spam("AsyncListenWebSocketClient.send ENTER") - - if self._exit_event.is_set(): - self._logger.notice("send exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient.send LEAVE") - return False - - if not await self.is_connected(): - self._logger.notice("is_connected is False") - self._logger.debug("AsyncListenWebSocketClient.send LEAVE") - return False - - if self._socket is not None: - try: - await self._socket.send(data) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"send() exiting gracefully: {e.code}") - self._logger.debug("AsyncListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return True - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"send({e.code}) exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return True - - self._logger.error("send() failed - ConnectionClosed: %s", str(e)) - self._logger.spam("AsyncListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error("send() failed - WebSocketException: %s", str(e)) - self._logger.spam("AsyncListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except Exception as e: # pylint: disable=broad-except - self._logger.error("send() failed - Exception: %s", str(e)) - self._logger.spam("AsyncListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - - self._logger.spam("send() succeeded") - self._logger.spam("AsyncListenWebSocketClient.send LEAVE") - return True - - self._logger.spam("send() failed. socket is None") - self._logger.spam("AsyncListenWebSocketClient.send LEAVE") - return False - - # pylint: enable=too-many-return-statements - async def keep_alive(self) -> bool: """ Sends a KeepAlive message """ self._logger.spam("AsyncListenWebSocketClient.keep_alive ENTER") - if self._exit_event.is_set(): - self._logger.notice("keep_alive exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient.keep_alive LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("AsyncListenWebSocketClient.keep_alive LEAVE") - return False - self._logger.notice("Sending KeepAlive...") ret = await self.send(json.dumps({"type": "KeepAlive"})) @@ -820,16 +494,6 @@ async def finalize(self) -> bool: """ self._logger.spam("AsyncListenWebSocketClient.finalize ENTER") - if self._exit_event.is_set(): - self._logger.notice("finalize exiting gracefully") - self._logger.debug("AsyncListenWebSocketClient.finalize LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("AsyncListenWebSocketClient.finalize LEAVE") - return False - self._logger.notice("Sending Finalize...") ret = await self.send(json.dumps({"type": "Finalize"})) @@ -843,18 +507,22 @@ async def finalize(self) -> bool: return True + async def _close_message(self) -> bool: + return await self.send(json.dumps({"type": "CloseStream"})) + async def finish(self) -> bool: """ Closes the WebSocket connection gracefully. """ self._logger.debug("AsyncListenWebSocketClient.finish ENTER") - # signal exit - await self._signal_exit() - # stop the threads self._logger.verbose("cancelling tasks...") try: + # call parent finish + if await super().finish() is False: + self._logger.error("AsyncListenWebSocketClient.finish failed") + # Before cancelling, check if the tasks were created # debug the threads for thread in threading.enumerate(): @@ -872,13 +540,9 @@ async def finish(self) -> bool: tasks.append(self._flush_thread) self._logger.notice("processing _flush_thread cancel...") - if self._listen_thread is not None: - self._listen_thread.cancel() - tasks.append(self._listen_thread) - self._logger.notice("processing _listen_thread cancel...") - # Use asyncio.gather to wait for tasks to be cancelled - await asyncio.gather(*filter(None, tasks), return_exceptions=True) + # Prevent indefinite waiting by setting a timeout + await asyncio.wait_for(asyncio.gather(*tasks), timeout=10) self._logger.notice("threads joined") # debug the threads @@ -895,49 +559,10 @@ async def finish(self) -> bool: self._logger.debug("AsyncListenWebSocketClient.finish LEAVE") return False - async def _signal_exit(self) -> None: - # send close event - self._logger.verbose("closing socket...") - if self._socket is not None: - self._logger.verbose("send CloseStream...") - try: - # if the socket connection is closed, the following line might throw an error - await self.send(json.dumps({"type": "CloseStream"})) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice("_signal_exit - ConnectionClosedOK: %s", e.code) - except websockets.exceptions.ConnectionClosed as e: - self._logger.error("_signal_exit - ConnectionClosed: %s", e.code) - except websockets.exceptions.WebSocketException as e: - self._logger.error("_signal_exit - WebSocketException: %s", str(e)) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_signal_exit - Exception: %s", str(e)) - - # push close event - try: - await self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Close), - close=CloseResponse(type=LiveTranscriptionEvents.Close), - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_emit - Exception: %s", e) - - # wait for task to send - await asyncio.sleep(0.5) - - # signal exit - self._exit_event.set() - - # closes the WebSocket connection gracefully - self._logger.verbose("clean up socket...") - if self._socket is not None: - self._logger.verbose("socket.wait_closed...") - try: - await self._socket.close() - except websockets.exceptions.WebSocketException as e: - self._logger.error("socket.wait_closed failed: %s", e) - - self._socket = None # type: ignore + except asyncio.TimeoutError as e: + self._logger.error("tasks cancellation timed out: %s", e) + self._logger.debug("AsyncListenWebSocketClient.finish LEAVE") + return False async def _inspect(self, msg_result: LiveResultResponse) -> bool: # auto flush_inspect is generically used to track any messages you might want to snoop on diff --git a/deepgram/clients/listen/v1/websocket/client.py b/deepgram/clients/listen/v1/websocket/client.py index a18315c4..a31f88f3 100644 --- a/deepgram/clients/listen/v1/websocket/client.py +++ b/deepgram/clients/listen/v1/websocket/client.py @@ -4,18 +4,15 @@ import json import time import logging -from typing import Dict, Union, Optional, cast, Any +from typing import Dict, Union, Optional, cast, Any, Callable from datetime import datetime import threading -from websockets.sync.client import connect, ClientConnection -import websockets - from .....utils import verboselogs from .....options import DeepgramClientOptions from ...enums import LiveTranscriptionEvents -from ..helpers import convert_to_websocket_url, append_query_params -from ....common.v1.errors import DeepgramError +from ....common import AbstractSyncWebSocketClient +from ....common import DeepgramError from .response import ( OpenResponse, @@ -27,7 +24,7 @@ ErrorResponse, UnhandledResponse, ) -from .options import LiveOptions, ListenWebSocketOptions +from .options import ListenWebSocketOptions ONE_SECOND = 1 HALF_SECOND = 0.5 @@ -35,7 +32,9 @@ PING_INTERVAL = 20 -class ListenWebSocketClient: # pylint: disable=too-many-instance-attributes +class ListenWebSocketClient( + AbstractSyncWebSocketClient +): # pylint: disable=too-many-instance-attributes """ Client for interacting with Deepgram's live transcription services over WebSockets. @@ -48,15 +47,10 @@ class ListenWebSocketClient: # pylint: disable=too-many-instance-attributes _logger: verboselogs.VerboseLogger _config: DeepgramClientOptions _endpoint: str - _websocket_url: str - _socket: ClientConnection - _exit_event: threading.Event - _lock_send: threading.Lock _lock_flush: threading.Lock _event_handlers: Dict[LiveTranscriptionEvents, list] - _listen_thread: Union[threading.Thread, None] _keep_alive_thread: Union[threading.Thread, None] _flush_thread: Union[threading.Thread, None] _last_datagram: Optional[datetime] = None @@ -68,7 +62,7 @@ class ListenWebSocketClient: # pylint: disable=too-many-instance-attributes def __init__(self, config: DeepgramClientOptions): if config is None: - raise DeepgramError("Config are required") + raise DeepgramError("Config is required") self._logger = verboselogs.VerboseLogger(__name__) self._logger.addHandler(logging.StreamHandler()) @@ -76,14 +70,9 @@ def __init__(self, config: DeepgramClientOptions): self._config = config self._endpoint = "v1/listen" - self._lock_send = threading.Lock() self._flush_thread = None self._keep_alive_thread = None - self._listen_thread = None - - # exit - self._exit_event = threading.Event() # auto flush self._last_datagram = None @@ -93,7 +82,9 @@ def __init__(self, config: DeepgramClientOptions): self._event_handlers = { event: [] for event in LiveTranscriptionEvents.__members__.values() } - self._websocket_url = convert_to_websocket_url(self._config.url, self._endpoint) + + # call the parent constructor + super().__init__(self._config, self._endpoint) # pylint: disable=too-many-statements,too-many-branches def start( @@ -140,34 +131,26 @@ def start( else: self._options = {} - combined_options = self._options - if self._addons is not None: - self._logger.info("merging addons to options") - combined_options.update(self._addons) - self._logger.info("new options: %s", combined_options) - self._logger.debug("combined_options: %s", combined_options) - - combined_headers = self._config.headers - if self._headers is not None: - self._logger.info("merging headers to options") - combined_headers.update(self._headers) - self._logger.info("new headers: %s", combined_headers) - self._logger.debug("combined_headers: %s", combined_headers) - - url_with_params = append_query_params(self._websocket_url, combined_options) try: - self._socket = connect(url_with_params, additional_headers=combined_headers) - self._exit_event.clear() + # call parent start + if ( + super().start( + self._options, + self._addons, + self._headers, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + is False + ): + self._logger.error("AsyncListenWebSocketClient.start failed") + self._logger.debug("AsyncListenWebSocketClient.start LEAVE") + return False # debug the threads for thread in threading.enumerate(): self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # listening thread - self._listen_thread = threading.Thread(target=self._listening) - self._listen_thread.start() - # keepalive thread if self._config.is_keep_alive_enabled(): self._logger.notice("keepalive is enabled") @@ -189,29 +172,10 @@ def start( self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # push open event - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Open), - OpenResponse(type=LiveTranscriptionEvents.Open), - ) - self._logger.notice("start succeeded") self._logger.debug("ListenWebSocketClient.start LEAVE") return True - except websockets.ConnectionClosed as e: - self._logger.error("ConnectionClosed in ListenWebSocketClient.start: %s", e) - self._logger.debug("ListenWebSocketClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise e - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in ListenWebSocketClient.start: %s", e - ) - self._logger.debug("ListenWebSocketClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise e - return False + except Exception as e: # pylint: disable=broad-except self._logger.error( "WebSocketException in ListenWebSocketClient.start: %s", e @@ -221,16 +185,10 @@ def start( raise e return False - def is_connected(self) -> bool: - """ - Returns the connection status of the WebSocket. - """ - return self._socket is not None - # pylint: enable=too-many-statements,too-many-branches def on( - self, event: LiveTranscriptionEvents, handler + self, event: LiveTranscriptionEvents, handler: Callable ) -> None: # registers event handlers for specific events """ Registers event handlers for specific events. @@ -243,221 +201,164 @@ def _emit(self, event: LiveTranscriptionEvents, *args, **kwargs) -> None: """ Emits events to the registered event handlers. """ + self._logger.debug("ListenWebSocketClient._emit ENTER") + self._logger.debug("callback handlers for: %s", event) + + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + self._logger.debug("callback handlers for: %s", event) for handler in self._event_handlers[event]: handler(self, *args, **kwargs) + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) + + self._logger.debug("ListenWebSocketClient._emit LEAVE") + # pylint: disable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches - def _listening( - self, - ) -> None: + def _process_text(self, message: str) -> None: """ - Listens for messages from the WebSocket connection. + Processes messages received over the WebSocket connection. """ - self._logger.debug("ListenWebSocketClient._listening ENTER") - - while True: - try: - if self._exit_event.is_set(): - self._logger.notice("_listening exiting gracefully") - self._logger.debug("ListenWebSocketClient._listening LEAVE") - return - - if self._socket is None: - self._logger.warning("socket is empty") - self._logger.debug("ListenWebSocketClient._listening LEAVE") - return - - message = str(self._socket.recv()) - - if message is None: - self._logger.info("message is empty") - continue - - data = json.loads(message) - response_type = data.get("type") - self._logger.debug("response_type: %s, data: %s", response_type, data) - - match response_type: - case LiveTranscriptionEvents.Open: - open_result: OpenResponse = OpenResponse.from_json(message) - self._logger.verbose("OpenResponse: %s", open_result) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Open), - open=open_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Transcript: - msg_result: LiveResultResponse = LiveResultResponse.from_json( - message - ) - self._logger.verbose("LiveResultResponse: %s", msg_result) - - # auto flush - if self._config.is_inspecting_listen(): - inspect_res = self._inspect(msg_result) - if not inspect_res: - self._logger.error("inspect_res failed") - - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Transcript), - result=msg_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Metadata: - meta_result: MetadataResponse = MetadataResponse.from_json( - message - ) - self._logger.verbose("MetadataResponse: %s", meta_result) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Metadata), - metadata=meta_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.SpeechStarted: - ss_result: SpeechStartedResponse = ( - SpeechStartedResponse.from_json(message) - ) - self._logger.verbose("SpeechStartedResponse: %s", ss_result) - self._emit( - LiveTranscriptionEvents( - LiveTranscriptionEvents.SpeechStarted - ), - speech_started=ss_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.UtteranceEnd: - ue_result: UtteranceEndResponse = ( - UtteranceEndResponse.from_json(message) - ) - self._logger.verbose("UtteranceEndResponse: %s", ue_result) - self._emit( - LiveTranscriptionEvents( - LiveTranscriptionEvents.UtteranceEnd - ), - utterance_end=ue_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Close: - close_result: CloseResponse = CloseResponse.from_json(message) - self._logger.verbose("CloseResponse: %s", close_result) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Close), - close=close_result, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case LiveTranscriptionEvents.Error: - err_error: ErrorResponse = ErrorResponse.from_json(message) - self._logger.verbose("ErrorResponse: %s", err_error) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), - error=err_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - case _: - self._logger.warning( - "Unknown Message: response_type: %s, data: %s", - response_type, - data, - ) - unhandled_error: UnhandledResponse = UnhandledResponse( - type=LiveTranscriptionEvents( - LiveTranscriptionEvents.Unhandled - ), - raw=message, - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Unhandled), - unhandled=unhandled_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient._listening LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient._listening LEAVE") - return - - # we need to explicitly call self._signal_exit() here because we are hanging on a recv() - # note: this is different than the speak websocket client - self._logger.error( - "ConnectionClosed in ListenWebSocketClient._listening with code %s: %s", - e.code, - e.reason, - ) - cc_error: ErrorResponse = ErrorResponse( - "ConnectionClosed in ListenWebSocketClient._listening", - f"{e}", - "ConnectionClosed", - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), cc_error - ) - - # signal exit and close - self._signal_exit() + self._logger.debug("ListenWebSocketClient._process_text ENTER") - self._logger.debug("ListenWebSocketClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise + try: + if len(message) == 0: + self._logger.debug("message is empty") + self._logger.debug("ListenWebSocketClient._process_text LEAVE") return - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in ListenWebSocketClient._listening with: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in ListenWebSocketClient._listening", - f"{e}", - "WebSocketException", - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), ws_error - ) - - # signal exit and close - self._signal_exit() - - self._logger.debug("ListenWebSocketClient._listening LEAVE") + data = json.loads(message) + response_type = data.get("type") + self._logger.debug("response_type: %s, data: %s", response_type, data) + + match response_type: + case LiveTranscriptionEvents.Open: + open_result: OpenResponse = OpenResponse.from_json(message) + self._logger.verbose("OpenResponse: %s", open_result) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Open), + open=open_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Transcript: + msg_result: LiveResultResponse = LiveResultResponse.from_json( + message + ) + self._logger.verbose("LiveResultResponse: %s", msg_result) + + # auto flush + if self._config.is_inspecting_listen(): + inspect_res = self._inspect(msg_result) + if not inspect_res: + self._logger.error("inspect_res failed") + + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Transcript), + result=msg_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Metadata: + meta_result: MetadataResponse = MetadataResponse.from_json(message) + self._logger.verbose("MetadataResponse: %s", meta_result) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Metadata), + metadata=meta_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.SpeechStarted: + ss_result: SpeechStartedResponse = SpeechStartedResponse.from_json( + message + ) + self._logger.verbose("SpeechStartedResponse: %s", ss_result) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.SpeechStarted), + speech_started=ss_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.UtteranceEnd: + ue_result: UtteranceEndResponse = UtteranceEndResponse.from_json( + message + ) + self._logger.verbose("UtteranceEndResponse: %s", ue_result) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.UtteranceEnd), + utterance_end=ue_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Close: + close_result: CloseResponse = CloseResponse.from_json(message) + self._logger.verbose("CloseResponse: %s", close_result) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Close), + close=close_result, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case LiveTranscriptionEvents.Error: + err_error: ErrorResponse = ErrorResponse.from_json(message) + self._logger.verbose("ErrorResponse: %s", err_error) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Error), + error=err_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + case _: + self._logger.warning( + "Unknown Message: response_type: %s, data: %s", + response_type, + data, + ) + unhandled_error: UnhandledResponse = UnhandledResponse( + type=LiveTranscriptionEvents(LiveTranscriptionEvents.Unhandled), + raw=message, + ) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Unhandled), + unhandled=unhandled_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + self._logger.notice("_process_text Succeeded") + self._logger.debug("SpeakStreamClient._process_text LEAVE") - if self._config.options.get("termination_exception") == "true": - raise - return - - except Exception as e: # pylint: disable=broad-except - self._logger.error( - "Exception in ListenWebSocketClient._listening: %s", e - ) - e_error: ErrorResponse = ErrorResponse( - "Exception in ListenWebSocketClient._listening", - f"{e}", - "Exception", - ) - self._logger.error( - "Exception in ListenWebSocketClient._listening: %s", str(e) - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), e_error - ) + except Exception as e: # pylint: disable=broad-except + self._logger.error( + "Exception in ListenWebSocketClient._process_text: %s", e + ) + e_error: ErrorResponse = ErrorResponse( + "Exception in ListenWebSocketClient._process_text", + f"{e}", + "Exception", + ) + self._logger.error( + "Exception in ListenWebSocketClient._process_text: %s", str(e) + ) + self._emit( + LiveTranscriptionEvents(LiveTranscriptionEvents.Error), + e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) - # signal exit and close - self._signal_exit() + # signal exit and close + self._signal_exit() - self._logger.debug("ListenWebSocketClient._listening LEAVE") + self._logger.debug("ListenWebSocketClient._process_text LEAVE") - if self._config.options.get("termination_exception") == "true": - raise - return + if self._config.options.get("termination_exception") == "true": + raise + return # pylint: enable=too-many-return-statements,too-many-statements - ## pylint: disable=too-many-return-statements + def _process_binary(self, message: bytes) -> None: + raise NotImplementedError("no _process_binary method should be called") + + # pylint: disable=too-many-return-statements def _keep_alive(self) -> None: self._logger.debug("ListenWebSocketClient._keep_alive ENTER") @@ -472,74 +373,10 @@ def _keep_alive(self) -> None: self._logger.debug("ListenWebSocketClient._keep_alive LEAVE") return - if self._socket is None: - self._logger.notice("socket is None, exiting keep_alive") - self._logger.debug("ListenWebSocketClient._keep_alive LEAVE") - return - # deepgram keepalive if counter % DEEPGRAM_INTERVAL == 0: self.keep_alive() - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_keep_alive({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient._keep_alive LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_keep_alive({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient._keep_alive LEAVE") - return - - # we need to explicitly call self._signal_exit() here because we are hanging on a recv() - # note: this is different than the speak websocket client - self._logger.error( - "ConnectionClosed in ListenWebSocketClient._keep_alive with code %s: %s", - e.code, - e.reason, - ) - cc_error: ErrorResponse = ErrorResponse( - "ConnectionClosed in ListenWebSocketClient._keep_alive", - f"{e}", - "ConnectionClosed", - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), cc_error - ) - - # signal exit and close - self._signal_exit() - - self._logger.debug("ListenWebSocketClient._keep_alive LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in ListenWebSocketClient._keep_alive with: %s", - e, - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in ListenWebSocketClient._keep_alive", - f"{e}", - "WebSocketException", - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), ws_error - ) - - # signal exit and close - self._signal_exit() - - self._logger.debug("ListenWebSocketClient._keep_alive LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - except Exception as e: # pylint: disable=broad-except self._logger.error( "Exception in ListenWebSocketClient._keep_alive: %s", e @@ -553,7 +390,9 @@ def _keep_alive(self) -> None: "Exception in ListenWebSocketClient._keep_alive: %s", str(e) ) self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), e_error + LiveTranscriptionEvents(LiveTranscriptionEvents.Error), + e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), ) # signal exit and close @@ -565,8 +404,6 @@ def _keep_alive(self) -> None: raise return - # pylint: enable=too-many-return-statements - ## pylint: disable=too-many-return-statements,too-many-statements def _flush(self) -> None: self._logger.debug("ListenWebSocketClient._flush ENTER") @@ -588,11 +425,6 @@ def _flush(self) -> None: self._logger.debug("ListenWebSocketClient._flush LEAVE") return - if self._socket is None: - self._logger.debug("socket is None, exiting flush") - self._logger.debug("ListenWebSocketClient._flush LEAVE") - return - with self._lock_flush: if self._last_datagram is None: self._logger.debug("AutoFlush last_datagram is None") @@ -609,64 +441,6 @@ def _flush(self) -> None: self._last_datagram = None self.finalize() - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient._flush LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient._flush LEAVE") - return - - # we need to explicitly call self._signal_exit() here because we are hanging on a recv() - # note: this is different than the speak websocket client - self._logger.error( - "ConnectionClosed in ListenWebSocketClient._flush with code %s: %s", - e.code, - e.reason, - ) - cc_error: ErrorResponse = ErrorResponse( - "ConnectionClosed in ListenWebSocketClient._flush", - f"{e}", - "ConnectionClosed", - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), cc_error - ) - - # signal exit and close - self._signal_exit() - - self._logger.debug("ListenWebSocketClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in ListenWebSocketClient._flush with: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in ListenWebSocketClient._flush", - f"{e}", - "WebSocketException", - ) - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), ws_error - ) - - # signal exit and close - self._signal_exit() - - self._logger.debug("ListenWebSocketClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - except Exception as e: # pylint: disable=broad-except self._logger.error("Exception in ListenWebSocketClient._flush: %s", e) e_error: ErrorResponse = ErrorResponse( @@ -678,7 +452,9 @@ def _flush(self) -> None: "Exception in ListenWebSocketClient._flush: %s", str(e) ) self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Error), e_error + LiveTranscriptionEvents(LiveTranscriptionEvents.Error), + e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), ) # signal exit and close @@ -692,87 +468,12 @@ def _flush(self) -> None: # pylint: enable=too-many-return-statements - # pylint: disable=too-many-return-statements - def send(self, data: Union[str, bytes]) -> bool: - """ - Sends data over the WebSocket connection. - """ - self._logger.spam("ListenWebSocketClient.send ENTER") - - if self._exit_event.is_set(): - self._logger.notice("send exiting gracefully") - self._logger.debug("ListenWebSocketClient.send LEAVE") - return False - - if not self.is_connected(): - self._logger.notice("is_connected is False") - self._logger.debug("ListenWebSocketClient.send LEAVE") - return False - - if self._socket is not None: - with self._lock_send: - try: - self._socket.send(data) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"send() exiting gracefully: {e.code}") - self._logger.debug("ListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return True - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"send({e.code}) exiting gracefully") - self._logger.debug("ListenWebSocketClient.send LEAVE") - if ( - self._config.options.get("termination_exception_send") - == "true" - ): - raise - return True - self._logger.error("send() failed - ConnectionClosed: %s", str(e)) - self._logger.spam("ListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error("send() failed - WebSocketException: %s", str(e)) - self._logger.spam("ListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except Exception as e: # pylint: disable=broad-except - self._logger.error("send() failed - Exception: %s", str(e)) - self._logger.spam("ListenWebSocketClient.send LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - - self._logger.spam("send() succeeded") - self._logger.spam("ListenWebSocketClient.send LEAVE") - return True - - self._logger.spam("send() failed. socket is None") - self._logger.spam("ListenWebSocketClient.send LEAVE") - return False - - # pylint: enable=too-many-return-statements - def keep_alive(self) -> bool: """ Sends a KeepAlive message """ self._logger.spam("ListenWebSocketClient.keep_alive ENTER") - if self._exit_event.is_set(): - self._logger.notice("keep_alive exiting gracefully") - self._logger.debug("ListenWebSocketClient.keep_alive LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("ListenWebSocketClient.keep_alive LEAVE") - return False - self._logger.notice("Sending KeepAlive...") ret = self.send(json.dumps({"type": "KeepAlive"})) @@ -792,16 +493,6 @@ def finalize(self) -> bool: """ self._logger.spam("ListenWebSocketClient.finalize ENTER") - if self._exit_event.is_set(): - self._logger.notice("finalize exiting gracefully") - self._logger.debug("ListenWebSocketClient.finalize LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("ListenWebSocketClient.finalize LEAVE") - return False - self._logger.notice("Sending Finalize...") ret = self.send(json.dumps({"type": "Finalize"})) @@ -815,6 +506,9 @@ def finalize(self) -> bool: return True + def _close_message(self) -> bool: + return self.send(json.dumps({"type": "CloseStream"})) + # closes the WebSocket connection gracefully def finish(self) -> bool: """ @@ -822,14 +516,15 @@ def finish(self) -> bool: """ self._logger.spam("ListenWebSocketClient.finish ENTER") + # call parent finish + if super().finish() is False: + self._logger.error("ListenWebSocketClient.finish failed") + # debug the threads for thread in threading.enumerate(): self._logger.debug("before running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # signal exit - self._signal_exit() - # stop the threads self._logger.verbose("cancelling tasks...") if self._flush_thread is not None: @@ -856,50 +551,6 @@ def finish(self) -> bool: self._logger.spam("ListenWebSocketClient.finish LEAVE") return True - # signals the WebSocket connection to exit - def _signal_exit(self) -> None: - # closes the WebSocket connection gracefully - self._logger.notice("closing socket...") - if self._socket is not None: - self._logger.notice("sending CloseStream...") - try: - # if the socket connection is closed, the following line might throw an error - self._socket.send(json.dumps({"type": "CloseStream"})) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice("_signal_exit - ConnectionClosedOK: %s", e.code) - except websockets.exceptions.ConnectionClosed as e: - self._logger.error("_signal_exit - ConnectionClosed: %s", e.code) - except websockets.exceptions.WebSocketException as e: - self._logger.error("_signal_exit - WebSocketException: %s", str(e)) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_signal_exit - Exception: %s", str(e)) - - # push close event - try: - self._emit( - LiveTranscriptionEvents(LiveTranscriptionEvents.Close), - CloseResponse(type=LiveTranscriptionEvents.Close), - ) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_signal_exit - Exception: %s", e) - - # wait for task to send - time.sleep(0.5) - - # signal exit - self._exit_event.set() - - # closes the WebSocket connection gracefully - self._logger.verbose("clean up socket...") - if self._socket is not None: - self._logger.verbose("socket.wait_closed...") - try: - self._socket.close() - except websockets.exceptions.WebSocketException as e: - self._logger.error("socket.wait_closed failed: %s", e) - - self._socket = None # type: ignore - def _inspect(self, msg_result: LiveResultResponse) -> bool: # auto flush_inspect is generically used to track any messages you might want to snoop on # place additional logic here to inspect messages of interest diff --git a/deepgram/clients/listen/v1/websocket/response.py b/deepgram/clients/listen/v1/websocket/response.py index 43cfe3e4..5a086b0c 100644 --- a/deepgram/clients/listen/v1/websocket/response.py +++ b/deepgram/clients/listen/v1/websocket/response.py @@ -91,9 +91,6 @@ def __getitem__(self, key): return _dict[key] -# unique - - @dataclass class Metadata(BaseResponse): """ diff --git a/deepgram/clients/manage/v1/async_client.py b/deepgram/clients/manage/v1/async_client.py index 1d7e7a86..e3fead44 100644 --- a/deepgram/clients/manage/v1/async_client.py +++ b/deepgram/clients/manage/v1/async_client.py @@ -9,7 +9,7 @@ from ....utils import verboselogs from ....options import DeepgramClientOptions -from ...abstract_async_client import AbstractAsyncRestClient +from ...common import AbstractAsyncRestClient from .response import ( Message, diff --git a/deepgram/clients/manage/v1/client.py b/deepgram/clients/manage/v1/client.py index 24b4a1d8..7307f213 100644 --- a/deepgram/clients/manage/v1/client.py +++ b/deepgram/clients/manage/v1/client.py @@ -9,7 +9,7 @@ from ....utils import verboselogs from ....options import DeepgramClientOptions -from ...abstract_sync_client import AbstractSyncRestClient +from ...common import AbstractSyncRestClient from .response import ( Message, diff --git a/deepgram/clients/selfhosted/v1/async_client.py b/deepgram/clients/selfhosted/v1/async_client.py index 3daf7fa8..cc9bb175 100644 --- a/deepgram/clients/selfhosted/v1/async_client.py +++ b/deepgram/clients/selfhosted/v1/async_client.py @@ -9,7 +9,7 @@ from ....utils import verboselogs from ....options import DeepgramClientOptions -from ...abstract_async_client import AbstractAsyncRestClient +from ...common import AbstractAsyncRestClient class AsyncSelfHostedClient(AbstractAsyncRestClient): diff --git a/deepgram/clients/selfhosted/v1/client.py b/deepgram/clients/selfhosted/v1/client.py index 94785d66..86d63ff2 100644 --- a/deepgram/clients/selfhosted/v1/client.py +++ b/deepgram/clients/selfhosted/v1/client.py @@ -9,7 +9,7 @@ from ....utils import verboselogs from ....options import DeepgramClientOptions -from ...abstract_sync_client import AbstractSyncRestClient +from ...common import AbstractSyncRestClient class SelfHostedClient(AbstractSyncRestClient): diff --git a/deepgram/clients/speak/v1/rest/async_client.py b/deepgram/clients/speak/v1/rest/async_client.py index 7f6c2064..fc83926b 100644 --- a/deepgram/clients/speak/v1/rest/async_client.py +++ b/deepgram/clients/speak/v1/rest/async_client.py @@ -14,8 +14,8 @@ from .....utils import verboselogs from .....options import DeepgramClientOptions -from ....abstract_async_client import AbstractAsyncRestClient -from ....common.v1.errors import DeepgramError, DeepgramTypeError +from ....common import AbstractAsyncRestClient +from ....common import DeepgramError, DeepgramTypeError from .helpers import is_text_source from .options import SpeakRESTOptions, FileSource diff --git a/deepgram/clients/speak/v1/rest/client.py b/deepgram/clients/speak/v1/rest/client.py index 7153ec63..e17877e4 100644 --- a/deepgram/clients/speak/v1/rest/client.py +++ b/deepgram/clients/speak/v1/rest/client.py @@ -13,8 +13,8 @@ from .....utils import verboselogs from .....options import DeepgramClientOptions -from ....abstract_sync_client import AbstractSyncRestClient -from ....common.v1.errors import DeepgramError, DeepgramTypeError +from ....common import AbstractSyncRestClient +from ....common import DeepgramError, DeepgramTypeError from .helpers import is_text_source from .options import SpeakRESTOptions, FileSource diff --git a/deepgram/clients/speak/v1/websocket/async_client.py b/deepgram/clients/speak/v1/websocket/async_client.py index 4b20de45..9720c85d 100644 --- a/deepgram/clients/speak/v1/websocket/async_client.py +++ b/deepgram/clients/speak/v1/websocket/async_client.py @@ -5,18 +5,15 @@ import asyncio import json import logging -from typing import Dict, Union, Optional, cast, Any +from typing import Dict, Union, Optional, cast, Any, Callable from datetime import datetime import threading -import websockets -from websockets.client import WebSocketClientProtocol - from .....utils import verboselogs from .....options import DeepgramClientOptions from ...enums import SpeakWebSocketEvents, SpeakWebSocketMessage -from .helpers import convert_to_websocket_url, append_query_params -from ....common.v1.errors import DeepgramError +from ....common import AbstractAsyncWebSocketClient +from ....common import DeepgramError from .response import ( OpenResponse, @@ -38,7 +35,9 @@ PING_INTERVAL = 20 -class AsyncSpeakWSClient: # pylint: disable=too-many-instance-attributes +class AsyncSpeakWSClient( + AbstractAsyncWebSocketClient +): # pylint: disable=too-many-instance-attributes """ Client for interacting with Deepgram's text-to-speech services over WebSockets. @@ -51,12 +50,9 @@ class AsyncSpeakWSClient: # pylint: disable=too-many-instance-attributes _logger: verboselogs.VerboseLogger _config: DeepgramClientOptions _endpoint: str - _websocket_url: str - _socket: WebSocketClientProtocol _event_handlers: Dict[SpeakWebSocketEvents, list] - _listen_thread: Union[asyncio.Task, None] _flush_thread: Union[asyncio.Task, None] _last_datagram: Optional[datetime] = None _flush_count: int @@ -69,6 +65,8 @@ class AsyncSpeakWSClient: # pylint: disable=too-many-instance-attributes _speaker: Optional[Speaker] = None def __init__(self, config: DeepgramClientOptions): + if config is None: + raise DeepgramError("Config is required") self._logger = verboselogs.VerboseLogger(__name__) self._logger.addHandler(logging.StreamHandler()) self._logger.setLevel(config.verbose) @@ -76,13 +74,9 @@ def __init__(self, config: DeepgramClientOptions): self._config = config self._endpoint = "v1/speak" - self._listen_thread = None self._flush_thread = None - # events - self._exit_event = asyncio.Event() - - # flush + # auto flush self._last_datagram = None self._flush_count = 0 @@ -90,7 +84,6 @@ def __init__(self, config: DeepgramClientOptions): self._event_handlers = { event: [] for event in SpeakWebSocketEvents.__members__.values() } - self._websocket_url = convert_to_websocket_url(self._config.url, self._endpoint) if self._config.options.get("speaker_playback") == "true": self._logger.info("speaker_playback is enabled") @@ -101,6 +94,11 @@ def __init__(self, config: DeepgramClientOptions): if channels is None: channels = CHANNELS device_index = self._config.options.get("speaker_playback_device_index") + + self._logger.debug("rate: %s", rate) + self._logger.debug("channels: %s", channels) + self._logger.debug("device_index: %s", device_index) + if device_index is not None: self._speaker = Speaker( rate=rate, @@ -115,6 +113,9 @@ def __init__(self, config: DeepgramClientOptions): verbose=self._config.verbose, ) + # call the parent constructor + super().__init__(self._config, self._endpoint) + # pylint: disable=too-many-branches,too-many-statements async def start( self, @@ -127,7 +128,7 @@ async def start( """ Starts the WebSocket connection for text-to-speech synthesis. """ - self._logger.debug("AsyncSpeakStreamClient.start ENTER") + self._logger.debug("AsyncSpeakWebSocketClient.start ENTER") self._logger.info("options: %s", options) self._logger.info("addons: %s", addons) self._logger.info("headers: %s", headers) @@ -136,7 +137,7 @@ async def start( if isinstance(options, SpeakWSOptions) and not options.check(): self._logger.error("options.check failed") - self._logger.debug("SpeakStreamClient.start LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient.start LEAVE") raise DeepgramError("Fatal text-to-speech options error") self._addons = addons @@ -160,43 +161,35 @@ async def start( else: self._options = {} - combined_options = self._options - if self._addons is not None: - self._logger.info("merging addons to options") - combined_options.update(self._addons) - self._logger.info("new options: %s", combined_options) - self._logger.debug("combined_options: %s", combined_options) - - combined_headers = self._config.headers - if self._headers is not None: - self._logger.info("merging headers to options") - combined_headers.update(self._headers) - self._logger.info("new headers: %s", combined_headers) - self._logger.debug("combined_headers: %s", combined_headers) - - url_with_params = append_query_params(self._websocket_url, combined_options) - try: - self._socket = await websockets.connect( - url_with_params, extra_headers=combined_headers - ) - self._exit_event.clear() + # speaker substitutes the listening thread + if self._speaker is not None: + self._logger.notice("passing speaker to delegate_listening") + super().delegate_listening(self._speaker) + + # call parent start + if ( + await super().start( + self._options, + self._addons, + self._headers, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + is False + ): + self._logger.error("AsyncSpeakWebSocketClient.start failed") + self._logger.debug("AsyncSpeakWebSocketClient.start LEAVE") + return False + + if self._speaker is not None: + self._logger.notice("start delegate_listening thread") + self._speaker.start() # debug the threads for thread in threading.enumerate(): self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # listen thread - if self._speaker is not None: - self._logger.notice("speaker_playback is enabled") - self._speaker.set_pull_callback(self._socket.recv) - self._speaker.set_push_callback(self._process_message) - self._speaker.start() - else: - self._logger.notice("create _listening thread") - self._listen_thread = asyncio.create_task(self._listening()) - # flush thread if self._config.is_auto_flush_speak_enabled(): self._logger.notice("autoflush is enabled") @@ -209,64 +202,35 @@ async def start( self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # push open event - await self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Open), - OpenResponse(type=SpeakWebSocketEvents.Open), - ) - self._logger.notice("start succeeded") - self._logger.debug("AsyncSpeakStreamClient.start LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient.start LEAVE") return True - except websockets.ConnectionClosed as e: - self._logger.notice( - "ConnectionClosed in AsyncSpeakStreamClient.start: %s", e - ) - self._logger.debug("AsyncSpeakStreamClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in AsyncSpeakStreamClient.start: %s", e - ) - self._logger.debug("AsyncSpeakStreamClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise - return False except Exception as e: # pylint: disable=broad-except self._logger.error( - "WebSocketException in AsyncSpeakStreamClient.start: %s", e + "WebSocketException in AsyncSpeakWebSocketClient.start: %s", e ) - self._logger.debug("AsyncSpeakStreamClient.start LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient.start LEAVE") if self._config.options.get("termination_exception_connect") == "true": raise return False # pylint: enable=too-many-branches,too-many-statements - async def is_connected(self) -> bool: - """ - Returns the connection status of the WebSocket. - """ - return self._socket is not None - - def on(self, event: SpeakWebSocketEvents, handler) -> None: + def on(self, event: SpeakWebSocketEvents, handler: Callable) -> None: """ Registers event handlers for specific events. """ self._logger.info("event subscribed: %s", event) if event in SpeakWebSocketEvents.__members__.values() and callable(handler): - if handler not in self._event_handlers[event]: - self._event_handlers[event].append(handler) + self._event_handlers[event].append(handler) # triggers the registered event handlers for a specific event async def _emit(self, event: SpeakWebSocketEvents, *args, **kwargs) -> None: """ Emits events to the registered event handlers. """ - self._logger.debug("AsyncSpeakStreamClient._emit ENTER") + self._logger.debug("AsyncSpeakWebSocketClient._emit ENTER") self._logger.debug("callback handlers for: %s", event) # debug the threads @@ -281,7 +245,7 @@ async def _emit(self, event: SpeakWebSocketEvents, *args, **kwargs) -> None: if tasks: self._logger.debug("waiting for tasks to finish...") - await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*filter(None, tasks), return_exceptions=True) tasks.clear() # debug the threads @@ -289,126 +253,22 @@ async def _emit(self, event: SpeakWebSocketEvents, *args, **kwargs) -> None: self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - self._logger.debug("AsyncSpeakStreamClient._emit LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient._emit LEAVE") - # pylint: disable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches - async def _listening(self) -> None: + async def _process_text(self, message: Union[str, bytes]) -> None: """ - Listens for messages from the WebSocket connection. + Processes messages received over the WebSocket connection. """ - self._logger.debug("AsyncSpeakStreamClient._listening ENTER") - - while True: - try: - if self._exit_event.is_set(): - self._logger.notice("_listening exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") - return - - if self._socket is None: - self._logger.warning("socket is empty") - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") - return - - message = await self._socket.recv() - - if message is None: - self._logger.spam("message is None") - continue - - if isinstance(message, bytes): - self._logger.debug("Binary data received") - - await self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.AudioData), - data=message, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - else: - self._logger.debug("Text data received") - await self._process_message(message) - - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") - return - - # no need to call self._signal_exit() here because we are already closed - # note: this is different than the listen websocket client - self._logger.notice( - "ConnectionClosed in AsyncSpeakStreamClient._listening with code %s: %s", - e.code, - e.reason, - ) - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in AsyncSpeakStreamClient._listening: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in AsyncSpeakStreamClient._listening", - f"{e}", - "WebSocketException", - ) - await self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Error), - error=ws_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except Exception as e: # pylint: disable=broad-except - self._logger.error( - "Exception in AsyncSpeakStreamClient._listening: %s", e - ) - e_error: ErrorResponse = ErrorResponse( - "Exception in AsyncSpeakStreamClient._listening", - f"{e}", - "Exception", - ) - await self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Error), - error=e_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - await self._signal_exit() + self._logger.debug("AsyncSpeakWebSocketClient._process_text ENTER") - self._logger.debug("AsyncSpeakStreamClient._listening LEAVE") + try: + self._logger.debug("Text data received") - if self._config.options.get("termination_exception") == "true": - raise + if len(message) == 0: + self._logger.debug("message is empty") + self._logger.debug("AsyncSpeakWebSocketClient._process_text LEAVE") return - async def _process_message(self, message: str) -> None: - self._logger.debug("AsyncSpeakStreamClient._process_message ENTER") - - if len(message) == 0: - self._logger.debug("message is empty") - self._logger.debug("AsyncSpeakStreamClient._process_message LEAVE") - return - - try: data = json.loads(message) response_type = data.get("type") self._logger.debug("response_type: %s, data: %s", response_type, data) @@ -487,7 +347,7 @@ async def _process_message(self, message: str) -> None: ) unhandled_error: UnhandledResponse = UnhandledResponse( type=SpeakWebSocketEvents(SpeakWebSocketEvents.Unhandled), - raw=message, + raw=str(message), ) await self._emit( SpeakWebSocketEvents(SpeakWebSocketEvents.Unhandled), @@ -495,15 +355,15 @@ async def _process_message(self, message: str) -> None: **dict(cast(Dict[Any, Any], self._kwargs)), ) - self._logger.notice("_process_message Succeeded") - self._logger.debug("AsyncSpeakStreamClient._process_message LEAVE") + self._logger.notice("_process_text Succeeded") + self._logger.debug("AsyncSpeakWebSocketClient._process_text LEAVE") except Exception as e: # pylint: disable=broad-except self._logger.error( - "Exception in AsyncSpeakStreamClient._process_message: %s", e + "Exception in AsyncSpeakWebSocketClient._process_text: %s", e ) e_error: ErrorResponse = ErrorResponse( - "Exception in AsyncSpeakStreamClient._process_message", + "Exception in AsyncSpeakWebSocketClient._process_text", f"{e}", "Exception", ) @@ -516,22 +376,35 @@ async def _process_message(self, message: str) -> None: # signal exit and close await self._signal_exit() - self._logger.debug("AsyncSpeakStreamClient._process_message LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient._process_text LEAVE") if self._config.options.get("termination_exception") == "true": raise return - # pylint: disable=too-many-return-statements + # pylint: enable=too-many-return-statements,too-many-statements + + async def _process_binary(self, message: bytes) -> None: + self._logger.debug("SpeakWebSocketClient._process_binary ENTER") + self._logger.debug("Binary data received") + + await self._emit( + SpeakWebSocketEvents(SpeakWebSocketEvents.AudioData), + data=message, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + self._logger.notice("_process_binary Succeeded") + self._logger.debug("SpeakWebSocketClient._process_binary LEAVE") - ## pylint: disable=too-many-return-statements,too-many-statements + ## pylint: disable=too-many-return-statements async def _flush(self) -> None: - self._logger.debug("AsyncSpeakStreamClient._flush ENTER") + self._logger.debug("AsyncSpeakWebSocketClient._flush ENTER") delta_in_ms_str = self._config.options.get("auto_flush_speak_delta") if delta_in_ms_str is None: self._logger.error("auto_flush_speak_delta is None") - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient._flush LEAVE") return delta_in_ms = float(delta_in_ms_str) @@ -541,12 +414,7 @@ async def _flush(self) -> None: if self._exit_event.is_set(): self._logger.notice("_flush exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") - return - - if self._socket is None: - self._logger.notice("socket is None, exiting flush") - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient._flush LEAVE") return if self._last_datagram is None: @@ -562,63 +430,17 @@ async def _flush(self) -> None: await self.flush() - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") - return - - # no need to call self._signal_exit() here because we are already closed - # note: this is different than the listen websocket client - self._logger.notice( - "ConnectionClosed in AsyncSpeakStreamClient._listening with code %s: %s", - e.code, - e.reason, - ) - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: + except Exception as e: # pylint: disable=broad-except self._logger.error( - "WebSocketException in AsyncSpeakStreamClient._flush: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in AsyncSpeakStreamClient._flush", - f"{e}", - "Exception", - ) - await self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Error), - error=ws_error, - **dict(cast(Dict[Any, Any], self._kwargs)), + "Exception in AsyncSpeakWebSocketClient._flush: %s", e ) - - # signal exit and close - await self._signal_exit() - - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except Exception as e: # pylint: disable=broad-except - self._logger.error("Exception in AsyncSpeakStreamClient._flush: %s", e) e_error: ErrorResponse = ErrorResponse( - "Exception in AsyncSpeakStreamClient._flush", + "Exception in AsyncSpeakWebSocketClient._flush", f"{e}", "Exception", ) self._logger.error( - "Exception in AsyncSpeakStreamClient._flush: %s", str(e) + "Exception in AsyncSpeakWebSocketClient._flush: %s", str(e) ) await self._emit( SpeakWebSocketEvents(SpeakWebSocketEvents.Error), @@ -629,7 +451,7 @@ async def _flush(self) -> None: # signal exit and close await self._signal_exit() - self._logger.debug("AsyncSpeakStreamClient._flush LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient._flush LEAVE") if self._config.options.get("termination_exception") == "true": raise @@ -650,11 +472,15 @@ async def send_text(self, text_input: str) -> bool: """ return await self.send_raw(json.dumps({"type": "Speak", "text": text_input})) - async def send(self, text_input: str) -> bool: + async def send(self, data: Union[bytes, str]) -> bool: """ Alias for send_text. Please see send_text for more information. """ - return await self.send_text(text_input) + if isinstance(data, bytes): + self._logger.error("send() failed - data is bytes") + return False + + return await self.send_text(data) # pylint: disable=unused-argument async def send_control( @@ -686,17 +512,7 @@ async def send_raw(self, msg: str) -> bool: Returns: bool: True if the message was successfully sent, False otherwise. """ - self._logger.spam("AsyncSpeakStreamClient.send_raw ENTER") - - if self._exit_event.is_set(): - self._logger.notice("send_raw exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient.send_raw LEAVE") - return False - - if not await self.is_connected(): - self._logger.notice("is_connected is False") - self._logger.debug("AsyncListenWebSocketClient.send LEAVE") - return False + self._logger.spam("AsyncSpeakWebSocketClient.send_raw ENTER") if self._config.is_inspecting_speak(): try: @@ -719,48 +535,20 @@ async def send_raw(self, msg: str) -> bool: except Exception as e: # pylint: disable=broad-except self._logger.error("send_raw() failed - Exception: %s", str(e)) - if self._socket is not None: - try: - await self._socket.send(msg) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"send_raw() exiting gracefully: {e.code}") - self._logger.debug("AsyncSpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return True - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"send_raw({e.code}) exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return True - - self._logger.error("send_raw() failed - ConnectionClosed: %s", str(e)) - self._logger.spam("AsyncSpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error("send_raw() failed - WebSocketException: %s", str(e)) - self._logger.spam("AsyncSpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except Exception as e: # pylint: disable=broad-except - self._logger.error("send_raw() failed - Exception: %s", str(e)) - self._logger.spam("AsyncSpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise + try: + if await super().send(msg) is False: + self._logger.error("send_raw() failed") + self._logger.spam("AsyncSpeakWebSocketClient.send_raw LEAVE") return False - self._logger.spam("send_raw() succeeded") - self._logger.spam("AsyncSpeakStreamClient.send_raw LEAVE") + self._logger.spam("AsyncSpeakWebSocketClient.send_raw LEAVE") return True - - self._logger.spam("send_raw() failed. socket is None") - self._logger.spam("AsyncSpeakStreamClient.send_raw LEAVE") - return False + except Exception as e: # pylint: disable=broad-except + self._logger.error("send_raw() failed - Exception: %s", str(e)) + self._logger.spam("AsyncSpeakWebSocketClient.send_raw LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False # pylint: enable=too-many-return-statements,too-many-branches @@ -768,28 +556,18 @@ async def flush(self) -> bool: """ Flushes the current buffer and returns generated audio """ - self._logger.spam("AsyncSpeakStreamClient.flush ENTER") - - if self._exit_event.is_set(): - self._logger.notice("flush exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient.flush LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("AsyncSpeakStreamClient.flush LEAVE") - return False + self._logger.spam("AsyncSpeakWebSocketClient.flush ENTER") self._logger.notice("Sending Flush...") ret = await self.send_control(SpeakWebSocketMessage.Flush) if not ret: self._logger.error("flush failed") - self._logger.spam("AsyncSpeakStreamClient.flush LEAVE") + self._logger.spam("AsyncSpeakWebSocketClient.flush LEAVE") return False self._logger.notice("flush succeeded") - self._logger.spam("AsyncSpeakStreamClient.flush LEAVE") + self._logger.spam("AsyncSpeakWebSocketClient.flush LEAVE") return True @@ -797,43 +575,37 @@ async def clear(self) -> bool: """ Clears the current buffer on the server """ - self._logger.spam("AsyncSpeakStreamClient.clear ENTER") - - if self._exit_event.is_set(): - self._logger.notice("clear exiting gracefully") - self._logger.debug("AsyncSpeakStreamClient.clear LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("AsyncSpeakStreamClient.clear LEAVE") - return False + self._logger.spam("AsyncSpeakWebSocketClient.clear ENTER") self._logger.notice("Sending Clear...") ret = await self.send_control(SpeakWebSocketMessage.Clear) if not ret: self._logger.error("clear failed") - self._logger.spam("AsyncSpeakStreamClient.clear LEAVE") + self._logger.spam("AsyncSpeakWebSocketClient.clear LEAVE") return False self._logger.notice("clear succeeded") - self._logger.spam("AsyncSpeakStreamClient.clear LEAVE") + self._logger.spam("AsyncSpeakWebSocketClient.clear LEAVE") return True + async def _close_message(self) -> bool: + return await self.send_control(SpeakWebSocketMessage.Close) + async def finish(self) -> bool: """ Closes the WebSocket connection gracefully. """ - self._logger.debug("AsyncSpeakStreamClient.finish ENTER") - - # signal exit - await self._signal_exit() + self._logger.debug("AsyncSpeakWebSocketClient.finish ENTER") # stop the threads self._logger.verbose("cancelling tasks...") try: + # call parent finish + if await super().finish() is False: + self._logger.error("AsyncListenWebSocketClient.finish failed") + # Before cancelling, check if the tasks were created # debug the threads for thread in threading.enumerate(): @@ -849,21 +621,14 @@ async def finish(self) -> bool: self._logger.notice("speaker stopped") if self._flush_thread is not None: - self._logger.notice("cancelling _flush_thread...") + self._logger.notice("stopping _flush_thread...") self._flush_thread.cancel() tasks.append(self._flush_thread) self._logger.notice("_flush_thread cancelled") - if self._listen_thread is not None: - self._logger.notice("cancelling _listen_thread...") - self._listen_thread.cancel() - tasks.append(self._listen_thread) - self._logger.notice("_listen_thread cancelled") - # Use asyncio.gather to wait for tasks to be cancelled - await asyncio.wait_for( - asyncio.gather(*tasks), timeout=10 - ) # Prevent indefinite waiting + # Prevent indefinite waiting by setting a timeout + await asyncio.wait_for(asyncio.gather(*tasks), timeout=10) self._logger.notice("threads joined") # debug the threads @@ -872,68 +637,19 @@ async def finish(self) -> bool: self._logger.debug("number of active threads: %s", threading.active_count()) self._logger.notice("finish succeeded") - self._logger.spam("AsyncSpeakStreamClient.finish LEAVE") + self._logger.spam("AsyncSpeakWebSocketClient.finish LEAVE") return True - except asyncio.CancelledError as e: - self._logger.error("tasks cancelled error: %s", e) - self._logger.debug("AsyncSpeakStreamClient.finish LEAVE") + except asyncio.CancelledError: + self._logger.debug("tasks cancelled") + self._logger.debug("AsyncSpeakWebSocketClient.finish LEAVE") return False except asyncio.TimeoutError as e: self._logger.error("tasks cancellation timed out: %s", e) - self._logger.debug("AsyncSpeakStreamClient.finish LEAVE") + self._logger.debug("AsyncSpeakWebSocketClient.finish LEAVE") return False - async def _signal_exit(self) -> None: - # send close event - self._logger.verbose("closing socket...") - if self._socket is not None: - self._logger.verbose("send CloseStream...") - try: - # if the socket connection is closed, the following line might throw an error - # need to explicitly use _socket.send (dont use self.send_raw) - await self._socket.send(json.dumps({"type": "CloseStream"})) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice("_signal_exit - ConnectionClosedOK: %s", e.code) - except websockets.exceptions.ConnectionClosed as e: - self._logger.error("_signal_exit - ConnectionClosed: %s", e.code) - except websockets.exceptions.WebSocketException as e: - self._logger.error("_signal_exit - WebSocketException: %s", str(e)) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_signal_exit - Exception: %s", str(e)) - - # close the socket - if self._socket is not None: - await self._socket.close() - - # push close event - try: - await self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Close), - close=CloseResponse(type=SpeakWebSocketEvents.Close), - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_emit - Exception: %s", e) - - # wait for task to send - await asyncio.sleep(0.5) - - # signal exit - self._exit_event.set() - - # closes the WebSocket connection gracefully - self._logger.verbose("clean up socket...") - if self._socket is not None: - self._logger.verbose("socket.wait_closed...") - try: - await self._socket.close() - except websockets.exceptions.WebSocketException as e: - self._logger.error("socket.wait_closed failed: %s", e) - - self._socket = None # type: ignore - async def _inspect(self) -> bool: # auto flush_inspect is generically used to track any messages you might want to snoop on # place additional logic here to inspect messages of interest @@ -949,7 +665,4 @@ async def _inspect(self) -> bool: return True -class AsyncSpeakWebSocketClient(AsyncSpeakWSClient): - """ - AsyncSpeakWebSocketClient is an alias for AsyncSpeakWSClient. - """ +AsyncSpeakWebSocketClient = AsyncSpeakWSClient diff --git a/deepgram/clients/speak/v1/websocket/client.py b/deepgram/clients/speak/v1/websocket/client.py index 56b9491e..5ceec68f 100644 --- a/deepgram/clients/speak/v1/websocket/client.py +++ b/deepgram/clients/speak/v1/websocket/client.py @@ -5,18 +5,15 @@ import json import time import logging -from typing import Dict, Union, Optional, cast, Any +from typing import Dict, Union, Optional, cast, Any, Callable from datetime import datetime import threading -from websockets.sync.client import connect, ClientConnection -import websockets - from .....utils import verboselogs from .....options import DeepgramClientOptions from ...enums import SpeakWebSocketEvents, SpeakWebSocketMessage -from .helpers import convert_to_websocket_url, append_query_params -from ....common.v1.errors import DeepgramError +from ....common import AbstractSyncWebSocketClient +from ....common import DeepgramError from .response import ( OpenResponse, @@ -38,7 +35,9 @@ PING_INTERVAL = 20 -class SpeakWSClient: # pylint: disable=too-many-instance-attributes +class SpeakWSClient( + AbstractSyncWebSocketClient +): # pylint: disable=too-many-instance-attributes """ Client for interacting with Deepgram's text-to-speech services over WebSockets. @@ -51,14 +50,9 @@ class SpeakWSClient: # pylint: disable=too-many-instance-attributes _logger: verboselogs.VerboseLogger _config: DeepgramClientOptions _endpoint: str - _websocket_url: str - _socket: ClientConnection - _exit_event: threading.Event - _lock_send: threading.Lock _event_handlers: Dict[SpeakWebSocketEvents, list] - _listen_thread: Union[threading.Thread, None] _flush_thread: Union[threading.Thread, None] _lock_flush: threading.Lock _last_datagram: Optional[datetime] = None @@ -72,22 +66,20 @@ class SpeakWSClient: # pylint: disable=too-many-instance-attributes _speaker: Optional[Speaker] = None def __init__(self, config: DeepgramClientOptions): + if config is None: + raise DeepgramError("Config is required") + self._logger = verboselogs.VerboseLogger(__name__) self._logger.addHandler(logging.StreamHandler()) self._logger.setLevel(config.verbose) self._config = config self._endpoint = "v1/speak" - self._lock_send = threading.Lock() self._lock_flush = threading.Lock() - self._listen_thread = None self._flush_thread = None - # exit - self._exit_event = threading.Event() - - # flush + # auto flush self._last_datagram = None self._flush_count = 0 @@ -95,7 +87,6 @@ def __init__(self, config: DeepgramClientOptions): self._event_handlers = { event: [] for event in SpeakWebSocketEvents.__members__.values() } - self._websocket_url = convert_to_websocket_url(self._config.url, self._endpoint) if self._config.options.get("speaker_playback") == "true": self._logger.info("speaker_playback is enabled") @@ -106,6 +97,11 @@ def __init__(self, config: DeepgramClientOptions): if channels is None: channels = CHANNELS device_index = self._config.options.get("speaker_playback_device_index") + + self._logger.debug("rate: %s", rate) + self._logger.debug("channels: %s", channels) + self._logger.debug("device_index: %s", device_index) + if device_index is not None: self._speaker = Speaker( rate=rate, @@ -120,6 +116,9 @@ def __init__(self, config: DeepgramClientOptions): verbose=self._config.verbose, ) + # call the parent constructor + super().__init__(self._config, self._endpoint) + # pylint: disable=too-many-statements,too-many-branches def start( self, @@ -132,7 +131,7 @@ def start( """ Starts the WebSocket connection for text-to-speech synthesis. """ - self._logger.debug("SpeakStreamClient.start ENTER") + self._logger.debug("SpeakWebSocketClient.start ENTER") self._logger.info("options: %s", options) self._logger.info("addons: %s", addons) self._logger.info("headers: %s", headers) @@ -141,7 +140,7 @@ def start( if isinstance(options, SpeakWSOptions) and not options.check(): self._logger.error("options.check failed") - self._logger.debug("SpeakStreamClient.start LEAVE") + self._logger.debug("SpeakWebSocketClient.start LEAVE") raise DeepgramError("Fatal text-to-speech options error") self._addons = addons @@ -165,41 +164,35 @@ def start( else: self._options = {} - combined_options = self._options - if self._addons is not None: - self._logger.info("merging addons to options") - combined_options.update(self._addons) - self._logger.info("new options: %s", combined_options) - self._logger.debug("combined_options: %s", combined_options) - - combined_headers = self._config.headers - if self._headers is not None: - self._logger.info("merging headers to options") - combined_headers.update(self._headers) - self._logger.info("new headers: %s", combined_headers) - self._logger.debug("combined_headers: %s", combined_headers) - - url_with_params = append_query_params(self._websocket_url, combined_options) try: - self._socket = connect(url_with_params, additional_headers=combined_headers) - self._exit_event.clear() + # speaker substitutes the listening thread + if self._speaker is not None: + self._logger.notice("passing speaker to delegate_listening") + super().delegate_listening(self._speaker) + + # call parent start + if ( + super().start( + self._options, + self._addons, + self._headers, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + is False + ): + self._logger.error("SpeakWebSocketClient.start failed") + self._logger.debug("SpeakWebSocketClient.start LEAVE") + return False + + if self._speaker is not None: + self._logger.notice("start delegate_listening thread") + self._speaker.start() # debug the threads for thread in threading.enumerate(): self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # listening thread - if self._speaker is not None: - self._logger.notice("speaker_playback is enabled") - self._speaker.set_pull_callback(self._socket.recv) - self._speaker.set_push_callback(self._process_message) - self._speaker.start() - else: - self._logger.notice("create _listening thread") - self._listen_thread = threading.Thread(target=self._listening) - self._listen_thread.start() - # flush thread if self._config.is_auto_flush_speak_enabled(): self._logger.notice("autoflush is enabled") @@ -213,46 +206,22 @@ def start( self._logger.debug("after running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # push open event - self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Open), - OpenResponse(type=SpeakWebSocketEvents.Open), - ) - self._logger.notice("start succeeded") - self._logger.debug("SpeakStreamClient.start LEAVE") + self._logger.debug("SpeakWebSocketClient.start LEAVE") return True - except websockets.ConnectionClosed as e: - self._logger.error("ConnectionClosed in SpeakStreamClient.start: %s", e) - self._logger.debug("SpeakStreamClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise e - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error("WebSocketException in SpeakStreamClient.start: %s", e) - self._logger.debug("SpeakStreamClient.start LEAVE") - if self._config.options.get("termination_exception_connect") == "true": - raise e - return False except Exception as e: # pylint: disable=broad-except - self._logger.error("WebSocketException in SpeakStreamClient.start: %s", e) - self._logger.debug("SpeakStreamClient.start LEAVE") + self._logger.error( + "WebSocketException in SpeakWebSocketClient.start: %s", e + ) + self._logger.debug("SpeakWebSocketClient.start LEAVE") if self._config.options.get("termination_exception_connect") == "true": - raise e + raise return False - def is_connected(self) -> bool: - """ - Returns the connection status of the WebSocket. - """ - return self._socket is not None - # pylint: enable=too-many-statements,too-many-branches - def on( - self, event: SpeakWebSocketEvents, handler - ) -> None: # registers event handlers for specific events + def on(self, event: SpeakWebSocketEvents, handler: Callable) -> None: """ Registers event handlers for specific events. """ @@ -264,121 +233,37 @@ def _emit(self, event: SpeakWebSocketEvents, *args, **kwargs) -> None: """ Emits events to the registered event handlers. """ + self._logger.debug("SpeakWebSocketClient._emit ENTER") self._logger.debug("callback handlers for: %s", event) - for handler in self._event_handlers[event]: - handler(self, *args, **kwargs) - - # pylint: disable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches - def _listening( - self, - ) -> None: - """ - Listens for messages from the WebSocket connection. - """ - self._logger.debug("SpeakStreamClient._listening ENTER") - - while True: - try: - if self._exit_event.is_set(): - self._logger.notice("_listening exiting gracefully") - self._logger.debug("SpeakStreamClient._listening LEAVE") - return - - if self._socket is None: - self._logger.warning("socket is empty") - self._logger.debug("SpeakStreamClient._listening LEAVE") - return - - message = self._socket.recv() - - if message is None: - self._logger.info("message is empty") - continue - if isinstance(message, bytes): - self._logger.debug("Binary data received") - - self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.AudioData), - data=message, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - else: - self._logger.debug("Text data received") - self._process_message(message) - - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("SpeakStreamClient._listening LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_listening({e.code}) exiting gracefully") - self._logger.debug("SpeakStreamClient._listening LEAVE") - return - - # no need to call self._signal_exit() here because we are already closed - # note: this is different than the listen websocket client - self._logger.notice( - "ConnectionClosed in SpeakStreamClient._listening with code %s: %s", - e.code, - e.reason, - ) - self._logger.debug("SpeakStreamClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in SpeakStreamClient._listening with: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in SpeakStreamClient._listening", - f"{e}", - "WebSocketException", - ) - self._emit(SpeakWebSocketEvents(SpeakWebSocketEvents.Error), ws_error) - - # signal exit and close - self._signal_exit() - - self._logger.debug("SpeakStreamClient._listening LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) - except Exception as e: # pylint: disable=broad-except - self._logger.error("Exception in SpeakStreamClient._listening: %s", e) - e_error: ErrorResponse = ErrorResponse( - "Exception in SpeakStreamClient._listening", - f"{e}", - "Exception", - ) - self._logger.error( - "Exception in SpeakStreamClient._listening: %s", str(e) - ) - self._emit(SpeakWebSocketEvents(SpeakWebSocketEvents.Error), e_error) + self._logger.debug("callback handlers for: %s", event) + for handler in self._event_handlers[event]: + handler(self, *args, **kwargs) - # signal exit and close - self._signal_exit() + # debug the threads + for thread in threading.enumerate(): + self._logger.debug("after running thread: %s", thread.name) + self._logger.debug("number of active threads: %s", threading.active_count()) - self._logger.debug("SpeakStreamClient._listening LEAVE") + self._logger.debug("ListenWebSocketClient._emit LEAVE") - if self._config.options.get("termination_exception") == "true": - raise - return + def _process_text(self, message: str) -> None: + """ + Processes messages received over the WebSocket connection. + """ + self._logger.debug("SpeakWebSocketClient._process_text ENTER") - def _process_message(self, message: str) -> None: try: - self._logger.debug("SpeakStreamClient._process_message ENTER") + self._logger.debug("Text data received") if len(message) == 0: self._logger.debug("message is empty") - self._logger.debug("SpeakStreamClient._process_message LEAVE") + self._logger.debug("SpeakWebSocketClient._process_text LEAVE") return data = json.loads(message) @@ -468,45 +353,62 @@ def _process_message(self, message: str) -> None: **dict(cast(Dict[Any, Any], self._kwargs)), ) - self._logger.notice("_process_message Succeeded") - self._logger.debug("SpeakStreamClient._process_message LEAVE") + self._logger.notice("_process_text Succeeded") + self._logger.debug("SpeakWebSocketClient._process_text LEAVE") except Exception as e: # pylint: disable=broad-except - self._logger.error("Exception in SpeakStreamClient._listening: %s", e) + self._logger.error("Exception in SpeakWebSocketClient._process_text: %s", e) e_error: ErrorResponse = ErrorResponse( - "Exception in SpeakStreamClient._listening", + "Exception in SpeakWebSocketClient._process_text", f"{e}", "Exception", ) - self._logger.error("Exception in SpeakStreamClient._listening: %s", str(e)) - self._emit(SpeakWebSocketEvents(SpeakWebSocketEvents.Error), e_error) + self._logger.error( + "Exception in SpeakWebSocketClient._process_text: %s", str(e) + ) + self._emit( + SpeakWebSocketEvents(SpeakWebSocketEvents.Error), + e_error, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) # signal exit and close self._signal_exit() - self._logger.debug("SpeakStreamClient._listening LEAVE") + self._logger.debug("SpeakWebSocketClient._process_text LEAVE") if self._config.options.get("termination_exception") == "true": raise return - # pylint: enable=too-many-return-statements,too-many-statements,too-many-locals,too-many-branches + # pylint: enable=too-many-return-statements,too-many-statements + + def _process_binary(self, message: bytes) -> None: + self._logger.debug("SpeakWebSocketClient._process_binary ENTER") + self._logger.debug("Binary data received") - # pylint: disable=too-many-return-statements,too-many-statements,too-many-branches + self._emit( + SpeakWebSocketEvents(SpeakWebSocketEvents.AudioData), + data=message, + **dict(cast(Dict[Any, Any], self._kwargs)), + ) + + self._logger.notice("_process_binary Succeeded") + self._logger.debug("SpeakWebSocketClient._process_binary LEAVE") + + # pylint: disable=too-many-return-statements def _flush(self) -> None: - self._logger.debug("SpeakStreamClient._flush ENTER") + self._logger.debug("SpeakWebSocketClient._flush ENTER") delta_in_ms_str = self._config.options.get("auto_flush_speak_delta") if delta_in_ms_str is None: self._logger.error("auto_flush_speak_delta is None") - self._logger.debug("SpeakStreamClient._flush LEAVE") + self._logger.debug("SpeakWebSocketClient._flush LEAVE") return delta_in_ms = float(delta_in_ms_str) - counter = 0 while True: try: - counter += 1 self._exit_event.wait(timeout=HALF_SECOND) if self._exit_event.is_set(): @@ -514,11 +416,6 @@ def _flush(self) -> None: self._logger.debug("ListenWebSocketClient._flush LEAVE") return - if self._socket is None: - self._logger.notice("socket is None, exiting keep_alive") - self._logger.debug("ListenWebSocketClient._flush LEAVE") - return - if self._last_datagram is None: self._logger.debug("AutoFlush last_datagram is None") continue @@ -533,62 +430,16 @@ def _flush(self) -> None: self.flush() - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("SpeakStreamClient._flush LEAVE") - return - - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"_flush({e.code}) exiting gracefully") - self._logger.debug("SpeakStreamClient._flush LEAVE") - return - - # no need to call self._signal_exit() here because we are already closed - # note: this is different than the listen websocket client - self._logger.notice( - "ConnectionClosed in SpeakStreamClient._flush with code %s: %s", - e.code, - e.reason, - ) - self._logger.debug("SpeakStreamClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "WebSocketException in SpeakStreamClient._flush: %s", e - ) - ws_error: ErrorResponse = ErrorResponse( - "WebSocketException in SpeakStreamClient._flush", - f"{e}", - "Exception", - ) - self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Error), - error=ws_error, - **dict(cast(Dict[Any, Any], self._kwargs)), - ) - - # signal exit and close - self._signal_exit() - - self._logger.debug("SpeakStreamClient._flush LEAVE") - - if self._config.options.get("termination_exception") == "true": - raise - return - except Exception as e: # pylint: disable=broad-except - self._logger.error("Exception in SpeakStreamClient._flush: %s", e) + self._logger.error("Exception in SpeakWebSocketClient._flush: %s", e) e_error: ErrorResponse = ErrorResponse( - "Exception in SpeakStreamClient._flush", + "Exception in SpeakWebSocketClient._flush", f"{e}", "Exception", ) - self._logger.error("Exception in SpeakStreamClient._flush: %s", str(e)) + self._logger.error( + "Exception in SpeakWebSocketClient._flush: %s", str(e) + ) self._emit( SpeakWebSocketEvents(SpeakWebSocketEvents.Error), error=e_error, @@ -598,13 +449,13 @@ def _flush(self) -> None: # signal exit and close self._signal_exit() - self._logger.debug("SpeakStreamClient._flush LEAVE") + self._logger.debug("SpeakWebSocketClient._flush LEAVE") if self._config.options.get("termination_exception") == "true": raise return - # pylint: enable=too-many-return-statements,too-many-statements,too-many-branches + # pylint: enable=too-many-return-statements def send_text(self, text_input: str) -> bool: """ @@ -619,11 +470,15 @@ def send_text(self, text_input: str) -> bool: """ return self.send_raw(json.dumps({"type": "Speak", "text": text_input})) - def send(self, text_input: str) -> bool: + def send(self, data: Union[str, bytes]) -> bool: """ Alias for send_text. Please see send_text for more information. """ - return self.send_text(text_input) + if isinstance(data, bytes): + self._logger.error("send() failed - data is bytes") + return False + + return self.send_text(data) # pylint: disable=unused-argument def send_control( @@ -655,17 +510,7 @@ def send_raw(self, msg: str) -> bool: Returns: bool: True if the message was successfully sent, False otherwise. """ - self._logger.spam("SpeakStreamClient.send_raw ENTER") - - if self._exit_event.is_set(): - self._logger.notice("send exiting gracefully") - self._logger.debug("SpeakStreamClient.send LEAVE") - return False - - if not self.is_connected(): - self._logger.notice("is_connected is False") - self._logger.debug("AsyncListenWebSocketClient.send LEAVE") - return False + self._logger.spam("SpeakWebSocketClient.send_raw ENTER") if self._config.is_inspecting_speak(): try: @@ -689,84 +534,39 @@ def send_raw(self, msg: str) -> bool: except Exception as e: # pylint: disable=broad-except self._logger.error("send_raw() failed - Exception: %s", str(e)) - if self._socket is not None: - with self._lock_send: - try: - self._socket.send(msg) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice(f"send_raw() exiting gracefully: {e.code}") - self._logger.debug("SpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return True - except websockets.exceptions.ConnectionClosed as e: - if e.code in [1000, 1001]: - self._logger.notice(f"send_raw({e.code}) exiting gracefully") - self._logger.debug("SpeakStreamClient.send_raw LEAVE") - if ( - self._config.options.get("termination_exception_send") - == "true" - ): - raise - return True - self._logger.error( - "send_raw() failed - ConnectionClosed: %s", str(e) - ) - self._logger.spam("SpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except websockets.exceptions.WebSocketException as e: - self._logger.error( - "send_raw() failed - WebSocketException: %s", str(e) - ) - self._logger.spam("SpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - except Exception as e: # pylint: disable=broad-except - self._logger.error("send_raw() failed - Exception: %s", str(e)) - self._logger.spam("SpeakStreamClient.send_raw LEAVE") - if self._config.options.get("termination_exception_send") == "true": - raise - return False - + try: + if super().send(msg) is False: + self._logger.error("send_raw() failed") + self._logger.spam("SpeakWebSocketClient.send_raw LEAVE") + return False self._logger.spam("send_raw() succeeded") - self._logger.spam("SpeakStreamClient.send_raw LEAVE") + self._logger.spam("SpeakWebSocketClient.send_raw LEAVE") return True + except Exception as e: # pylint: disable=broad-except + self._logger.error("send_raw() failed - Exception: %s", str(e)) + self._logger.spam("SpeakWebSocketClient.send_raw LEAVE") + if self._config.options.get("termination_exception_send") == "true": + raise + return False - self._logger.spam("send_raw() failed. socket is None") - self._logger.spam("SpeakStreamClient.send_raw LEAVE") - return False - - # pylint: enable=too-many-return-statements,too-many-branches,too-many-statements + # pylint: enable=too-many-return-statements,too-many-branches def flush(self) -> bool: """ Flushes the current buffer and returns generated audio """ - self._logger.spam("SpeakStreamClient.flush ENTER") - - if self._exit_event.is_set(): - self._logger.notice("flush exiting gracefully") - self._logger.debug("SpeakStreamClient.flush LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("SpeakStreamClient.flush LEAVE") - return False + self._logger.spam("SpeakWebSocketClient.flush ENTER") self._logger.notice("Sending Flush...") ret = self.send_control(SpeakWebSocketMessage.Flush) if not ret: self._logger.error("flush failed") - self._logger.spam("SpeakStreamClient.flush LEAVE") + self._logger.spam("SpeakWebSocketClient.flush LEAVE") return False self._logger.notice("flush succeeded") - self._logger.spam("SpeakStreamClient.flush LEAVE") + self._logger.spam("SpeakWebSocketClient.flush LEAVE") return True @@ -774,46 +574,40 @@ def clear(self) -> bool: """ Clears the current buffer on the server """ - self._logger.spam("SpeakStreamClient.clear ENTER") - - if self._exit_event.is_set(): - self._logger.notice("clear exiting gracefully") - self._logger.debug("SpeakStreamClient.clear LEAVE") - return False - - if self._socket is None: - self._logger.notice("socket is not intialized") - self._logger.debug("SpeakStreamClient.clear LEAVE") - return False + self._logger.spam("SpeakWebSocketClient.clear ENTER") self._logger.notice("Sending Clear...") ret = self.send_control(SpeakWebSocketMessage.Clear) if not ret: self._logger.error("clear failed") - self._logger.spam("SpeakStreamClient.clear LEAVE") + self._logger.spam("SpeakWebSocketClient.clear LEAVE") return False self._logger.notice("clear succeeded") - self._logger.spam("SpeakStreamClient.clear LEAVE") + self._logger.spam("SpeakWebSocketClient.clear LEAVE") return True + def _close_message(self) -> bool: + return self.send_control(SpeakWebSocketMessage.Close) + # closes the WebSocket connection gracefully def finish(self) -> bool: """ Closes the WebSocket connection gracefully. """ - self._logger.spam("SpeakStreamClient.finish ENTER") + self._logger.spam("SpeakWebSocketClient.finish ENTER") + + # call parent finish which calls signal_exit + if super().finish() is False: + self._logger.error("ListenWebSocketClient.finish failed") # debug the threads for thread in threading.enumerate(): self._logger.debug("before running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) - # signal exit - self._signal_exit() - # stop the threads if self._speaker is not None: self._logger.verbose("stopping speaker...") @@ -822,73 +616,20 @@ def finish(self) -> bool: self._logger.notice("speaker stopped") if self._flush_thread is not None: - self._logger.verbose("cancelling _flush_thread...") + self._logger.verbose("sdtopping _flush_thread...") self._flush_thread.join() self._flush_thread = None self._logger.notice("_flush_thread joined") - if self._listen_thread is not None: - self._logger.verbose("cancelling _listen_thread...") - self._listen_thread.join() - self._listen_thread = None - self._logger.notice("_listen_thread joined") - # debug the threads for thread in threading.enumerate(): self._logger.debug("before running thread: %s", thread.name) self._logger.debug("number of active threads: %s", threading.active_count()) self._logger.notice("finish succeeded") - self._logger.spam("SpeakStreamClient.finish LEAVE") + self._logger.spam("SpeakWebSocketClient.finish LEAVE") return True - # signals the WebSocket connection to exit - def _signal_exit(self) -> None: - # closes the WebSocket connection gracefully - self._logger.notice("closing socket...") - if self._socket is not None: - self._logger.notice("sending Close...") - try: - # if the socket connection is closed, the following line might throw an error - # need to explicitly use _socket.send (dont use self.send_raw) - self._socket.send(json.dumps({"type": "CloseStream"})) - except websockets.exceptions.ConnectionClosedOK as e: - self._logger.notice("_signal_exit - ConnectionClosedOK: %s", e.code) - except websockets.exceptions.ConnectionClosed as e: - self._logger.notice("_signal_exit - ConnectionClosed: %s", e.code) - except websockets.exceptions.WebSocketException as e: - self._logger.error("_signal_exit - WebSocketException: %s", str(e)) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_signal_exit - Exception: %s", str(e)) - - # close the socket - if self._socket is not None: - self._socket.close() - - # push close event - try: - self._emit( - SpeakWebSocketEvents(SpeakWebSocketEvents.Close), - CloseResponse(type=SpeakWebSocketEvents.Close), - ) - except Exception as e: # pylint: disable=broad-except - self._logger.error("_signal_exit - Exception: %s", e) - - # wait for task to send - time.sleep(0.5) - - # signal exit - self._exit_event.set() - - # closes the WebSocket connection gracefully - self._logger.verbose("clean up socket...") - if self._socket is not None: - self._logger.verbose("socket.wait_closed...") - try: - self._socket.close() - except websockets.exceptions.WebSocketException as e: - self._logger.error("socket.wait_closed failed: %s", e) - def _inspect(self) -> bool: # auto flush_inspect is generically used to track any messages you might want to snoop on # place additional logic here to inspect messages of interest @@ -905,7 +646,4 @@ def _inspect(self) -> bool: return True -class SpeakWebSocketClient(SpeakWSClient): - """ - AsyncSpeakWebSocketClient is an alias for AsyncSpeakWSClient. - """ +SpeakWebSocketClient = SpeakWSClient diff --git a/examples/text-to-speech/websocket/async_complete/main.py b/examples/text-to-speech/websocket/async_complete/main.py index d015e9d2..46819ce2 100644 --- a/examples/text-to-speech/websocket/async_complete/main.py +++ b/examples/text-to-speech/websocket/async_complete/main.py @@ -2,6 +2,7 @@ # Use of this source code is governed by a MIT license that can be found in the LICENSE file. # SPDX-License-Identifier: MIT +from signal import SIGINT, SIGTERM import asyncio import time from deepgram.utils import verboselogs @@ -15,9 +16,20 @@ TTS_TEXT = "Hello, this is a text to speech example using Deepgram." +global warning_notice +warning_notice = True + async def main(): try: + loop = asyncio.get_event_loop() + + for signal in (SIGTERM, SIGINT): + loop.add_signal_handler( + signal, + lambda: asyncio.create_task(shutdown(signal, loop, dg_connection)), + ) + # example of setting up a client config. logging values: WARNING, VERBOSE, DEBUG, SPAM config: DeepgramClientOptions = DeepgramClientOptions( options={"auto_flush_speak_delta": "500", "speaker_playback": "true"}, @@ -32,12 +44,15 @@ async def on_open(self, open, **kwargs): print(f"\n\n{open}\n\n") async def on_binary_data(self, data, **kwargs): - print("Received binary data") - print("You can do something with the binary data here") - print("OR") - print( - "If you want to simply play the audio, set speaker_playback to true in the options for DeepgramClientOptions" - ) + global warning_notice + if warning_notice: + print("Received binary data") + print("You can do something with the binary data here") + print("OR") + print( + "If you want to simply play the audio, set speaker_playback to true in the options for DeepgramClientOptions" + ) + warning_notice = False async def on_metadata(self, metadata, **kwargs): print(f"\n\n{metadata}\n\n") @@ -101,5 +116,15 @@ async def on_unhandled(self, unhandled, **kwargs): print(f"An unexpected error occurred: {e}") -if __name__ == "__main__": - asyncio.run(main()) +async def shutdown(signal, loop, dg_connection): + print(f"Received exit signal {signal.name}...") + await dg_connection.finish() + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + [task.cancel() for task in tasks] + print(f"Cancelling {len(tasks)} outstanding tasks") + await asyncio.gather(*tasks, return_exceptions=True) + loop.stop() + print("Shutdown complete.") + + +asyncio.run(main()) diff --git a/examples/text-to-speech/websocket/complete/main.py b/examples/text-to-speech/websocket/complete/main.py index d87ba827..3e10f0fe 100644 --- a/examples/text-to-speech/websocket/complete/main.py +++ b/examples/text-to-speech/websocket/complete/main.py @@ -15,13 +15,19 @@ TTS_TEXT = "Hello, this is a text to speech example using Deepgram." +global warning_notice +warning_notice = True + def main(): try: # example of setting up a client config. logging values: WARNING, VERBOSE, DEBUG, SPAM config: DeepgramClientOptions = DeepgramClientOptions( - options={"auto_flush_speak_delta": "500", "speaker_playback": "true"}, - # verbose=verboselogs.SPAM, + options={ + # "auto_flush_speak_delta": "500", + "speaker_playback": "true", + }, + # verbose=verboselogs.DEBUG, ) deepgram: DeepgramClient = DeepgramClient("", config) @@ -32,12 +38,15 @@ def on_open(self, open, **kwargs): print(f"\n\n{open}\n\n") def on_binary_data(self, data, **kwargs): - print("Received binary data") - print("You can do something with the binary data here") - print("OR") - print( - "If you want to simply play the audio, set speaker_playback to true in the options for DeepgramClientOptions" - ) + global warning_notice + if warning_notice: + print("Received binary data") + print("You can do something with the binary data here") + print("OR") + print( + "If you want to simply play the audio, set speaker_playback to true in the options for DeepgramClientOptions" + ) + warning_notice = False def on_metadata(self, metadata, **kwargs): print(f"\n\n{metadata}\n\n")