Bolt Assistant: wake-phrase voice assistant for Bolt
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""Speech-to-text for one utterance, through the Bolt server's /desk/stt
|
||||
websocket relay (to Deepgram) — the route Bolt-Pet and the console's mic use,
|
||||
authenticated with the desk API key; no Deepgram account here.
|
||||
|
||||
t = Transcription(connect)
|
||||
t.add(frames) # as the utterance is spoken (streamed live)
|
||||
text = t.finish(audio) # when it ends; `audio` is the whole utterance
|
||||
t.cancel() # it was too short to matter
|
||||
|
||||
Live when possible, so the text is ready the moment you stop talking. If the
|
||||
socket never opened or died, finish() opens a fresh one and sends `audio`
|
||||
in one go, from an empty transcript (so a half-live session can't return
|
||||
words twice). A healthy socket is never re-sent — that would pay twice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.parse
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
class SttError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def connect(sample_rate: int = config.SAMPLE_RATE):
|
||||
import websocket # websocket-client
|
||||
|
||||
query = urllib.parse.urlencode({"session_id": config.SESSION_ID, "encoding": "linear16",
|
||||
"sample_rate": sample_rate})
|
||||
return websocket.create_connection(
|
||||
config.SERVER_URL.replace("http", "ws", 1) + "/desk/stt?" + query,
|
||||
header=[f"X-Desk-Api-Key: {config.API_KEY}"], timeout=10)
|
||||
|
||||
|
||||
def parse_result(message) -> tuple:
|
||||
"""(text, is_final) from a Deepgram results frame; ("", False) otherwise."""
|
||||
try:
|
||||
if isinstance(message, bytes):
|
||||
message = message.decode("utf-8", "ignore")
|
||||
data = json.loads(message)
|
||||
except (TypeError, ValueError):
|
||||
return "", False
|
||||
if not isinstance(data, dict) or data.get("type") not in (None, "Results"):
|
||||
return "", False
|
||||
alternatives = (data.get("channel") or {}).get("alternatives") or []
|
||||
if not alternatives:
|
||||
return "", False
|
||||
text = str((alternatives[0] or {}).get("transcript") or "").strip()
|
||||
return text, bool(data.get("is_final") or data.get("speech_final"))
|
||||
|
||||
|
||||
class Transcription:
|
||||
def __init__(self, connect_fn: Callable = connect):
|
||||
self._connect = connect_fn
|
||||
self._finals: List[str] = []
|
||||
self._interim = ""
|
||||
self._lock = threading.Lock()
|
||||
self._socket = None
|
||||
self._ok = False
|
||||
self._reader: Optional[threading.Thread] = None
|
||||
self._open()
|
||||
|
||||
def _open(self) -> None:
|
||||
try:
|
||||
self._socket = self._connect(config.SAMPLE_RATE)
|
||||
self._ok = True
|
||||
except Exception:
|
||||
self._socket, self._ok = None, False
|
||||
return
|
||||
self._reader = threading.Thread(target=self._read, name="stt-reader", daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def add(self, frames: List[bytes]) -> None:
|
||||
for frame in frames:
|
||||
self._send(frame)
|
||||
|
||||
def _send(self, data: bytes) -> None:
|
||||
if self._ok:
|
||||
try:
|
||||
self._socket.send_binary(data)
|
||||
except Exception:
|
||||
self._ok = False
|
||||
|
||||
def finish(self, audio: bytes) -> str:
|
||||
"""The transcript. Raises SttError only if even the fallback couldn't
|
||||
reach the server."""
|
||||
if self._ok:
|
||||
self._flush()
|
||||
return self._text()
|
||||
self._close()
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=1)
|
||||
with self._lock:
|
||||
self._finals, self._interim = [], ""
|
||||
self._open()
|
||||
if not self._ok:
|
||||
raise SttError("couldn't reach the transcription server")
|
||||
step = config.FRAME_SAMPLES * 2 * 8
|
||||
for offset in range(0, len(audio), step):
|
||||
self._send(audio[offset:offset + step])
|
||||
self._flush()
|
||||
return self._text()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._ok = False
|
||||
self._close()
|
||||
|
||||
def _flush(self) -> None:
|
||||
"""Deepgram sends its last results after CloseStream; wait for them,
|
||||
bounded, so a hung socket can't hold up the reply."""
|
||||
try:
|
||||
self._socket.send(json.dumps({"type": "CloseStream"}))
|
||||
except Exception:
|
||||
pass
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=4)
|
||||
self._close()
|
||||
|
||||
def _close(self) -> None:
|
||||
socket, self._socket = self._socket, None
|
||||
if socket is not None:
|
||||
try:
|
||||
socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _read(self) -> None:
|
||||
socket = self._socket
|
||||
while socket is not None:
|
||||
try:
|
||||
message = socket.recv()
|
||||
except Exception:
|
||||
break
|
||||
if not message:
|
||||
break
|
||||
text, is_final = parse_result(message)
|
||||
with self._lock:
|
||||
if is_final:
|
||||
if text:
|
||||
self._finals.append(text)
|
||||
self._interim = ""
|
||||
elif text:
|
||||
self._interim = text
|
||||
|
||||
def _text(self) -> str:
|
||||
with self._lock:
|
||||
return " ".join(self._finals + ([self._interim] if self._interim else [])).strip()
|
||||
Reference in New Issue
Block a user