Files
turnstone/tests/test_audio.py
T
Patrick Buckley 3d12798315 feat(audio): voice I/O — speech-to-text + text-to-speech via model roles (#618)
* feat(audio): voice I/O — speech-to-text + text-to-speech via model roles

Browser voice input/output over the OpenAI audio wire protocol, selected
through the existing model-roles system so the same code path serves OpenAI,
vLLM/vLLM-Omni, or any compatible backend — pure registry config, no new
in-process deps. Anthropic has no audio API, so it is capability-gated out of
the audio roles while remaining valid as the agent model.

Backend
- core/audio.py: role resolution + capability gating + transcribe()/synthesize()
  over a registry-resolved client. Typed AudioUnavailableError (503) /
  AudioBackendError (502 — body masked, SDK detail logged). Optional STT prompt.
- Endpoints POST /v1/api/workstreams/{ws_id}/speech-to-text and POST /v1/api/tts,
  registered in v1_routes, write-scoped (direct + proxied), offloaded with
  asyncio.to_thread. Silence -> 422; configured-but-failed backend -> masked 502.
- Model roles: audio.stt_model_alias / audio.tts_model_alias / audio.tts_voice /
  audio.stt_prompt settings; Models -> Roles entries (capability-gated dropdowns,
  "(disabled — voice off)" when unset). /v1/api/models exposes resolved
  stt_default_alias / tts_default_alias + per-model capabilities.
- Capabilities: supports_transcription / supports_speech_synthesis on
  ModelCapabilities; current OpenAI audio lineup (whisper-1, gpt-4o[-mini]-
  transcribe, tts-1[-hd], gpt-4o-mini-tts) registered as known models, with a
  name-inference backstop for local/openai-compatible aliases.

Frontend (interactive UI)
- Mic dictation (record -> transcribe -> fill composer for review) and
  per-message playback, shown only when the role is configured.
- CSS-mask icon set, aria-pressed + live-region announcements, recording timer,
  reduced-motion cue, error-typed toasts + persistent denial, mic disabled while
  busy, code/math stripped before TTS.

Tests: new test_audio.py plus STT/TTS endpoint, settings, openapi, available-
models, and OpenAI-lineup capability coverage. ruff + mypy + node --check clean.

* fix(audio): use const for AUDIO_MODEL_HINTS (var-sweep invariant)
2026-05-30 14:03:39 -07:00

232 lines
9.7 KiB
Python

"""Unit tests for the STT/TTS audio helper (model-role resolution + backends).
``transcribe`` / ``synthesize`` are exercised through the registry boundary
with a mocked OpenAI-SDK client (mocking ``client.audio.*``), so the real
helper code runs end-to-end without a network call.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from turnstone.core import audio
class _Cfg:
"""Stand-in for ModelConfig — only the fields audio.py reads."""
def __init__(self, model: str, capabilities: dict | None = None) -> None:
self.model = model
self.capabilities = capabilities or {}
class _FakeConfigStore:
def __init__(self, **values: str) -> None:
self._values = values
def get(self, key: str, default: str = "") -> str:
return self._values.get(key, default)
class _FakeRegistry:
"""Minimal registry exposing the surface audio.py uses."""
def __init__(self, alias: str, cfg: _Cfg, client: object) -> None:
self._alias = alias
self._cfg = cfg
self._client = client
def has_alias(self, alias: str) -> bool:
return alias == self._alias
def get_config(self, alias: str) -> _Cfg:
if alias != self._alias:
raise ValueError(alias)
return self._cfg
def resolve(self, alias: str | None = None):
if alias not in (None, self._alias):
raise ValueError(alias)
return self._client, self._cfg.model, self._cfg
# ---------------------------------------------------------------------------
# Capability gating
# ---------------------------------------------------------------------------
class TestModelSupportsRole:
def test_explicit_flag_wins(self):
assert audio.model_supports_role(_Cfg("anything", {"supports_transcription": True}), "stt")
# Explicit False overrides the would-be inference from the model name.
assert not audio.model_supports_role(
_Cfg("gpt-4o-mini-tts", {"supports_speech_synthesis": False}), "tts"
)
def test_infers_known_openai_audio_models(self):
assert audio.model_supports_role(_Cfg("gpt-4o-mini-transcribe"), "stt")
assert audio.model_supports_role(_Cfg("whisper-1"), "stt")
assert audio.model_supports_role(_Cfg("gpt-4o-mini-tts"), "tts")
assert audio.model_supports_role(_Cfg("tts-1"), "tts")
def test_chat_model_not_eligible(self):
assert not audio.model_supports_role(_Cfg("gpt-5"), "stt")
# Anthropic has no audio API — gated out of every audio role.
assert not audio.model_supports_role(_Cfg("claude-opus-4-8"), "tts")
assert not audio.model_supports_role(_Cfg("claude-opus-4-8"), "stt")
def test_unknown_role(self):
assert not audio.model_supports_role(_Cfg("whisper-1"), "vision_eval")
def test_hint_seed_lists_are_pinned(self):
# Mirrored verbatim in admin.js AUDIO_MODEL_HINTS — if these change,
# update the JS dropdown gate too (this pin makes the change deliberate).
assert audio._AUDIO_MODEL_HINTS == {
"stt": ("transcribe", "whisper", "-asr"),
"tts": ("tts-", "-tts"),
}
# ---------------------------------------------------------------------------
# Role resolution
# ---------------------------------------------------------------------------
class TestResolveRoleAlias:
def test_resolves_configured_capable_alias(self):
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), MagicMock())
cs = _FakeConfigStore(**{"audio.stt_model_alias": "voice"})
assert audio.resolve_role_alias(config_store=cs, registry=reg, role="stt") == "voice"
def test_none_when_unset(self):
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), MagicMock())
assert (
audio.resolve_role_alias(config_store=_FakeConfigStore(), registry=reg, role="stt")
is None
)
def test_none_when_alias_missing_from_registry(self):
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), MagicMock())
cs = _FakeConfigStore(**{"audio.stt_model_alias": "ghost"})
assert audio.resolve_role_alias(config_store=cs, registry=reg, role="stt") is None
def test_none_when_alias_not_capability_eligible(self):
# Alias exists but its model can't do TTS -> gated out (Anthropic case).
reg = _FakeRegistry("brain", _Cfg("claude-opus-4-8"), MagicMock())
cs = _FakeConfigStore(**{"audio.tts_model_alias": "brain"})
assert audio.resolve_role_alias(config_store=cs, registry=reg, role="tts") is None
def test_none_when_no_registry_or_store(self):
assert audio.resolve_role_alias(config_store=None, registry=None, role="stt") is None
# ---------------------------------------------------------------------------
# transcribe / synthesize — boundary: mocked OpenAI-SDK client
# ---------------------------------------------------------------------------
class TestTranscribe:
def test_calls_audio_transcriptions_and_returns_text(self):
client = MagicMock()
client.audio.transcriptions.create.return_value = MagicMock(text=" hello world ")
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-transcribe"), client)
res = audio.transcribe(
registry=reg, alias="voice", data=b"RIFFfake", filename="speech.webm"
)
assert res.transcript == "hello world"
assert res.model_alias == "voice"
assert res.model == "gpt-4o-mini-transcribe"
kwargs = client.audio.transcriptions.create.call_args.kwargs
assert kwargs["model"] == "gpt-4o-mini-transcribe"
assert kwargs["file"] == ("speech.webm", b"RIFFfake")
def test_prompt_forwarded_when_set(self):
client = MagicMock()
client.audio.transcriptions.create.return_value = MagicMock(text="ok")
reg = _FakeRegistry("voice", _Cfg("whisper-1"), client)
audio.transcribe(
registry=reg, alias="voice", data=b"x", filename="a.wav", prompt="ACME jargon"
)
assert client.audio.transcriptions.create.call_args.kwargs["prompt"] == "ACME jargon"
def test_prompt_omitted_when_blank(self):
client = MagicMock()
client.audio.transcriptions.create.return_value = MagicMock(text="ok")
reg = _FakeRegistry("voice", _Cfg("whisper-1"), client)
audio.transcribe(registry=reg, alias="voice", data=b"x", filename="a.wav")
assert "prompt" not in client.audio.transcriptions.create.call_args.kwargs
def test_backend_failure_raises_backend_error(self):
client = MagicMock()
client.audio.transcriptions.create.side_effect = RuntimeError("boom")
reg = _FakeRegistry("voice", _Cfg("whisper-1"), client)
with pytest.raises(audio.AudioBackendError):
audio.transcribe(registry=reg, alias="voice", data=b"x", filename="a.wav")
class TestSynthesize:
def test_calls_audio_speech_and_returns_bytes(self):
client = MagicMock()
speech = MagicMock()
speech.read.return_value = b"RIFF...wavbytes"
client.audio.speech.create.return_value = speech
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-tts"), client)
res = audio.synthesize(registry=reg, alias="voice", text="hi", voice="nova")
assert res.audio_bytes == b"RIFF...wavbytes"
assert res.media_type == "audio/mpeg"
assert res.model_alias == "voice"
kwargs = client.audio.speech.create.call_args.kwargs
assert kwargs["voice"] == "nova"
assert kwargs["input"] == "hi"
def test_default_voice_when_empty(self):
client = MagicMock()
client.audio.speech.create.return_value = MagicMock(read=lambda: b"a")
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-tts"), client)
audio.synthesize(registry=reg, alias="voice", text="hi", voice="")
assert client.audio.speech.create.call_args.kwargs["voice"] == "alloy"
def test_backend_failure_raises_backend_error(self):
client = MagicMock()
client.audio.speech.create.side_effect = RuntimeError("down")
reg = _FakeRegistry("voice", _Cfg("gpt-4o-mini-tts"), client)
with pytest.raises(audio.AudioBackendError):
audio.synthesize(registry=reg, alias="voice", text="hi", voice="nova")
class TestOpenAIAudioModelsKnown:
"""The current OpenAI STT/TTS lineup is registered in the static capability
table, so the admin 'suggested capabilities' recognizes them and they show
in the known-models list. (Role gating also works via name inference for
openai-compatible/local backends that aren't in the static table.)"""
def test_stt_models_flagged(self):
from turnstone.core.providers import lookup_model_capabilities
for m in (
"whisper-1",
"gpt-4o-transcribe",
"gpt-4o-mini-transcribe",
"gpt-4o-transcribe-diarize", # prefix variant
):
caps = lookup_model_capabilities("openai", m) or {}
assert caps.get("supports_transcription") is True, m
assert caps.get("supports_speech_synthesis") is False, m
def test_tts_models_flagged(self):
from turnstone.core.providers import lookup_model_capabilities
for m in ("tts-1", "tts-1-hd", "gpt-4o-mini-tts"): # tts-1-hd is a prefix variant
caps = lookup_model_capabilities("openai", m) or {}
assert caps.get("supports_speech_synthesis") is True, m
assert caps.get("supports_transcription") is False, m
def test_chat_model_has_no_audio_flags(self):
from turnstone.core.providers import lookup_model_capabilities
caps = lookup_model_capabilities("openai", "gpt-5") or {}
assert not caps.get("supports_transcription")
assert not caps.get("supports_speech_synthesis")