855 lines
38 KiB
Python
855 lines
38 KiB
Python
from __future__ import annotations
|
||
|
||
import io
|
||
import json
|
||
import math
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import unittest
|
||
import wave
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
from cognitive_agent.infrastructure.speech.sensevoice import (
|
||
SpeechRecognitionError,
|
||
SpeechTranscriber,
|
||
)
|
||
from cognitive_agent.infrastructure.speech.understanding import (
|
||
UnderstandingTranscriptProcessor,
|
||
)
|
||
from cognitive_agent.infrastructure.speech.wake_word import WakeWordMatcher
|
||
|
||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||
|
||
|
||
def pcm16_wav(*, frequency: float = 220, seconds: float = 0.8) -> bytes:
|
||
frames = bytearray()
|
||
for index in range(round(16_000 * seconds)):
|
||
sample = round(math.sin(index * 2 * math.pi * frequency / 16_000) * 12_000)
|
||
frames.extend(int(sample).to_bytes(2, "little", signed=True))
|
||
target = io.BytesIO()
|
||
with wave.open(target, "wb") as output:
|
||
output.setnchannels(1)
|
||
output.setsampwidth(2)
|
||
output.setframerate(16_000)
|
||
output.writeframes(frames)
|
||
return target.getvalue()
|
||
|
||
|
||
class _FakeStream:
|
||
def __init__(self, text: str) -> None:
|
||
self.result = SimpleNamespace(
|
||
text=f"<|zh|><|NEUTRAL|><|Speech|>{text}",
|
||
lang="<|zh|>",
|
||
emotion="<|NEUTRAL|>",
|
||
event="<|Speech|>",
|
||
)
|
||
|
||
def accept_waveform(self, sample_rate: int, samples: object) -> None:
|
||
self.sample_rate = sample_rate
|
||
self.samples = samples
|
||
|
||
|
||
class _FakeRecognizer:
|
||
def __init__(self, text: str) -> None:
|
||
self.text = text
|
||
self.decode_count = 0
|
||
self.decoded_sample_counts: list[int] = []
|
||
|
||
def create_stream(self) -> _FakeStream:
|
||
return _FakeStream(self.text)
|
||
|
||
def decode_stream(self, stream: _FakeStream) -> None:
|
||
self.decode_count += 1
|
||
self.decoded_sample_counts.append(len(stream.samples))
|
||
|
||
|
||
class _BlockingRecognizer(_FakeRecognizer):
|
||
def __init__(self, text: str) -> None:
|
||
super().__init__(text)
|
||
self.decode_started = threading.Event()
|
||
self.release_decode = threading.Event()
|
||
|
||
def decode_stream(self, stream: _FakeStream) -> None:
|
||
super().decode_stream(stream)
|
||
self.decode_started.set()
|
||
if not self.release_decode.wait(timeout=5):
|
||
raise RuntimeError("test recognizer was not released")
|
||
|
||
|
||
class UnderstandingRegressionTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls.processor = UnderstandingTranscriptProcessor(REPOSITORY_ROOT)
|
||
fixture = REPOSITORY_ROOT / "tests" / "fixtures" / "speech_text_regression.json"
|
||
cls.cases = json.loads(fixture.read_text(encoding="utf-8"))
|
||
|
||
def test_text_regression_suite(self) -> None:
|
||
for case in self.cases:
|
||
with self.subTest(case=case["name"]):
|
||
result = self.processor.process(case["raw"])
|
||
self.assertEqual(result.raw_text, case["raw"])
|
||
self.assertEqual(result.clean_text, case["clean"])
|
||
self.assertEqual(
|
||
result.assistant_text,
|
||
case.get("assistant", case["clean"]),
|
||
)
|
||
serialized = result.to_dict()
|
||
self.assertEqual(serialized["text"], result.assistant_text)
|
||
self.assertEqual(serialized["assistant_text"], result.assistant_text)
|
||
self.assertEqual(serialized["control_text"], result.clean_text)
|
||
self.assertEqual(
|
||
[command.capability for command in result.commands],
|
||
case["capabilities"],
|
||
)
|
||
self.assertEqual(len(result.cancelled_fragments), case["cancelled"])
|
||
self.assertEqual(result.uncertain, case["uncertain"])
|
||
self.assertEqual(
|
||
result.execution_eligible,
|
||
case.get(
|
||
"execution_eligible",
|
||
bool(result.commands) and not result.uncertain,
|
||
),
|
||
)
|
||
if preserved := case.get("preserved_filler"):
|
||
self.assertNotIn(preserved, result.removed_fillers)
|
||
|
||
def test_cancelled_commands_never_appear_as_active(self) -> None:
|
||
result = self.processor.process(
|
||
"打开空调,不对,把座椅靠背向后调24度。"
|
||
)
|
||
self.assertEqual([command.domain for command in result.commands], ["seat"])
|
||
cancelled = result.cancelled_fragments[0].commands
|
||
self.assertEqual([command.domain for command in cancelled], ["climate"])
|
||
self.assertTrue(all(command.status == "cancelled" for command in cancelled))
|
||
|
||
def test_invalid_seat_absolute_angle_fails_closed(self) -> None:
|
||
result = self.processor.process("把座椅靠背调整到24度。")
|
||
self.assertTrue(result.uncertain)
|
||
self.assertFalse(result.execution_eligible)
|
||
self.assertIn("低于工程下限95", "".join(result.reasons))
|
||
|
||
def test_regular_multi_command_is_not_treated_as_correction(self) -> None:
|
||
result = self.processor.process("打开空调,然后打开氛围灯。")
|
||
self.assertEqual(len(result.commands), 2)
|
||
self.assertFalse(result.cancelled_fragments)
|
||
|
||
def test_meaningful_non_control_speech_remains_available_to_assistant(self) -> None:
|
||
cases = (
|
||
("今天天气怎么样?", "今天天气怎么样", "no_command"),
|
||
("我们中午吃什么?", "我们中午吃什么", "no_command"),
|
||
("车上有点热。", "车上有点热", "no_command"),
|
||
("打开雨刷。", "打开雨刷", "incomplete"),
|
||
("空调挺凉快。", "空调挺凉快", "incomplete"),
|
||
)
|
||
for raw, preserved, status in cases:
|
||
with self.subTest(raw=raw):
|
||
result = self.processor.process(raw)
|
||
self.assertIn(preserved, result.assistant_text)
|
||
self.assertTrue(result.assistant_text.strip())
|
||
self.assertEqual(result.semantic_status, status)
|
||
self.assertFalse(result.execution_eligible)
|
||
self.assertFalse(result.commands)
|
||
|
||
def test_control_and_chat_in_same_spoken_block_are_both_preserved_for_assistant(self) -> None:
|
||
result = self.processor.process("打开空调,今天天气怎么样?")
|
||
|
||
self.assertIn("打开空调", result.assistant_text)
|
||
self.assertIn("今天天气怎么样", result.assistant_text)
|
||
self.assertEqual(
|
||
[command.capability for command in result.commands],
|
||
["climate.set_power"],
|
||
)
|
||
self.assertEqual(result.clean_text, "打开空调。")
|
||
self.assertFalse(result.execution_eligible)
|
||
self.assertTrue(result.uncertain)
|
||
|
||
def test_mode_creation_stays_before_its_normalized_control_definition(self) -> None:
|
||
result = self.processor.process(
|
||
"创建一个躺平模式,副驾座椅水平向前移动到最大位置,车窗关闭"
|
||
)
|
||
|
||
self.assertEqual(
|
||
result.assistant_text,
|
||
"创建一个躺平模式,将副驾座椅向前调整到0%,然后关闭车窗。",
|
||
)
|
||
self.assertEqual(result.assistant_text.count("躺平模式"), 1)
|
||
self.assertEqual(
|
||
[command.capability for command in result.commands],
|
||
["seat.set_longitudinal_position", "window.set_motion"],
|
||
)
|
||
self.assertEqual(result.commands[0].parameters, {"position_pct": 0})
|
||
self.assertEqual(
|
||
result.clean_text,
|
||
"将副驾座椅向前调整到0%,然后关闭车窗。",
|
||
)
|
||
|
||
ordinary_controls = self.processor.process(
|
||
"副驾座椅水平向前移动到最大位置,车窗关闭"
|
||
)
|
||
self.assertEqual(
|
||
ordinary_controls.assistant_text,
|
||
"将副驾座椅向前调整到0%,然后关闭车窗。",
|
||
)
|
||
|
||
def test_cancelled_control_can_be_followed_by_an_assistant_question(self) -> None:
|
||
result = self.processor.process("打开空调,算了,我们聊聊空调为什么制冷。")
|
||
|
||
self.assertIn("我们聊聊空调为什么制冷", result.assistant_text)
|
||
self.assertNotIn("打开空调", result.assistant_text)
|
||
self.assertFalse(result.commands)
|
||
self.assertFalse(result.execution_eligible)
|
||
self.assertEqual(len(result.cancelled_fragments), 1)
|
||
self.assertEqual(
|
||
[command.capability for command in result.cancelled_fragments[0].commands],
|
||
["climate.set_power"],
|
||
)
|
||
|
||
def test_general_question_correction_keeps_only_latest_meaningful_request(self) -> None:
|
||
result = self.processor.process("帮我查北京天气,不对,上海天气。")
|
||
|
||
self.assertIn("上海天气", result.assistant_text)
|
||
self.assertNotIn("北京天气", result.assistant_text)
|
||
self.assertFalse(result.commands)
|
||
self.assertFalse(result.execution_eligible)
|
||
self.assertEqual(len(result.cancelled_fragments), 1)
|
||
|
||
def test_general_questions_do_not_split_connector_characters_inside_words(self) -> None:
|
||
cases = (
|
||
"再生能源是什么?",
|
||
"并发编程是什么意思?",
|
||
"先锋队是什么意思?",
|
||
"最后生还者讲了什么?",
|
||
"我想了解再生能源和并发编程。",
|
||
"不对称加密是什么?",
|
||
"如何把PDF改成Word?",
|
||
)
|
||
for raw in cases:
|
||
with self.subTest(raw=raw):
|
||
result = self.processor.process(raw)
|
||
self.assertEqual(result.assistant_text, raw)
|
||
self.assertFalse(result.commands)
|
||
self.assertFalse(result.execution_eligible)
|
||
|
||
def test_control_and_chat_joined_by_coordination_are_both_preserved(self) -> None:
|
||
result = self.processor.process("打开空调和告诉我天气")
|
||
|
||
self.assertIn("打开空调", result.assistant_text)
|
||
self.assertIn("告诉我天气", result.assistant_text)
|
||
self.assertEqual(
|
||
[command.capability for command in result.commands],
|
||
["climate.set_power"],
|
||
)
|
||
self.assertEqual(result.clean_text, "打开空调。")
|
||
self.assertTrue(result.uncertain)
|
||
self.assertFalse(result.execution_eligible)
|
||
|
||
def test_negated_control_remains_conversational_but_never_executable(self) -> None:
|
||
result = self.processor.process("不要打开空调,告诉我空调为什么制冷。")
|
||
|
||
self.assertIn("不要打开空调", result.assistant_text)
|
||
self.assertIn("告诉我空调为什么制冷", result.assistant_text)
|
||
self.assertFalse(result.commands)
|
||
self.assertFalse(result.execution_eligible)
|
||
self.assertTrue(result.cancelled_fragments)
|
||
self.assertTrue(
|
||
all(
|
||
command.negated and command.status == "cancelled"
|
||
for fragment in result.cancelled_fragments
|
||
for command in fragment.commands
|
||
)
|
||
)
|
||
|
||
def test_multiple_devices_numbers_and_decimal_values_keep_clause_binding(self) -> None:
|
||
result = self.processor.process(
|
||
"把主驾温度调到22.5度,副驾调到24度,再把风量调到2档。"
|
||
)
|
||
|
||
self.assertTrue(result.execution_eligible)
|
||
self.assertEqual(
|
||
[
|
||
(command.capability, command.target_scope, command.parameters)
|
||
for command in result.commands
|
||
],
|
||
[
|
||
("climate.set_temperature", "driver", {"temperature_c": 22.5}),
|
||
("climate.set_temperature", "passenger", {"temperature_c": 24}),
|
||
("climate.set_fan_level", "vehicle", {"level": 2}),
|
||
],
|
||
)
|
||
for token in ("主驾", "22.5度", "副驾", "24度", "2档"):
|
||
self.assertIn(token, result.assistant_text)
|
||
|
||
def test_number_correction_does_not_steal_other_device_value(self) -> None:
|
||
result = self.processor.process(
|
||
"主驾温度调到22度,不对,22.5度,然后副驾调到24度。"
|
||
)
|
||
|
||
self.assertTrue(result.execution_eligible)
|
||
temperatures = [
|
||
(command.target_scope, command.parameters["temperature_c"])
|
||
for command in result.commands
|
||
if command.capability == "climate.set_temperature"
|
||
]
|
||
self.assertEqual(temperatures, [("driver", 22.5), ("passenger", 24)])
|
||
self.assertNotIn("22度", result.assistant_text)
|
||
self.assertIn("22.5度", result.assistant_text)
|
||
self.assertIn("24度", result.assistant_text)
|
||
|
||
|
||
class SpeechTranscriberTests(unittest.TestCase):
|
||
def make_transcriber(self, directory: str, text: str) -> tuple[SpeechTranscriber, _FakeRecognizer]:
|
||
transcriber = SpeechTranscriber(Path(directory))
|
||
# Most unit calls intentionally run back-to-back. Production keeps a
|
||
# 500 ms floor, which has dedicated rate-limit coverage below.
|
||
transcriber.preview_min_interval_ms = 0
|
||
recognizer = _FakeRecognizer(text)
|
||
transcriber._recognizer = recognizer
|
||
return transcriber, recognizer
|
||
|
||
def test_transcriber_preserves_raw_and_returns_structured_result(self) -> None:
|
||
raw = "打开空调,不对,把座椅靠背向后调24度。"
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, raw)
|
||
result = transcriber.transcribe_wav(
|
||
pcm16_wav(), utterance_id="unit-structured"
|
||
)
|
||
self.assertEqual(result["raw_text"], raw)
|
||
self.assertEqual(result["clean_text"], "将座椅靠背向后调整24度。")
|
||
self.assertEqual(result["commands"][0]["domain"], "seat")
|
||
self.assertEqual(result["cancelled_fragments"][0]["commands"][0]["domain"], "climate")
|
||
self.assertIn("timings", result)
|
||
self.assertEqual(result["segments"][0]["state"], "final")
|
||
|
||
def test_filler_only_is_evidence_but_not_a_control_error(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, "嗯嗯。")
|
||
result = transcriber.transcribe_wav(
|
||
pcm16_wav(), utterance_id="unit-filler"
|
||
)
|
||
self.assertEqual(result["raw_text"], "嗯嗯。")
|
||
self.assertEqual(result["clean_text"], "")
|
||
self.assertEqual(result["commands"], [])
|
||
self.assertFalse(result["execution_eligible"])
|
||
|
||
def test_general_question_is_returned_as_assistant_text_without_control(self) -> None:
|
||
raw = "什么是CAN FD?"
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, raw)
|
||
result = transcriber.transcribe_wav(
|
||
pcm16_wav(), utterance_id="unit-general-question"
|
||
)
|
||
|
||
self.assertEqual(result["raw_text"], raw)
|
||
self.assertIn("什么是CAN FD", result["assistant_text"])
|
||
self.assertEqual(result["text"], result["assistant_text"])
|
||
self.assertEqual(result["clean_text"], "")
|
||
self.assertEqual(result["control_text"], "")
|
||
self.assertEqual(result["commands"], [])
|
||
self.assertFalse(result["execution_eligible"])
|
||
|
||
def test_final_wake_word_transcription_strips_only_the_wake_prefix(self) -> None:
|
||
captured = "你好,双十一,车上有点热。"
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, captured)
|
||
result = transcriber.transcribe_wav(
|
||
pcm16_wav(),
|
||
utterance_id="unit-wake-final",
|
||
require_wake_word=True,
|
||
)
|
||
|
||
self.assertEqual(result["captured_text"], captured)
|
||
self.assertEqual(result["raw_text"], "车上有点热。")
|
||
self.assertEqual(result["assistant_text"], "车上有点热。")
|
||
self.assertTrue(result["wake_word"]["detected"])
|
||
self.assertEqual(result["wake_word"]["remainder"], "车上有点热。")
|
||
self.assertFalse(result["execution_eligible"])
|
||
|
||
def test_final_transcription_rejects_missing_or_wake_only_audio(self) -> None:
|
||
cases = (
|
||
("车上有点热。", "wake_word_missing"),
|
||
("你好,双十一。", "empty_after_wake_word"),
|
||
)
|
||
for index, (raw, code) in enumerate(cases):
|
||
with self.subTest(raw=raw), tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, raw)
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.transcribe_wav(
|
||
pcm16_wav(),
|
||
utterance_id=f"unit-wake-rejected-{index}",
|
||
require_wake_word=True,
|
||
)
|
||
self.assertEqual(caught.exception.code, code)
|
||
|
||
def test_live_preview_is_raw_semantic_free_and_wake_prefix_aware(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(
|
||
directory, "你好双十一打开空"
|
||
)
|
||
first_audio = pcm16_wav(seconds=0.5)
|
||
first = transcriber.transcribe_preview_wav(
|
||
first_audio,
|
||
utterance_id="unit-preview-live",
|
||
sequence=0,
|
||
)
|
||
recognizer.text = "你好双十一打开空调到24.5度"
|
||
second_audio = pcm16_wav(frequency=330, seconds=0.8)
|
||
second = transcriber.transcribe_preview_wav(
|
||
second_audio,
|
||
utterance_id="unit-preview-live",
|
||
sequence=1,
|
||
)
|
||
|
||
self.assertEqual(first["state"], "partial")
|
||
self.assertEqual(first["raw_text"], "你好双十一打开空")
|
||
self.assertEqual(first["live_text"], "打开空")
|
||
self.assertFalse(first["semantic_cleaned"])
|
||
self.assertEqual(second["sequence"], 1)
|
||
self.assertEqual(second["raw_text"], "你好双十一打开空调到24.5度")
|
||
self.assertEqual(second["live_text"], "打开空调到24.5度")
|
||
self.assertTrue(second["wake_word"]["detected"])
|
||
self.assertEqual(recognizer.decode_count, 2)
|
||
|
||
def test_live_preview_duplicate_stale_and_conflicting_sequences_are_safe(self) -> None:
|
||
first_audio = pcm16_wav(seconds=0.5)
|
||
second_audio = pcm16_wav(frequency=330, seconds=0.8)
|
||
conflicting_audio = pcm16_wav(frequency=440, seconds=0.8)
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(
|
||
directory, "你好双十一打开空调"
|
||
)
|
||
first = transcriber.transcribe_preview_wav(
|
||
first_audio,
|
||
utterance_id="unit-preview-order",
|
||
sequence=0,
|
||
)
|
||
recognizer.text = "你好双十一打开空调到24度"
|
||
latest = transcriber.transcribe_preview_wav(
|
||
second_audio,
|
||
utterance_id="unit-preview-order",
|
||
sequence=1,
|
||
)
|
||
duplicate = transcriber.transcribe_preview_wav(
|
||
second_audio,
|
||
utterance_id="unit-preview-order",
|
||
sequence=1,
|
||
)
|
||
stale = transcriber.transcribe_preview_wav(
|
||
first_audio,
|
||
utterance_id="unit-preview-order",
|
||
sequence=0,
|
||
)
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.transcribe_preview_wav(
|
||
conflicting_audio,
|
||
utterance_id="unit-preview-order",
|
||
sequence=1,
|
||
)
|
||
|
||
self.assertFalse(first["idempotent_replay"])
|
||
self.assertFalse(latest["stale"])
|
||
self.assertTrue(duplicate["idempotent_replay"])
|
||
self.assertFalse(duplicate["stale"])
|
||
self.assertEqual(duplicate["live_text"], "打开空调到24度")
|
||
self.assertTrue(stale["stale"])
|
||
self.assertTrue(stale["idempotent_replay"])
|
||
self.assertEqual(stale["requested_sequence"], 0)
|
||
self.assertEqual(stale["sequence"], 1)
|
||
for sensitive_key in ("raw_text", "text", "live_text", "wake_word"):
|
||
self.assertNotIn(sensitive_key, stale)
|
||
self.assertEqual(caught.exception.code, "preview_sequence_conflict")
|
||
self.assertEqual(recognizer.decode_count, 2)
|
||
|
||
def test_concurrent_identical_preview_decodes_once_and_inflight_conflict_is_409(
|
||
self,
|
||
) -> None:
|
||
audio = pcm16_wav(seconds=0.5)
|
||
conflicting_audio = pcm16_wav(frequency=440, seconds=0.5)
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber = SpeechTranscriber(Path(directory))
|
||
transcriber.preview_min_interval_ms = 0
|
||
recognizer = _BlockingRecognizer("你好双十一打开空调")
|
||
transcriber._recognizer = recognizer
|
||
waiter_entered = threading.Event()
|
||
original_wait = transcriber._wait_for_preview
|
||
|
||
def observed_wait(inflight: object) -> dict[str, object]:
|
||
waiter_entered.set()
|
||
return original_wait(inflight) # type: ignore[arg-type]
|
||
|
||
transcriber._wait_for_preview = observed_wait # type: ignore[method-assign]
|
||
results: list[dict[str, object]] = []
|
||
failures: list[BaseException] = []
|
||
|
||
def request() -> None:
|
||
try:
|
||
results.append(
|
||
transcriber.transcribe_preview_wav(
|
||
audio,
|
||
utterance_id="unit-preview-concurrent",
|
||
sequence=3,
|
||
)
|
||
)
|
||
except Exception as exc: # noqa: BLE001 # pragma: no cover - assertion aid
|
||
failures.append(exc)
|
||
|
||
owner = threading.Thread(target=request)
|
||
owner.start()
|
||
self.assertTrue(recognizer.decode_started.wait(timeout=2))
|
||
with self.assertRaises(SpeechRecognitionError) as conflict:
|
||
transcriber.transcribe_preview_wav(
|
||
conflicting_audio,
|
||
utterance_id="unit-preview-concurrent",
|
||
sequence=3,
|
||
)
|
||
waiter = threading.Thread(target=request)
|
||
waiter.start()
|
||
self.assertTrue(waiter_entered.wait(timeout=2))
|
||
recognizer.release_decode.set()
|
||
owner.join(timeout=2)
|
||
waiter.join(timeout=2)
|
||
|
||
self.assertFalse(owner.is_alive())
|
||
self.assertFalse(waiter.is_alive())
|
||
self.assertEqual(conflict.exception.code, "preview_sequence_conflict")
|
||
self.assertEqual(failures, [])
|
||
self.assertEqual(recognizer.decode_count, 1)
|
||
self.assertEqual(len(results), 2)
|
||
self.assertEqual(
|
||
sorted(bool(item["idempotent_replay"]) for item in results),
|
||
[False, True],
|
||
)
|
||
self.assertEqual({item["live_text"] for item in results}, {"打开空调"})
|
||
|
||
def test_preview_global_capacity_rejects_immediately(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(
|
||
directory, "你好双十一打开空调"
|
||
)
|
||
transcriber._preview_request_slots = threading.BoundedSemaphore(1)
|
||
self.assertTrue(transcriber._preview_request_slots.acquire(blocking=False))
|
||
started = time.perf_counter()
|
||
try:
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=0.5),
|
||
utterance_id="unit-preview-overload",
|
||
sequence=0,
|
||
)
|
||
finally:
|
||
transcriber._preview_request_slots.release()
|
||
|
||
self.assertLess(time.perf_counter() - started, 0.2)
|
||
self.assertEqual(caught.exception.code, "preview_overloaded")
|
||
self.assertEqual(caught.exception.status, 429)
|
||
self.assertEqual(caught.exception.retry_after_seconds, 1)
|
||
self.assertEqual(recognizer.decode_count, 0)
|
||
|
||
def test_preview_per_utterance_interval_and_update_limit_are_enforced(self) -> None:
|
||
audio = pcm16_wav(seconds=0.5)
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
throttled, _ = self.make_transcriber(
|
||
directory, "你好双十一打开空调"
|
||
)
|
||
throttled.preview_min_interval_ms = 10_000
|
||
throttled.transcribe_preview_wav(
|
||
audio, utterance_id="unit-preview-throttle", sequence=0
|
||
)
|
||
with self.assertRaises(SpeechRecognitionError) as rate_limited:
|
||
throttled.transcribe_preview_wav(
|
||
pcm16_wav(frequency=330, seconds=0.6),
|
||
utterance_id="unit-preview-throttle",
|
||
sequence=1,
|
||
)
|
||
|
||
limited, _ = self.make_transcriber(directory, "你好双十一打开空调")
|
||
limited.preview_max_updates = 1
|
||
limited.transcribe_preview_wav(
|
||
audio, utterance_id="unit-preview-count", sequence=0
|
||
)
|
||
with self.assertRaises(SpeechRecognitionError) as count_limited:
|
||
limited.transcribe_preview_wav(
|
||
pcm16_wav(frequency=330, seconds=0.6),
|
||
utterance_id="unit-preview-count",
|
||
sequence=1,
|
||
)
|
||
|
||
self.assertEqual(rate_limited.exception.code, "preview_rate_limited")
|
||
self.assertEqual(rate_limited.exception.status, 429)
|
||
self.assertGreaterEqual(rate_limited.exception.retry_after_seconds or 0, 1)
|
||
self.assertEqual(count_limited.exception.code, "preview_limit_reached")
|
||
self.assertEqual(count_limited.exception.status, 429)
|
||
|
||
def test_windowed_preview_returns_complete_accumulated_live_text(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(
|
||
directory, "你好双十一打开空调"
|
||
)
|
||
transcriber.preview_window_ms = 400
|
||
first = transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=0.3),
|
||
utterance_id="unit-preview-window",
|
||
sequence=0,
|
||
)
|
||
# The first tail window may begin inside the spoken wake phrase.
|
||
# Its remainder must not be appended to the assistant input.
|
||
recognizer.text = "好双十一打开空调到24度然后打开遮阳帘"
|
||
second = transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=1.2),
|
||
utterance_id="unit-preview-window",
|
||
sequence=1,
|
||
)
|
||
|
||
self.assertEqual(first["live_text"], "打开空调")
|
||
self.assertEqual(
|
||
second["raw_text"], "好双十一打开空调到24度然后打开遮阳帘"
|
||
)
|
||
self.assertEqual(second["live_text"], "打开空调到24度然后打开遮阳帘")
|
||
self.assertTrue(second["wake_word"]["detected"])
|
||
self.assertTrue(second["wake_word"]["latched"])
|
||
self.assertEqual(
|
||
second["wake_word"]["remainder"],
|
||
second["live_text"],
|
||
)
|
||
self.assertEqual(recognizer.decoded_sample_counts, [4_800, 6_400])
|
||
self.assertTrue(second["preview_window"]["bounded"])
|
||
self.assertEqual(second["preview_window"]["start_ms"], 800)
|
||
self.assertTrue(second["preview_window"]["complete_live_text"])
|
||
|
||
def test_unawakened_fixed_prefix_is_not_decoded_repeatedly(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(directory, "普通环境声音")
|
||
transcriber.preview_window_ms = 400
|
||
first = transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=0.6),
|
||
utterance_id="unit-preview-unawakened-prefix",
|
||
sequence=0,
|
||
)
|
||
recognizer.text = "这段内容不应被再次解码"
|
||
second = transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=0.9),
|
||
utterance_id="unit-preview-unawakened-prefix",
|
||
sequence=1,
|
||
)
|
||
|
||
self.assertFalse(first["wake_word"]["detected"])
|
||
self.assertEqual(first["live_text"], "")
|
||
self.assertEqual(second["live_text"], "")
|
||
self.assertEqual(second["raw_text"], "普通环境声音")
|
||
self.assertEqual(second["duration_ms"], 900)
|
||
self.assertTrue(second["preview_window"]["reused"])
|
||
self.assertEqual(recognizer.decode_count, 1)
|
||
|
||
def test_final_transcription_has_priority_over_new_preview_work(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber = SpeechTranscriber(Path(directory))
|
||
transcriber.preview_min_interval_ms = 0
|
||
recognizer = _BlockingRecognizer("什么是CAN FD?")
|
||
transcriber._recognizer = recognizer
|
||
final_failures: list[BaseException] = []
|
||
|
||
def finalize() -> None:
|
||
try:
|
||
transcriber.transcribe_wav(
|
||
pcm16_wav(), utterance_id="unit-final-priority"
|
||
)
|
||
except Exception as exc: # noqa: BLE001 # pragma: no cover - assertion aid
|
||
final_failures.append(exc)
|
||
|
||
final_thread = threading.Thread(target=finalize)
|
||
final_thread.start()
|
||
self.assertTrue(recognizer.decode_started.wait(timeout=2))
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=0.5),
|
||
utterance_id="unit-preview-during-final",
|
||
sequence=0,
|
||
)
|
||
recognizer.release_decode.set()
|
||
final_thread.join(timeout=2)
|
||
|
||
self.assertFalse(final_thread.is_alive())
|
||
self.assertEqual(final_failures, [])
|
||
self.assertEqual(caught.exception.code, "preview_finalizing")
|
||
self.assertEqual(caught.exception.status, 429)
|
||
self.assertEqual(recognizer.decode_count, 1)
|
||
|
||
def test_background_warmup_is_nonblocking_and_reports_loading_ready_error(
|
||
self,
|
||
) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber = SpeechTranscriber(Path(directory))
|
||
recognizer = _FakeRecognizer("ready")
|
||
loading = threading.Event()
|
||
release = threading.Event()
|
||
|
||
def delayed_load() -> _FakeRecognizer:
|
||
loading.set()
|
||
if not release.wait(timeout=5):
|
||
raise RuntimeError("test warmup was not released")
|
||
transcriber._recognizer = recognizer
|
||
return recognizer
|
||
|
||
transcriber._get_recognizer = delayed_load # type: ignore[method-assign]
|
||
started = time.perf_counter()
|
||
transcriber.start()
|
||
self.assertLess(time.perf_counter() - started, 0.2)
|
||
self.assertTrue(loading.wait(timeout=2))
|
||
self.assertEqual(transcriber.status()["load_state"], "loading")
|
||
release.set()
|
||
assert transcriber._warmup_thread is not None
|
||
transcriber._warmup_thread.join(timeout=2)
|
||
ready_status = transcriber.status()
|
||
|
||
failed = SpeechTranscriber(Path(directory))
|
||
|
||
def failed_load() -> object:
|
||
raise SpeechRecognitionError(
|
||
"model_unavailable", "model missing", status=503
|
||
)
|
||
|
||
failed._get_recognizer = failed_load # type: ignore[method-assign]
|
||
failed.start()
|
||
assert failed._warmup_thread is not None
|
||
failed._warmup_thread.join(timeout=2)
|
||
failed_status = failed.status()
|
||
|
||
self.assertFalse(transcriber._warmup_thread.is_alive())
|
||
self.assertEqual(ready_status["load_state"], "ready")
|
||
self.assertTrue(ready_status["loaded"])
|
||
self.assertEqual(ready_status["warmup"]["state"], "ready")
|
||
self.assertFalse(failed._warmup_thread.is_alive())
|
||
self.assertEqual(failed_status["load_state"], "error")
|
||
self.assertEqual(failed_status["warmup"]["error"], "model_unavailable")
|
||
|
||
def test_preview_without_wake_word_never_exposes_live_assistant_text(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, "打开空调")
|
||
result = transcriber.transcribe_preview_wav(
|
||
pcm16_wav(seconds=0.5),
|
||
utterance_id="unit-preview-not-awake",
|
||
sequence=0,
|
||
)
|
||
|
||
self.assertEqual(result["raw_text"], "打开空调")
|
||
self.assertEqual(result["live_text"], "")
|
||
self.assertFalse(result["wake_word"]["detected"])
|
||
|
||
def test_preview_and_final_can_share_utterance_without_cache_collision(self) -> None:
|
||
audio = pcm16_wav(seconds=0.8)
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(
|
||
directory, "你好双十一打开空"
|
||
)
|
||
preview = transcriber.transcribe_preview_wav(
|
||
audio,
|
||
utterance_id="unit-preview-final",
|
||
sequence=0,
|
||
)
|
||
recognizer.text = "你好双十一打开空调"
|
||
final = transcriber.transcribe_wav(
|
||
audio,
|
||
utterance_id="unit-preview-final",
|
||
require_wake_word=True,
|
||
)
|
||
|
||
self.assertEqual(preview["state"], "partial")
|
||
self.assertEqual(final["raw_text"], "打开空调")
|
||
self.assertEqual(final["clean_text"], "打开空调。")
|
||
self.assertEqual(final["assistant_text"], "打开空调。")
|
||
self.assertEqual(recognizer.decode_count, 2)
|
||
|
||
def test_same_utterance_and_audio_is_decoded_once(self) -> None:
|
||
audio = pcm16_wav()
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, recognizer = self.make_transcriber(directory, "打开空调。")
|
||
first = transcriber.transcribe_wav(audio, utterance_id="unit-idempotent")
|
||
second = transcriber.transcribe_wav(audio, utterance_id="unit-idempotent")
|
||
self.assertEqual(recognizer.decode_count, 1)
|
||
self.assertFalse(first["idempotent_replay"])
|
||
self.assertTrue(second["idempotent_replay"])
|
||
|
||
def test_cached_result_is_server_owned_and_returned_as_a_copy(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, "打开空调。")
|
||
transcriber.transcribe_wav(pcm16_wav(), utterance_id="unit-cached")
|
||
first = transcriber.cached_result("unit-cached")
|
||
first["clean_text"] = "tampered"
|
||
second = transcriber.cached_result("unit-cached")
|
||
self.assertNotEqual(second["clean_text"], "tampered")
|
||
|
||
def test_missing_cached_result_is_rejected(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, "打开空调。")
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.cached_result("unit-expired")
|
||
self.assertEqual(caught.exception.code, "speech_result_expired")
|
||
|
||
def test_same_utterance_with_different_audio_is_rejected(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, "打开空调。")
|
||
transcriber.transcribe_wav(pcm16_wav(), utterance_id="unit-conflict")
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.transcribe_wav(
|
||
pcm16_wav(frequency=330), utterance_id="unit-conflict"
|
||
)
|
||
self.assertEqual(caught.exception.code, "utterance_id_conflict")
|
||
|
||
def test_invalid_utterance_id_is_rejected(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
transcriber, _ = self.make_transcriber(directory, "打开空调。")
|
||
with self.assertRaises(SpeechRecognitionError) as caught:
|
||
transcriber.transcribe_wav(pcm16_wav(), utterance_id="bad id/路径")
|
||
self.assertEqual(caught.exception.code, "invalid_utterance_id")
|
||
|
||
|
||
class WakeWordMatcherTests(unittest.TestCase):
|
||
def test_default_phrase_accepts_punctuation_spacing_and_numeric_alias(self) -> None:
|
||
matcher = WakeWordMatcher(enabled=True)
|
||
cases = (
|
||
("你好,双十一,打开空调", "打开空调"),
|
||
("你好双十一打开空调", "打开空调"),
|
||
("嗯, 你好 双 11,打开空调", "打开空调"),
|
||
("请,你好:双十一。什么是CAN FD", "什么是CAN FD"),
|
||
)
|
||
for text, remainder in cases:
|
||
with self.subTest(text=text):
|
||
result = matcher.match(text)
|
||
self.assertTrue(result.detected)
|
||
self.assertEqual(result.remainder, remainder)
|
||
|
||
def test_wake_phrase_is_anchored_to_the_start_and_avoids_false_positives(self) -> None:
|
||
matcher = WakeWordMatcher(enabled=True)
|
||
for text in (
|
||
"有人说你好双十一会唤醒吗",
|
||
"双十一购物节有什么活动",
|
||
"你好小十一打开空调",
|
||
"你好十一打开空调",
|
||
):
|
||
with self.subTest(text=text):
|
||
result = matcher.match(text)
|
||
self.assertFalse(result.detected)
|
||
self.assertEqual(result.remainder, "")
|
||
|
||
def test_wake_phrase_recovers_common_self_repetition_without_leaking_it(self) -> None:
|
||
matcher = WakeWordMatcher(enabled=True)
|
||
for text in (
|
||
"你你好双十一打开空调",
|
||
"你好你好双十一打开空调",
|
||
):
|
||
with self.subTest(text=text):
|
||
result = matcher.match(text)
|
||
self.assertTrue(result.detected)
|
||
self.assertEqual(result.remainder, "打开空调")
|
||
|
||
def test_disabled_matcher_preserves_input_without_activating(self) -> None:
|
||
matcher = WakeWordMatcher(enabled=False)
|
||
result = matcher.match("你好,双十一,打开空调")
|
||
self.assertFalse(result.detected)
|
||
self.assertEqual(result.remainder, "你好,双十一,打开空调")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|