3d72fd28ec
Task 5-1-01: voice/ — VoiceProvider protocol (transcribe/synthesize, D-030 mirroring
LLMProvider), deterministic MockVoiceProvider (scripted STT queue, canned tone-WAV
TTS chunks, failure modes incl. empty audio), browser fallback descriptor (client
native SR/TTS), factory (mock default; browser; openai-audio REJECTED as a v0.4 seam
per CUT-1/G-7), config key AI_VOICE_PROVIDER + .env.example note. voice/ imports no
agents/api (AST-tested).
Task 5-1-03: DefenseStore (4th D-027 store; first FK family) — DefenseRecord +
DefenseTurn (ordered by (defense_id, seq)); start/append_turn/finalize/get/
list_for_learner; PRAGMA foreign_keys=ON for Postgres parity; integrity signals JSON
(A-109); store owns the finished transition.
34 voice tests green; ruff clean.
---ci---
phase: 5
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-006], partial: []}
---/ci---
97 lines
3.5 KiB
Python
97 lines
3.5 KiB
Python
"""Deterministic MockVoiceProvider (D-030, REQ-3-006).
|
|
|
|
Canned transcripts (scripted per test via queue) + canned 1kHz-tone WAV bytes
|
|
+ scripted failure modes. Two identical transcribe calls yield identical
|
|
segments; tests NEVER touch a real voice API (conftest cloud-free rule).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import math
|
|
import struct
|
|
import wave
|
|
from collections.abc import AsyncIterator
|
|
|
|
from .base import TranscriptSegment
|
|
|
|
|
|
def _tone_wav(duration_ms: int = 250, freq_hz: float = 1000.0) -> bytes:
|
|
"""A small, deterministic 16-bit mono WAV: a sine tone (stdlib only)."""
|
|
rate = 8000
|
|
n_samples = max(1, int(rate * duration_ms / 1000))
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(rate)
|
|
for i in range(n_samples):
|
|
sample = int(12000 * math.sin(2 * math.pi * freq_hz * i / rate))
|
|
w.writeframes(struct.pack("<h", sample))
|
|
return buf.getvalue()
|
|
|
|
|
|
class MockVoiceFailure(RuntimeError):
|
|
"""Scripted failure mode for tests."""
|
|
|
|
|
|
class MockVoiceProvider:
|
|
"""Deterministic voice provider: scripted STT, canned-tone TTS.
|
|
|
|
- `transcribe`: pops the next scripted transcript from a queue (or a
|
|
default); two identical calls with the same queue state are identical.
|
|
Failure mode: raise MockVoiceFailure when the queue holds a failure
|
|
marker (the string "FAIL") or `audio` is empty.
|
|
- `synthesize`: yields the canned tone WAV in fixed-size chunks; failure
|
|
mode: empty text raises MockVoiceFailure.
|
|
"""
|
|
|
|
def __init__(self, transcripts: list[str] | None = None) -> None:
|
|
self._transcripts = list(transcripts or [])
|
|
self._cursor = 0
|
|
self.transcribe_calls = 0
|
|
self.synthesize_calls = 0
|
|
|
|
def script(self, transcripts: list[str]) -> None:
|
|
"""Replace the scripted queue (tests set expectations up front)."""
|
|
self._transcripts = list(transcripts)
|
|
self._cursor = 0
|
|
|
|
async def transcribe(self, audio: bytes, fmt: str) -> TranscriptSegment:
|
|
self.transcribe_calls += 1
|
|
if not audio:
|
|
raise MockVoiceFailure("no audio bytes provided")
|
|
if not self._transcripts:
|
|
raise MockVoiceFailure("transcript queue exhausted — script it")
|
|
item = self._transcripts[self._cursor]
|
|
self._cursor = (self._cursor + 1) % len(self._transcripts)
|
|
if item == "FAIL":
|
|
raise MockVoiceFailure("scripted STT failure")
|
|
return TranscriptSegment(
|
|
text=item,
|
|
duration_ms=max(1, len(audio) // 32), # deterministic pseudo-duration
|
|
)
|
|
|
|
async def synthesize(self, text: str, voice: str = "default") -> AsyncIterator[bytes]: # noqa: ASYNC109 (protocol parity)
|
|
# NOTE: protocol parity matters more than the async-generator purity
|
|
# lint; the real provider seam (v0.4) will stream over HTTP.
|
|
self.synthesize_calls += 1
|
|
if not text:
|
|
raise MockVoiceFailure("cannot synthesize empty text")
|
|
wav = _tone_wav(duration_ms=min(2000, max(120, len(text) * 12)))
|
|
for i in range(0, len(wav), 1024):
|
|
yield wav[i : i + 1024]
|
|
await asyncio.sleep(0) # yield to the loop like a network stream
|
|
|
|
|
|
# Protocol-shape parity guard (mock must satisfy the D-030 port).
|
|
from .base import VoiceProvider # noqa: E402
|
|
|
|
|
|
def _assert_protocol() -> None:
|
|
assert isinstance(MockVoiceProvider(), VoiceProvider)
|
|
|
|
|
|
_assert_protocol()
|