Files
2026-07-13 13:39:38 +08:00

992 lines
40 KiB
Python

# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import dataclasses
import json
import os
import time
import weakref
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
import aiohttp
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
LanguageCode,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from livekit.agents.voice.io import TimedString
from ._utils import PeriodicCollector, _to_deepgram_url
from .log import logger
from .models import DeepgramLanguages, DeepgramModels
@dataclass
class STTOptions:
language: LanguageCode | None
detect_language: bool
interim_results: bool
punctuate: bool
model: DeepgramModels | str
smart_format: bool
no_delay: bool
endpointing_ms: int
enable_diarization: bool
filler_words: bool
sample_rate: int
num_channels: int
keywords: list[tuple[str, float]]
keyterm: str | Sequence[str]
profanity_filter: bool
redact: str | list[str]
endpoint_url: str
vad_events: bool = True
numerals: bool = False
mip_opt_out: bool = False
tags: NotGivenOr[list[str]] = NOT_GIVEN
utterance_end_ms: int | None = None
dictation: bool = False
replace: dict[str, str] | None = None
search: list[str] | None = None
class STT(stt.STT):
def __init__(
self,
*,
model: DeepgramModels | str = "nova-3",
language: DeepgramLanguages | str = "en-US",
detect_language: bool = False,
interim_results: bool = True,
punctuate: bool = True,
smart_format: bool = False,
sample_rate: int = 16000,
no_delay: bool = True,
endpointing_ms: int = 25,
enable_diarization: bool = False,
# enable filler words by default to improve turn detector accuracy
filler_words: bool = True,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
keyterm: NotGivenOr[str | list[str]] = NOT_GIVEN,
tags: NotGivenOr[list[str]] = NOT_GIVEN,
profanity_filter: bool = False,
redact: NotGivenOr[str | list[str]] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
base_url: str = "https://api.deepgram.com/v1/listen",
numerals: bool = False,
mip_opt_out: bool = False,
vad_events: bool = True,
utterance_end_ms: int | None = None,
dictation: bool = False,
replace: dict[str, str] | None = None,
search: list[str] | None = None,
# deprecated
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
) -> None:
"""Create a new instance of Deepgram STT.
Args:
model: The Deepgram model to use for speech recognition. Defaults to "nova-3".
language: The language code for recognition. Defaults to "en-US".
detect_language: Whether to enable automatic language detection. Defaults to False.
interim_results: Whether to return interim (non-final) transcription results. Defaults to True.
punctuate: Whether to add punctuations to the transcription. Defaults to True. Turn detector will work better with punctuations.
smart_format: Whether to apply smart formatting to numbers, dates, etc. Defaults to False.
sample_rate: The sample rate of the audio in Hz. Defaults to 16000.
no_delay: When smart_format is used, ensures it does not wait for sequence to be complete before returning results. Defaults to True.
endpointing_ms: Time in milliseconds of silence to consider end of speech. Set to 0 to disable. Defaults to 25.
filler_words: Whether to include filler words (um, uh, etc.) in transcription. Defaults to True.
keywords: List of tuples containing keywords and their boost values for improved recognition.
Each tuple should be (keyword: str, boost: float). Defaults to None.
`keywords` does not work with Nova-3 models. Use `keyterm` instead.
keyterm: str or list of str of key terms to improve recognition accuracy. Defaults to None.
`keyterm` is only supported by Nova-3 models.
tags: List of tags to add to the requests for usage reporting. Defaults to NOT_GIVEN.
profanity_filter: Whether to filter profanity from the transcription. Defaults to False.
redact: Redact sensitive information from the transcription. Accepts a single value or
list of values. Supported values: "pci", "numbers", "ssn", "true" (redact all).
See https://developers.deepgram.com/docs/redaction for details.
api_key: Your Deepgram API key. If not provided, will look for DEEPGRAM_API_KEY environment variable.
http_session: Optional aiohttp ClientSession to use for requests.
base_url: The base URL for Deepgram API. Defaults to "https://api.deepgram.com/v1/listen".
numerals: Whether to include numerals in the transcription. Defaults to False.
mip_opt_out: Whether to take part in the model improvement program
vad_events: Whether to enable VAD (Voice Activity Detection) events.
When enabled, SpeechStarted events are sent when speech is detected. Defaults to True.
utterance_end_ms: Duration of silence in milliseconds to detect the end of an utterance
and emit an UtteranceEnd event. Requires interim_results=True.
See https://developers.deepgram.com/docs/understand-endpointing-interim-results
dictation: Whether to enable dictation mode which converts spoken punctuation commands
(e.g. "comma", "period") into punctuation marks. Defaults to False.
See https://developers.deepgram.com/reference/speech-to-text/listen-streaming#query-dictation
replace: Dictionary of terms to replace in the transcript, where keys are the original
terms and values are the replacements (e.g. {"hello": "hi"}).
See https://developers.deepgram.com/reference/speech-to-text/listen-streaming#query-replace
search: List of terms to search for in the transcript. Matched terms are returned with
confidence scores in the response.
See https://developers.deepgram.com/reference/speech-to-text/listen-streaming#query-search
Raises:
ValueError: If no API key is provided or found in environment variables.
Note:
The api_key must be set either through the constructor argument or by setting
the DEEPGRAM_API_KEY environmental variable.
""" # noqa: E501
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=interim_results,
diarization=enable_diarization,
aligned_transcript="word",
keyterms=True,
)
)
deepgram_api_key = api_key if is_given(api_key) else os.environ.get("DEEPGRAM_API_KEY")
if not deepgram_api_key:
raise ValueError(
"Deepgram API key is required, either as argument or set"
" DEEPGRAM_API_KEY environment variable"
)
self._api_key = deepgram_api_key
model = _validate_model(model, language)
if is_given(keyterms):
logger.warning(
"`keyterms` is deprecated, use `keyterm` instead for consistency with Deepgram API."
)
keyterm = keyterms
_validate_keyterm(model, language, keyterm, keywords)
self._opts = STTOptions(
language=LanguageCode(language) if language else None,
detect_language=detect_language,
interim_results=interim_results,
punctuate=punctuate,
model=model,
smart_format=smart_format,
no_delay=no_delay,
endpointing_ms=endpointing_ms,
enable_diarization=enable_diarization,
filler_words=filler_words,
sample_rate=sample_rate,
num_channels=1,
keywords=keywords if is_given(keywords) else [],
keyterm=([keyterm] if isinstance(keyterm, str) else list(keyterm))
if is_given(keyterm)
else [],
profanity_filter=profanity_filter,
redact=redact if is_given(redact) else [],
numerals=numerals,
mip_opt_out=mip_opt_out,
vad_events=vad_events,
tags=_validate_tags(tags) if is_given(tags) else [],
endpoint_url=base_url,
utterance_end_ms=utterance_end_ms,
dictation=dictation,
replace=replace,
search=search,
)
# user keyterms; _opts.keyterm holds the effective set (user + session)
self._user_keyterm: list[str] = list(self._opts.keyterm)
self._session_keyterms: list[str] = []
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "Deepgram"
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
config = self._sanitize_options(language=language)
recognize_config = {
"model": str(config.model),
"punctuate": config.punctuate,
"detect_language": config.detect_language,
"smart_format": config.smart_format,
"keywords": self._opts.keywords,
"profanity_filter": config.profanity_filter,
"numerals": config.numerals,
"mip_opt_out": config.mip_opt_out,
}
if self._opts.keyterm:
recognize_config["keyterm"] = self._opts.keyterm
if config.redact:
recognize_config["redact"] = config.redact
if config.enable_diarization:
logger.warning("speaker diarization is not supported in non-streaming mode, ignoring")
if config.language:
recognize_config["language"] = config.language
try:
async with self._ensure_session().post(
url=_to_deepgram_url(recognize_config, self._opts.endpoint_url, websocket=False),
data=rtc.combine_audio_frames(buffer).to_wav_bytes(),
headers={
"Authorization": f"Token {self._api_key}",
"Accept": "application/json",
"Content-Type": "audio/wav",
},
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=conn_options.timeout,
),
) as res:
return prerecorded_transcription_to_speech_event(
config.language,
await res.json(),
)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
def stream(
self,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = self._sanitize_options(language=language)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
http_session=self._ensure_session(),
base_url=self._opts.endpoint_url,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
model: NotGivenOr[DeepgramModels | str] = NOT_GIVEN,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
punctuate: NotGivenOr[bool] = NOT_GIVEN,
smart_format: NotGivenOr[bool] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
no_delay: NotGivenOr[bool] = NOT_GIVEN,
endpointing_ms: NotGivenOr[int] = NOT_GIVEN,
enable_diarization: NotGivenOr[bool] = NOT_GIVEN,
filler_words: NotGivenOr[bool] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
keyterm: NotGivenOr[str | list[str]] = NOT_GIVEN,
profanity_filter: NotGivenOr[bool] = NOT_GIVEN,
redact: NotGivenOr[str | list[str]] = NOT_GIVEN,
numerals: NotGivenOr[bool] = NOT_GIVEN,
mip_opt_out: NotGivenOr[bool] = NOT_GIVEN,
vad_events: NotGivenOr[bool] = NOT_GIVEN,
tags: NotGivenOr[list[str]] = NOT_GIVEN,
endpoint_url: NotGivenOr[str] = NOT_GIVEN,
utterance_end_ms: NotGivenOr[int | None] = NOT_GIVEN,
dictation: NotGivenOr[bool] = NOT_GIVEN,
replace: NotGivenOr[dict[str, str] | None] = NOT_GIVEN,
search: NotGivenOr[list[str] | None] = NOT_GIVEN,
# deprecated
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
) -> None:
if is_given(language):
self._opts.language = LanguageCode(language)
if is_given(model):
self._opts.model = _validate_model(
model, language if is_given(language) else (self._opts.language or NOT_GIVEN)
)
if is_given(interim_results):
self._opts.interim_results = interim_results
if is_given(punctuate):
self._opts.punctuate = punctuate
if is_given(smart_format):
self._opts.smart_format = smart_format
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(no_delay):
self._opts.no_delay = no_delay
if is_given(endpointing_ms):
self._opts.endpointing_ms = endpointing_ms
if is_given(enable_diarization):
self._opts.enable_diarization = enable_diarization
if is_given(filler_words):
self._opts.filler_words = filler_words
if is_given(keywords):
self._opts.keywords = keywords
if is_given(keyterms):
logger.warning(
"`keyterms` is deprecated, use `keyterm` instead for consistency with Deepgram API."
)
keyterm = keyterms
if is_given(keyterm):
self._user_keyterm = [keyterm] if isinstance(keyterm, str) else list(keyterm)
keyterm = list(dict.fromkeys([*self._user_keyterm, *self._session_keyterms]))
self._opts.keyterm = keyterm
if is_given(profanity_filter):
self._opts.profanity_filter = profanity_filter
if is_given(redact):
self._opts.redact = redact
if is_given(numerals):
self._opts.numerals = numerals
if is_given(mip_opt_out):
self._opts.mip_opt_out = mip_opt_out
if is_given(vad_events):
self._opts.vad_events = vad_events
if is_given(tags):
self._opts.tags = _validate_tags(tags)
if is_given(endpoint_url):
self._opts.endpoint_url = endpoint_url
if is_given(utterance_end_ms):
self._opts.utterance_end_ms = utterance_end_ms
if is_given(dictation):
self._opts.dictation = dictation
if is_given(replace):
self._opts.replace = replace
if is_given(search):
self._opts.search = search
for stream in self._streams:
stream.update_options(
language=language,
model=model,
interim_results=interim_results,
punctuate=punctuate,
smart_format=smart_format,
sample_rate=sample_rate,
no_delay=no_delay,
endpointing_ms=endpointing_ms,
filler_words=filler_words,
keywords=keywords,
keyterm=keyterm,
profanity_filter=profanity_filter,
redact=redact,
numerals=numerals,
mip_opt_out=mip_opt_out,
vad_events=vad_events,
endpoint_url=endpoint_url,
utterance_end_ms=utterance_end_ms,
dictation=dictation,
replace=replace,
search=search,
)
def _update_session_keyterms(self, keyterms: list[str]) -> None:
if keyterms == self._session_keyterms:
return
self._session_keyterms = list(keyterms)
merged = list(dict.fromkeys([*self._user_keyterm, *keyterms]))
self._opts.keyterm = merged
for stream in self._streams:
if stream._speaking:
# defer the reconnect to the end of the utterance so we don't cut it off
stream._pending_keyterm = merged
else:
stream.update_options(keyterm=merged)
def _sanitize_options(
self, *, language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN
) -> STTOptions:
config = dataclasses.replace(self._opts)
if is_given(language):
config.language = LanguageCode(language)
if config.detect_language:
config.language = None
return config
class SpeechStream(stt.SpeechStream):
_KEEPALIVE_MSG: str = json.dumps({"type": "KeepAlive"})
_CLOSE_MSG: str = json.dumps({"type": "CloseStream"})
_FINALIZE_MSG: str = json.dumps({"type": "Finalize"})
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: str,
http_session: aiohttp.ClientSession,
base_url: str,
) -> None:
if opts.detect_language or opts.language is None:
raise ValueError(
"language detection is not supported in streaming mode, "
"please disable it and specify a language"
)
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._api_key = api_key
self._session = http_session
self._opts.endpoint_url = base_url
self._speaking = False
self._audio_duration_collector = PeriodicCollector(
callback=self._on_audio_duration_report,
duration=5.0,
)
self._request_id = ""
self._reconnect_event = asyncio.Event()
# keyterms set while the user is speaking; applied at END_OF_SPEECH (latest wins)
self._pending_keyterm: list[str] | None = None
# Track how much duration has already been reported so we can emit
# the connection-lifetime remainder on close, matching what Deepgram
# actually bills (which includes WebSocket open/teardown overhead
# beyond the pushed audio frames).
self._reported_duration: float = 0.0
def update_options(
self,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
model: NotGivenOr[DeepgramModels | str] = NOT_GIVEN,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
punctuate: NotGivenOr[bool] = NOT_GIVEN,
smart_format: NotGivenOr[bool] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
no_delay: NotGivenOr[bool] = NOT_GIVEN,
endpointing_ms: NotGivenOr[int] = NOT_GIVEN,
enable_diarization: NotGivenOr[bool] = NOT_GIVEN,
filler_words: NotGivenOr[bool] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
keyterm: NotGivenOr[str | list[str]] = NOT_GIVEN,
profanity_filter: NotGivenOr[bool] = NOT_GIVEN,
redact: NotGivenOr[str | list[str]] = NOT_GIVEN,
numerals: NotGivenOr[bool] = NOT_GIVEN,
mip_opt_out: NotGivenOr[bool] = NOT_GIVEN,
vad_events: NotGivenOr[bool] = NOT_GIVEN,
tags: NotGivenOr[list[str]] = NOT_GIVEN,
endpoint_url: NotGivenOr[str] = NOT_GIVEN,
utterance_end_ms: NotGivenOr[int | None] = NOT_GIVEN,
dictation: NotGivenOr[bool] = NOT_GIVEN,
replace: NotGivenOr[dict[str, str] | None] = NOT_GIVEN,
search: NotGivenOr[list[str] | None] = NOT_GIVEN,
# deprecated
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
) -> None:
if is_given(language):
self._opts.language = LanguageCode(language)
if is_given(model):
self._opts.model = _validate_model(
model, language if is_given(language) else (self._opts.language or NOT_GIVEN)
)
if is_given(interim_results):
self._opts.interim_results = interim_results
if is_given(punctuate):
self._opts.punctuate = punctuate
if is_given(smart_format):
self._opts.smart_format = smart_format
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(no_delay):
self._opts.no_delay = no_delay
if is_given(endpointing_ms):
self._opts.endpointing_ms = endpointing_ms
if is_given(enable_diarization):
self._opts.enable_diarization = enable_diarization
if is_given(filler_words):
self._opts.filler_words = filler_words
if is_given(keywords):
self._opts.keywords = keywords
if is_given(keyterms):
logger.warning(
"`keyterms` is deprecated, use `keyterm` instead for consistency with Deepgram API."
)
keyterm = keyterms
if is_given(keyterm):
self._opts.keyterm = keyterm
self._pending_keyterm = None
if is_given(profanity_filter):
self._opts.profanity_filter = profanity_filter
if is_given(redact):
self._opts.redact = redact
if is_given(numerals):
self._opts.numerals = numerals
if is_given(mip_opt_out):
self._opts.mip_opt_out = mip_opt_out
if is_given(vad_events):
self._opts.vad_events = vad_events
if is_given(tags):
self._opts.tags = _validate_tags(tags)
if is_given(endpoint_url):
self._opts.endpoint_url = endpoint_url
if is_given(utterance_end_ms):
self._opts.utterance_end_ms = utterance_end_ms
if is_given(dictation):
self._opts.dictation = dictation
if is_given(replace):
self._opts.replace = replace
if is_given(search):
self._opts.search = search
self._reconnect_event.set()
def _on_end_of_speech(self) -> None:
if self._pending_keyterm is not None:
self.update_options(keyterm=self._pending_keyterm)
self._pending_keyterm = None
async def _run(self) -> None:
closing_ws = False
async def keepalive_task(ws: aiohttp.ClientWebSocketResponse) -> None:
# if we want to keep the connection alive even if no audio is sent,
# Deepgram expects a keepalive message.
# https://developers.deepgram.com/reference/listen-live#stream-keepalive
try:
while True:
await ws.send_str(SpeechStream._KEEPALIVE_MSG)
await asyncio.sleep(5)
except Exception as e:
logger.warning(f"Deepgram keepalive task exited: {e}")
return
@utils.log_exceptions(logger=logger)
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
# forward audio to deepgram in chunks of 50ms
samples_50ms = self._opts.sample_rate // 20
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=self._opts.num_channels,
samples_per_channel=samples_50ms,
)
has_ended = False
async for data in self._input_ch:
frames: list[rtc.AudioFrame] = []
if isinstance(data, rtc.AudioFrame):
frames.extend(audio_bstream.write(data.data.tobytes()))
elif isinstance(data, self._FlushSentinel):
frames.extend(audio_bstream.flush())
has_ended = True
for frame in frames:
self._audio_duration_collector.push(frame.duration)
await ws.send_bytes(frame.data.tobytes())
if has_ended:
self._audio_duration_collector.flush()
await ws.send_str(SpeechStream._FINALIZE_MSG)
has_ended = False
# tell deepgram we are done sending audio/inputs
closing_ws = True
await ws.send_str(SpeechStream._CLOSE_MSG)
@utils.log_exceptions(logger=logger)
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
# close is expected, see SpeechStream.aclose
# or when the agent session ends, the http session is closed
if closing_ws or self._session.closed:
return
# this will trigger a reconnection, see the _run loop
raise APIStatusError(
message="deepgram connection closed unexpectedly",
status_code=ws.close_code or -1,
body=f"{msg.data=} {msg.extra=}",
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected deepgram message type %s", msg.type)
continue
try:
self._process_stream_event(json.loads(msg.data))
except Exception:
logger.exception("failed to process deepgram message")
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
conn_start_time = 0.0
try:
ws = await self._connect_ws()
conn_start_time = time.perf_counter()
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
asyncio.create_task(keepalive_task(ws)),
]
tasks_group = asyncio.gather(*tasks)
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
(tasks_group, wait_reconnect_task),
return_when=asyncio.FIRST_COMPLETED,
)
# propagate exceptions from completed tasks
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(*tasks, wait_reconnect_task)
tasks_group.cancel()
tasks_group.exception() # retrieve the exception
finally:
if ws is not None:
await ws.close()
# Deepgram bills WebSocket lifetime, not just audio
# frames pushed. Emit the remainder between the
# connection's wall-clock lifetime and the frame
# durations we've already reported so usage reflects
# what the provider actually charges for.
if conn_start_time:
self._audio_duration_collector.flush()
lifetime = time.perf_counter() - conn_start_time
remainder = lifetime - self._reported_duration
if remainder > 0:
self._on_audio_duration_report(remainder)
self._reported_duration = 0.0
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
live_config: dict[str, Any] = {
"model": self._opts.model,
"punctuate": self._opts.punctuate,
"smart_format": self._opts.smart_format,
"no_delay": self._opts.no_delay,
"interim_results": self._opts.interim_results,
"encoding": "linear16",
"vad_events": self._opts.vad_events,
"sample_rate": self._opts.sample_rate,
"channels": self._opts.num_channels,
"endpointing": False if self._opts.endpointing_ms == 0 else self._opts.endpointing_ms,
"filler_words": self._opts.filler_words,
"profanity_filter": self._opts.profanity_filter,
"numerals": self._opts.numerals,
"mip_opt_out": self._opts.mip_opt_out,
}
if self._opts.enable_diarization:
live_config["diarize"] = True
if self._opts.keywords:
live_config["keywords"] = self._opts.keywords
if self._opts.keyterm:
live_config["keyterm"] = self._opts.keyterm
if self._opts.utterance_end_ms is not None:
live_config["utterance_end_ms"] = self._opts.utterance_end_ms
if self._opts.dictation:
live_config["dictation"] = True
if self._opts.replace:
live_config["replace"] = self._opts.replace
if self._opts.search:
live_config["search"] = self._opts.search
if self._opts.language:
live_config["language"] = self._opts.language
if self._opts.redact:
live_config["redact"] = self._opts.redact
if self._opts.tags:
live_config["tag"] = self._opts.tags
t0 = time.perf_counter()
try:
ws = await asyncio.wait_for(
self._session.ws_connect(
_to_deepgram_url(live_config, base_url=self._opts.endpoint_url, websocket=True),
headers={"Authorization": f"Token {self._api_key}"},
),
self._conn_options.timeout,
)
self._report_connection_acquired(time.perf_counter() - t0, False)
ws_headers = {
k: v for k, v in ws._response.headers.items() if k.startswith("dg-") or k == "Date"
}
logger.debug(
"Established new Deepgram STT WebSocket connection:",
extra={"headers": ws_headers},
)
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise APIConnectionError("failed to connect to deepgram") from e
return ws
def _on_audio_duration_report(self, duration: float) -> None:
self._reported_duration += duration
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=self._request_id,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=duration),
)
self._event_ch.send_nowait(usage_event)
def _process_stream_event(self, data: dict) -> None:
assert self._opts.language is not None
if data["type"] == "SpeechStarted":
# This is a normal case. Deepgram's SpeechStarted events
# are not correlated with speech_final or utterance end.
# It's possible that we receive two in a row without an endpoint
# It's also possible we receive a transcript without a SpeechStarted event.
if self._speaking:
return
self._speaking = True
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
# see this page:
# https://developers.deepgram.com/docs/understand-endpointing-interim-results#using-endpointing-speech_final
# for more information about the different types of events
elif data["type"] == "Results":
metadata = data["metadata"]
request_id = metadata["request_id"]
is_final_transcript = data["is_final"]
is_endpoint = data["speech_final"]
self._request_id = request_id
alts = live_transcription_to_speech_data(
self._opts.language,
data,
is_final=is_final_transcript,
start_time_offset=self.start_time_offset,
)
# If, for some reason, we didn't get a SpeechStarted event but we got
# a transcript with text, we should start speaking. It's rare but has
# been observed.
if len(alts) > 0 and alts[0].text:
if not self._speaking:
self._speaking = True
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
if is_final_transcript:
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=request_id,
alternatives=alts,
)
self._event_ch.send_nowait(final_event)
else:
interim_event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=request_id,
alternatives=alts,
)
self._event_ch.send_nowait(interim_event)
# if we receive an endpoint, only end the speech if
# we either had a SpeechStarted event or we have a seen
# a non-empty transcript (deepgram doesn't have a SpeechEnded event)
if is_endpoint and self._speaking:
self._speaking = False
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
self._on_end_of_speech()
elif data["type"] == "UtteranceEnd":
# Fired when utterance_end_ms is set and the configured silence duration has elapsed.
# https://developers.deepgram.com/docs/understand-endpointing-interim-results
if self._speaking:
self._speaking = False
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
self._on_end_of_speech()
elif data["type"] == "Metadata":
pass # metadata is too noisy
else:
logger.warning("received unexpected message from deepgram %s", data)
def live_transcription_to_speech_data(
language: str, data: dict, *, is_final: bool, start_time_offset: float
) -> list[stt.SpeechData]:
dg_alts = data["channel"]["alternatives"]
speech_data = []
for alt in dg_alts:
if is_final:
speakers = [word["speaker"] for word in alt["words"] if "speaker" in word]
speaker = Counter(speakers).most_common(1)[0][0] if speakers else None
else:
# interim result doesn't have correct speaker information?
speaker = None
sd = stt.SpeechData(
language=LanguageCode(language),
start_time=next((word.get("start", 0) for word in alt["words"]), 0) + start_time_offset,
end_time=next((word.get("end", 0) for word in alt["words"]), 0) + start_time_offset,
confidence=alt["confidence"],
text=alt["transcript"],
speaker_id=f"S{speaker}" if speaker is not None else None,
words=[
TimedString(
text=word.get("word", ""),
start_time=word.get("start", 0) + start_time_offset,
end_time=word.get("end", 0) + start_time_offset,
start_time_offset=start_time_offset,
)
for word in alt["words"]
]
if alt["words"]
else None,
)
if language == "multi" and "languages" in alt:
sd.language = LanguageCode(alt["languages"][0]) # TODO: handle multiple languages
speech_data.append(sd)
return speech_data
def prerecorded_transcription_to_speech_event(
language: str | None, # language should be None when 'detect_language' is enabled
data: dict,
) -> stt.SpeechEvent:
# We only support one channel for now
request_id = data["metadata"]["request_id"]
channel: dict = data["results"]["channels"][0]
dg_alts = channel["alternatives"]
# Use the detected language if enabled
# https://developers.deepgram.com/docs/language-detection
detected_language = LanguageCode(channel.get("detected_language", ""))
return stt.SpeechEvent(
request_id=request_id,
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=LanguageCode(language or detected_language),
start_time=alt["words"][0]["start"] if alt["words"] else 0,
end_time=alt["words"][-1]["end"] if alt["words"] else 0,
confidence=alt["confidence"],
text=alt["transcript"],
words=[
TimedString(
text=word.get("word", ""),
start_time=word.get("start", 0),
end_time=word.get("end", 0),
)
for word in alt["words"]
],
)
for alt in dg_alts
],
)
def _validate_model(
model: DeepgramModels | str, language: NotGivenOr[DeepgramLanguages | str]
) -> DeepgramModels | str:
en_only_models = {
"nova-2-meeting",
"nova-2-phonecall",
"nova-2-finance",
"nova-2-conversationalai",
"nova-2-voicemail",
"nova-2-video",
"nova-2-medical",
"nova-2-drivethru",
"nova-2-automotive",
}
if is_given(language) and language not in ("en-US", "en") and model in en_only_models:
logger.warning(
f"{model} does not support language {language}, falling back to nova-2-general"
)
return "nova-2-general"
return model
def _validate_tags(tags: list[str]) -> list[str]:
for tag in tags:
if len(tag) > 128:
raise ValueError("tag must be no more than 128 characters")
return tags
def _validate_keyterm(
model: DeepgramModels | str,
language: NotGivenOr[DeepgramLanguages | str],
keyterm: NotGivenOr[str | list[str]],
keywords: NotGivenOr[list[tuple[str, float]]],
) -> None:
"""
Validating keyterm and keywords for model compatibility.
See: https://developers.deepgram.com/docs/keyterm and https://developers.deepgram.com/docs/keywords
"""
if model.startswith("nova-3") and is_given(keywords):
raise ValueError(
"Keywords is only available for use with Nova-2, Nova-1, Enhanced, and "
"Base speech to text models. For Nova-3, use Keyterm Prompting."
)
if is_given(keyterm) and (not model.startswith("nova-3")):
raise ValueError(
"Keyterm Prompting is only available for transcription using the Nova-3 Model. "
"To boost recognition of keywords using another model, use the Keywords feature."
)