Files
Cognitive_Agent/tests/unit/test_speech_transcription.py
T

940 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.application.mode_profile_intent import (
is_mode_profile_query,
parse_mode_profile_create,
parse_mode_profile_update,
)
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_spoken_mode_creation_variants_keep_the_canonical_anchor_first(self) -> None:
cases = (
(
"我想创建一个观影模式,把空调温度调到24度,遮阳帘变成100%",
"观影模式",
("climate.set_temperature", "roof.set_shade_position"),
),
(
"请你帮我构建一套影院模式,副驾空调调到22.5度,车窗关闭",
"影院模式",
("climate.set_temperature", "window.set_motion"),
),
)
for raw, expected_name, expected_capabilities in cases:
with self.subTest(raw=raw):
result = self.processor.process(raw)
parsed = parse_mode_profile_create(result.assistant_text)
self.assertIsNotNone(parsed)
self.assertEqual(parsed[0], expected_name)
self.assertLess(
result.assistant_text.index(expected_name),
result.assistant_text.index("空调"),
)
self.assertEqual(
tuple(command.capability for command in result.commands),
expected_capabilities,
)
self.assertFalse(result.execution_eligible)
def test_mode_draft_update_keeps_name_correction_and_multiple_actions(self) -> None:
result = self.processor.process(
"把观影模式的空调调到24度,不对,25度,然后把遮阳帘变成100%"
)
parsed = parse_mode_profile_update(result.assistant_text)
self.assertIsNotNone(parsed)
self.assertEqual(parsed[0], "观影模式")
self.assertNotIn("24度", result.assistant_text)
self.assertIn("25度", result.assistant_text)
self.assertIn("遮阳帘", result.assistant_text)
self.assertEqual(
[command.capability for command in result.commands],
["climate.set_temperature", "roof.set_shade_position"],
)
self.assertEqual(result.commands[0].parameters, {"temperature_c": 25})
self.assertEqual(result.commands[1].parameters, {"position_pct": 100})
self.assertFalse(result.execution_eligible)
def test_mode_query_without_question_mark_is_never_rendered_as_control(self) -> None:
raw = "观影模式里遮阳帘是0%还是100%"
result = self.processor.process(raw)
self.assertTrue(is_mode_profile_query(result.assistant_text))
self.assertIn("还是", result.assistant_text)
self.assertFalse(result.execution_eligible)
self.assertNotEqual(result.assistant_text, "将遮阳帘打开到0%。")
def test_negated_or_cancelled_mode_update_never_becomes_executable(self) -> None:
cases = (
"把观影模式的遮阳帘变成100%,但不要执行",
"把观影模式的遮阳帘变成100%,算了",
"不要把观影模式的遮阳帘变成100%",
)
for raw in cases:
with self.subTest(raw=raw):
result = self.processor.process(raw)
self.assertFalse(result.execution_eligible)
self.assertTrue(
any(
marker in result.assistant_text
for marker in ("不要", "算了")
)
)
if "不要执行" in raw:
self.assertIn("观影模式", result.assistant_text)
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_and_spacing(self) -> None:
matcher = WakeWordMatcher(enabled=True)
self.assertEqual(matcher.primary, "你好,小迪")
self.assertEqual(matcher.phrases, ("你好,小迪",))
cases = (
("你好,小迪,打开空调", "打开空调"),
("你好小迪打开空调", "打开空调"),
("嗯, 你好 小 迪,打开空调", "打开空调"),
("请,你好:小迪。什么是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()