310 lines
11 KiB
Python
310 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from http.server import ThreadingHTTPServer
|
|
from typing import ClassVar
|
|
|
|
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
|
from cognitive_agent.infrastructure.speech import SpeechRecognitionError
|
|
from cognitive_agent.server import SeatDemoHandler
|
|
|
|
|
|
class SpeechStub:
|
|
preview_calls: ClassVar[list[dict[str, object]]] = []
|
|
final_calls: ClassVar[list[dict[str, object]]] = []
|
|
preview_error: ClassVar[SpeechRecognitionError | None] = None
|
|
|
|
@staticmethod
|
|
def status() -> dict[str, object]:
|
|
return {
|
|
"enabled": True,
|
|
"model": "SenseVoiceSmall",
|
|
"decoder": "offline-sensevoice-live-preview-and-final",
|
|
"live_preview": {"enabled": True, "recommended_interval_ms": 850},
|
|
"input_modes": {
|
|
"supported": ["manual", "wake"],
|
|
"header": "X-Speech-Input-Mode",
|
|
"preview_default": "wake",
|
|
"final_default": "manual",
|
|
},
|
|
"wake_word": {
|
|
"enabled": True,
|
|
"primary": "你好,小迪",
|
|
"aliases": ["你好,小迪"],
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def transcribe_preview_wav(
|
|
cls,
|
|
content: bytes,
|
|
*,
|
|
utterance_id: str | None = None,
|
|
sequence: int,
|
|
) -> dict[str, object]:
|
|
if cls.preview_error is not None:
|
|
raise cls.preview_error
|
|
if sequence < 0:
|
|
raise SpeechRecognitionError(
|
|
"invalid_preview_sequence", "实时转写序号无效。", status=400
|
|
)
|
|
cls.preview_calls.append(
|
|
{
|
|
"content": content,
|
|
"utterance_id": utterance_id,
|
|
"sequence": sequence,
|
|
}
|
|
)
|
|
return {
|
|
"utterance_id": utterance_id,
|
|
"sequence": sequence,
|
|
"state": "partial",
|
|
"raw_text": "你好小迪打开空",
|
|
"text": "你好小迪打开空",
|
|
"live_text": "打开空",
|
|
"wake_word": {"detected": True, "remainder": "打开空"},
|
|
"semantic_cleaned": False,
|
|
"stale": False,
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
@classmethod
|
|
def transcribe_wav(
|
|
cls,
|
|
content: bytes,
|
|
*,
|
|
utterance_id: str | None = None,
|
|
require_wake_word: bool = False,
|
|
) -> dict[str, object]:
|
|
cls.final_calls.append(
|
|
{
|
|
"content": content,
|
|
"utterance_id": utterance_id,
|
|
"require_wake_word": require_wake_word,
|
|
}
|
|
)
|
|
return {
|
|
"utterance_id": utterance_id,
|
|
"captured_text": "你好小迪车上有点热",
|
|
"raw_text": "车上有点热",
|
|
"assistant_text": "车上有点热。",
|
|
"text": "车上有点热。",
|
|
"clean_text": "",
|
|
"control_text": "",
|
|
"commands": [],
|
|
"execution_eligible": False,
|
|
"wake_word": {"detected": True, "remainder": "车上有点热"},
|
|
}
|
|
|
|
|
|
class SpeechApiHandler(SeatDemoHandler):
|
|
speech = SpeechStub()
|
|
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
|
|
|
|
|
|
class TestSpeechLiveWebApi:
|
|
early_rejection_body = b"RIFF" + (b"\0" * (256 * 1024))
|
|
|
|
@classmethod
|
|
def setup_class(cls) -> None:
|
|
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SpeechApiHandler)
|
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
|
|
|
|
@classmethod
|
|
def teardown_class(cls) -> None:
|
|
cls.server.shutdown()
|
|
cls.server.server_close()
|
|
cls.thread.join(timeout=3)
|
|
|
|
def setup_method(self) -> None:
|
|
SpeechStub.preview_calls.clear()
|
|
SpeechStub.final_calls.clear()
|
|
SpeechStub.preview_error = None
|
|
|
|
@classmethod
|
|
def post_audio(
|
|
cls,
|
|
path: str,
|
|
*,
|
|
body: bytes = b"RIFF-live-speech",
|
|
headers: dict[str, str] | None = None,
|
|
) -> tuple[int, dict[str, object]]:
|
|
request = urllib.request.Request(
|
|
f"{cls.base_url}{path}",
|
|
method="POST",
|
|
headers={"Content-Type": "audio/wav", **(headers or {})},
|
|
data=body,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
cls.last_response_headers = dict(response.headers.items())
|
|
return response.status, json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
cls.last_response_headers = dict(exc.headers.items())
|
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
|
|
|
def test_status_advertises_live_preview_and_configured_wake_word(self) -> None:
|
|
with urllib.request.urlopen(f"{self.base_url}/api/speech/status", timeout=5) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
|
|
assert payload["decoder"] == "offline-sensevoice-live-preview-and-final"
|
|
assert payload["live_preview"]["enabled"] is True
|
|
assert payload["input_modes"] == {
|
|
"supported": ["manual", "wake"],
|
|
"header": "X-Speech-Input-Mode",
|
|
"preview_default": "wake",
|
|
"final_default": "manual",
|
|
}
|
|
assert payload["wake_word"]["enabled"] is True
|
|
assert payload["wake_word"]["primary"] == "你好,小迪"
|
|
|
|
def test_preview_endpoint_forwards_accumulated_audio_identity_and_sequence(self) -> None:
|
|
status, payload = self.post_audio(
|
|
"/api/speech/preview",
|
|
headers={
|
|
"X-Speech-Input-Mode": "wake",
|
|
"X-Speech-Utterance-Id": "web-live-preview-1",
|
|
"X-Speech-Preview-Sequence": "7",
|
|
},
|
|
)
|
|
|
|
assert status == 200
|
|
assert SpeechStub.preview_calls == [
|
|
{
|
|
"content": b"RIFF-live-speech",
|
|
"utterance_id": "web-live-preview-1",
|
|
"sequence": 7,
|
|
}
|
|
]
|
|
assert payload["state"] == "partial"
|
|
assert payload["sequence"] == 7
|
|
assert payload["raw_text"] == "你好小迪打开空"
|
|
assert payload["live_text"] == "打开空"
|
|
assert payload["semantic_cleaned"] is False
|
|
|
|
def test_final_endpoint_requires_wake_and_preserves_assistant_only_text(self) -> None:
|
|
status, payload = self.post_audio(
|
|
"/api/speech/transcribe",
|
|
headers={
|
|
"X-Speech-Input-Mode": "wake",
|
|
"X-Speech-Utterance-Id": "web-live-final-1",
|
|
"X-Speech-Wake-Word-Required": "1",
|
|
},
|
|
)
|
|
|
|
assert status == 200
|
|
assert SpeechStub.final_calls == [
|
|
{
|
|
"content": b"RIFF-live-speech",
|
|
"utterance_id": "web-live-final-1",
|
|
"require_wake_word": True,
|
|
}
|
|
]
|
|
assert payload["raw_text"] == "车上有点热"
|
|
assert payload["assistant_text"] == "车上有点热。"
|
|
assert payload["text"] == "车上有点热。"
|
|
assert payload["control_text"] == ""
|
|
assert payload["commands"] == []
|
|
assert payload["execution_eligible"] is False
|
|
|
|
def test_manual_final_endpoint_does_not_require_a_wake_word(self) -> None:
|
|
status, payload = self.post_audio(
|
|
"/api/speech/transcribe",
|
|
headers={
|
|
"X-Speech-Input-Mode": "manual",
|
|
"X-Speech-Utterance-Id": "web-manual-final-1",
|
|
"X-Speech-Wake-Word-Required": "0",
|
|
},
|
|
)
|
|
|
|
assert status == 200
|
|
assert SpeechStub.final_calls == [
|
|
{
|
|
"content": b"RIFF-live-speech",
|
|
"utterance_id": "web-manual-final-1",
|
|
"require_wake_word": False,
|
|
}
|
|
]
|
|
assert payload["assistant_text"] == "车上有点热。"
|
|
|
|
def test_final_endpoint_defaults_to_manual_when_wake_header_is_absent(self) -> None:
|
|
status, _payload = self.post_audio(
|
|
"/api/speech/transcribe",
|
|
headers={
|
|
"X-Speech-Input-Mode": "manual",
|
|
"X-Speech-Utterance-Id": "web-manual-default-1",
|
|
},
|
|
)
|
|
|
|
assert status == 200
|
|
assert SpeechStub.final_calls[0]["require_wake_word"] is False
|
|
|
|
def test_preview_rejects_missing_sequence_without_invoking_transcriber(self) -> None:
|
|
status, payload = self.post_audio(
|
|
"/api/speech/preview",
|
|
body=self.early_rejection_body,
|
|
headers={"X-Speech-Utterance-Id": "web-live-preview-invalid"},
|
|
)
|
|
|
|
assert status == 400
|
|
assert payload["error"] == "invalid_preview_sequence"
|
|
assert SpeechStub.preview_calls == []
|
|
|
|
def test_preview_http_gate_rejects_overload_before_reading_or_transcribing(self) -> None:
|
|
original_slots = SpeechApiHandler.speech_preview_http_slots
|
|
constrained_slots = threading.BoundedSemaphore(1)
|
|
SpeechApiHandler.speech_preview_http_slots = constrained_slots
|
|
assert constrained_slots.acquire(blocking=False)
|
|
try:
|
|
status, payload = self.post_audio(
|
|
"/api/speech/preview",
|
|
body=self.early_rejection_body,
|
|
headers={
|
|
"X-Speech-Utterance-Id": "web-live-preview-overloaded",
|
|
"X-Speech-Preview-Sequence": "0",
|
|
},
|
|
)
|
|
finally:
|
|
constrained_slots.release()
|
|
SpeechApiHandler.speech_preview_http_slots = original_slots
|
|
|
|
assert status == 429
|
|
assert payload["error"] == "preview_overloaded"
|
|
assert payload["retry_after_seconds"] == 1
|
|
assert self.last_response_headers["Retry-After"] == "1"
|
|
assert SpeechStub.preview_calls == []
|
|
|
|
def test_preview_transcriber_rate_error_preserves_retry_contract(self) -> None:
|
|
SpeechStub.preview_error = SpeechRecognitionError(
|
|
"preview_rate_limited",
|
|
"实时语音更新过快,请稍后重试。",
|
|
status=429,
|
|
retry_after_seconds=2,
|
|
)
|
|
status, payload = self.post_audio(
|
|
"/api/speech/preview",
|
|
headers={
|
|
"X-Speech-Utterance-Id": "web-live-preview-throttled",
|
|
"X-Speech-Preview-Sequence": "1",
|
|
},
|
|
)
|
|
|
|
assert status == 429
|
|
assert payload["error"] == "preview_rate_limited"
|
|
assert payload["retry_after_seconds"] == 2
|
|
assert self.last_response_headers["Retry-After"] == "2"
|
|
|
|
def test_live_endpoints_reject_non_wav_content_type(self) -> None:
|
|
for path in ("/api/speech/preview", "/api/speech/transcribe"):
|
|
status, payload = self.post_audio(
|
|
path,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert status == 415, path
|
|
assert payload["error"] == "unsupported_media_type", path
|