Update desktop app to android app capabilities.
This commit is contained in:
@@ -20,8 +20,7 @@ from bolt_pet.doctor import FAIL, OK, WARN
|
||||
|
||||
def test_every_check_returns_a_verdict_on_a_bare_machine(monkeypatch):
|
||||
"""Nothing configured, nothing installed — still a full report."""
|
||||
for name in ("SERVER_URL", "API_KEY", "DEEPGRAM_API_KEY",
|
||||
"ELEVENLABS_API_KEY", "ELEVENLABS_VOICE_ID"):
|
||||
for name in ("SERVER_URL", "API_KEY", "ELEVENLABS_API_KEY", "ELEVENLABS_VOICE_ID"):
|
||||
monkeypatch.setattr(config, name, "")
|
||||
monkeypatch.setattr(doctor, "_module", lambda _n: False)
|
||||
|
||||
@@ -71,7 +70,7 @@ def test_a_configured_server_passes_without_being_contacted(monkeypatch):
|
||||
def test_every_problem_comes_with_something_to_do_about_it(monkeypatch):
|
||||
""""screen reading: warn" is useless on its own; "apt install tesseract-ocr"
|
||||
is the entire point of the tool."""
|
||||
for name in ("SERVER_URL", "API_KEY", "DEEPGRAM_API_KEY"):
|
||||
for name in ("SERVER_URL", "API_KEY"):
|
||||
monkeypatch.setattr(config, name, "")
|
||||
monkeypatch.setattr(doctor, "_module", lambda _n: False)
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""stt.transcribe() — the "guaranteed" one-shot fallback.
|
||||
|
||||
There is no separate REST endpoint server-side any more: /desk/stt is a
|
||||
websocket relay only, so this connects the exact same way stt_stream.py's
|
||||
opportunistic streaming path does (via stt_stream.connect(), unconditionally
|
||||
— not gated by STT_STREAMING, since there's nothing left to fall back to).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio import stt, stt_stream
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, messages=(), fail_to_connect=False):
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
self._fail_to_connect = fail_to_connect
|
||||
self._messages = list(messages)
|
||||
|
||||
def send_binary(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
def send(self, text):
|
||||
self.sent.append(text)
|
||||
|
||||
def recv(self):
|
||||
if self._messages:
|
||||
return self._messages.pop(0)
|
||||
time.sleep(0.01)
|
||||
raise ConnectionError("closed")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _results(transcript, is_final=True):
|
||||
return json.dumps({
|
||||
"type": "Results", "is_final": is_final,
|
||||
"channel": {"alternatives": [{"transcript": transcript}]},
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch):
|
||||
monkeypatch.setattr(stt.config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(stt.config, "API_KEY", "test-key")
|
||||
|
||||
|
||||
def test_transcribe_raises_when_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(stt.config, "API_KEY", "")
|
||||
with pytest.raises(stt.SttError, match="BOLT_SERVER_URL"):
|
||||
stt.transcribe(np.zeros(320, dtype=np.int16))
|
||||
|
||||
|
||||
def test_transcribe_connects_and_feeds_the_whole_utterance(monkeypatch):
|
||||
socket = FakeSocket([_results("turn on the lights")])
|
||||
monkeypatch.setattr(stt_stream, "connect", lambda: socket)
|
||||
|
||||
pcm = np.full(3200, 500, dtype=np.int16)
|
||||
text = stt.transcribe(pcm)
|
||||
|
||||
assert text == "turn on the lights"
|
||||
# one binary frame (the whole utterance) plus the close message
|
||||
assert len(socket.sent) == 2
|
||||
assert socket.closed
|
||||
|
||||
|
||||
def test_transcribe_raises_when_the_server_is_unreachable(monkeypatch):
|
||||
def refuse():
|
||||
raise OSError("no route to host")
|
||||
|
||||
monkeypatch.setattr(stt_stream, "connect", refuse)
|
||||
|
||||
with pytest.raises(stt.SttError, match="couldn't reach"):
|
||||
stt.transcribe(np.zeros(320, dtype=np.int16))
|
||||
|
||||
|
||||
def test_transcribe_returns_empty_string_for_silence_not_an_error(monkeypatch):
|
||||
"""No speech recognized is a legitimate outcome, not a failure — callers
|
||||
(controller.py) treat "" as "say nothing" rather than logging an error."""
|
||||
socket = FakeSocket([]) # never says anything back
|
||||
monkeypatch.setattr(stt_stream, "connect", lambda: socket)
|
||||
|
||||
assert stt.transcribe(np.zeros(320, dtype=np.int16)) == ""
|
||||
|
||||
|
||||
def test_transcribe_is_unaffected_by_stt_streaming_being_off(monkeypatch):
|
||||
"""The opportunistic accelerator and the guaranteed fallback share a
|
||||
connector, but STT_STREAMING must only gate the former."""
|
||||
monkeypatch.setattr(stt.config, "STT_STREAMING", False)
|
||||
socket = FakeSocket([_results("still works")])
|
||||
monkeypatch.setattr(stt_stream, "connect", lambda: socket)
|
||||
|
||||
assert stt.transcribe(np.zeros(320, dtype=np.int16)) == "still works"
|
||||
@@ -130,16 +130,67 @@ def test_open_returns_a_session_when_it_can():
|
||||
assert session.finish() == "hi"
|
||||
|
||||
|
||||
def test_streaming_is_off_without_the_switch_or_a_key(monkeypatch):
|
||||
def test_streaming_is_off_without_the_switch_or_server_config(monkeypatch):
|
||||
from bolt_pet.audio import stt_stream
|
||||
|
||||
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", False)
|
||||
monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "test-key")
|
||||
assert stt_stream.available() is False
|
||||
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True)
|
||||
monkeypatch.setattr(stt_stream.config, "DEEPGRAM_API_KEY", "")
|
||||
assert stt_stream.available() is True
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "")
|
||||
assert stt_stream.available() is False
|
||||
|
||||
|
||||
# ── connecting to the server relay ──────────────────────────────────────────
|
||||
|
||||
def test_connect_builds_the_server_relay_url(monkeypatch):
|
||||
"""No local Deepgram account any more — the pet connects to its own
|
||||
server's /desk/stt, authenticated with its own desk key."""
|
||||
from bolt_pet.audio import stt_stream
|
||||
|
||||
monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://my-server:5002")
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "my-desk-key")
|
||||
monkeypatch.setattr(stt_stream.config, "SESSION_ID", "pet-test")
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeWebsocketModule:
|
||||
@staticmethod
|
||||
def create_connection(url, header=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["header"] = header
|
||||
return "a-socket"
|
||||
|
||||
monkeypatch.setitem(sys.modules, "websocket", _FakeWebsocketModule())
|
||||
|
||||
result = stt_stream.connect(sample_rate=16000)
|
||||
|
||||
assert result == "a-socket"
|
||||
assert captured["url"] == (
|
||||
"ws://my-server:5002/desk/stt?session_id=pet-test"
|
||||
"&encoding=linear16&sample_rate=16000"
|
||||
)
|
||||
assert captured["header"] == ["X-Desk-Api-Key: my-desk-key"]
|
||||
|
||||
|
||||
def test_open_uses_connect_by_default(monkeypatch):
|
||||
"""StreamingTranscriber.open() with no injected connect() goes through
|
||||
the real server-relay connector."""
|
||||
from bolt_pet.audio import stt_stream
|
||||
|
||||
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True)
|
||||
monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://my-server:5002")
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "my-desk-key")
|
||||
monkeypatch.setattr(stt_stream, "_connect", lambda rate: FakeSocket([_results("hi")]))
|
||||
|
||||
session = stt_stream.StreamingTranscriber.open()
|
||||
|
||||
assert session is not None
|
||||
assert session.finish() == "hi"
|
||||
|
||||
|
||||
# ── the capture hook ────────────────────────────────────────────────────────
|
||||
|
||||
class _Stream:
|
||||
|
||||
+81
-13
@@ -3,13 +3,16 @@ no network, no audio device, no ONNX model."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config as tts_config
|
||||
from bolt_pet.audio.tts import chunks_to_int16, model_for, voice_for
|
||||
from bolt_pet.audio import tts
|
||||
from bolt_pet.audio.tts import TtsError, chunks_to_int16, voice_for
|
||||
from bolt_pet.audio.wake_word import NearMissLog
|
||||
|
||||
|
||||
@@ -83,7 +86,7 @@ def test_clear_resets_peak_and_entries():
|
||||
assert log.entries() == [] and log.peak == 0.0
|
||||
|
||||
|
||||
# ── voice / model selection (server speak_as) ───────────────────────────────
|
||||
# ── voice selection (server speak_as) ───────────────────────────────────────
|
||||
|
||||
def test_the_override_voice_wins_over_the_configured_one(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "DEFAULT")
|
||||
@@ -92,16 +95,81 @@ def test_the_override_voice_wins_over_the_configured_one(monkeypatch):
|
||||
assert voice_for(None) == "DEFAULT"
|
||||
|
||||
|
||||
def test_english_replies_in_the_default_voice_use_the_default_model(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
|
||||
assert model_for("all good here", None) == "eleven_flash_v2"
|
||||
# ── server-hosted synthesis (/desk/tts) ─────────────────────────────────────
|
||||
# No local ElevenLabs account: both the whole-clip and streaming paths post
|
||||
# to this pet's own server, same as server_client.py's other endpoints.
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(tts_config, "API_KEY", "test-key")
|
||||
monkeypatch.setattr(tts_config, "SESSION_ID", "pet-test")
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "default-voice")
|
||||
|
||||
|
||||
def test_a_picked_voice_or_non_english_text_uses_the_multilingual_model(monkeypatch):
|
||||
# eleven_flash_v2 is English-only: it would read either of these as
|
||||
# mangled phonetic English rather than failing outright.
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
|
||||
assert model_for("all good here", "VOICE1") == "eleven_flash_v2_5"
|
||||
assert model_for("こんにちは", None) == "eleven_flash_v2_5"
|
||||
def _mock_response(content=b"", ok=True):
|
||||
resp = MagicMock()
|
||||
resp.content = content
|
||||
resp.raise_for_status = MagicMock() if ok else MagicMock(side_effect=Exception("boom"))
|
||||
return resp
|
||||
|
||||
|
||||
def test_synthesize_pcm_posts_to_the_servers_tts_endpoint():
|
||||
pcm_bytes = np.array([1, 2, 3], dtype=np.int16).tobytes()
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(pcm_bytes)
|
||||
pcm, rate = tts.synthesize_pcm("hello there")
|
||||
post.assert_called_once()
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/tts"
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
assert kwargs["json"] == {
|
||||
"session_id": "pet-test", "text": "hello there", "voice_id": "default-voice",
|
||||
}
|
||||
assert pcm.tolist() == [1, 2, 3]
|
||||
assert rate == tts_config.TTS_SAMPLE_RATE
|
||||
|
||||
|
||||
def test_synthesize_pcm_uses_the_override_voice():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(b"\x01\x00")
|
||||
tts.synthesize_pcm("hi", voice_id="picked-voice")
|
||||
assert post.call_args.kwargs["json"]["voice_id"] == "picked-voice"
|
||||
|
||||
|
||||
def test_synthesize_pcm_raises_when_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "API_KEY", "")
|
||||
with pytest.raises(TtsError, match="BOLT_SERVER_URL"):
|
||||
tts.synthesize_pcm("hi")
|
||||
|
||||
|
||||
def test_synthesize_pcm_raises_on_empty_audio():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(b"")
|
||||
with pytest.raises(TtsError, match="no audio"):
|
||||
tts.synthesize_pcm("hi")
|
||||
|
||||
|
||||
def test_synthesize_pcm_raises_when_the_request_fails():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(b"", ok=False)
|
||||
with pytest.raises(TtsError, match="server tts request failed"):
|
||||
tts.synthesize_pcm("hi")
|
||||
|
||||
|
||||
def test_stream_pcm_posts_to_the_same_endpoint_with_stream_true():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
response = MagicMock()
|
||||
response.raise_for_status = MagicMock()
|
||||
response.iter_content.return_value = [np.array([4, 5], dtype=np.int16).tobytes()]
|
||||
post.return_value = response
|
||||
chunks = list(tts.stream_pcm("hi"))
|
||||
assert post.call_args.kwargs["stream"] is True
|
||||
assert post.call_args[0][0] == "http://test-server:5002/desk/tts"
|
||||
assert np.concatenate(chunks).tolist() == [4, 5]
|
||||
|
||||
|
||||
def test_stream_pcm_raises_when_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "SERVER_URL", "")
|
||||
with pytest.raises(TtsError, match="BOLT_SERVER_URL"):
|
||||
list(tts.stream_pcm("hi"))
|
||||
|
||||
Reference in New Issue
Block a user