feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -4,6 +4,8 @@ import io
|
||||
import json
|
||||
import math
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import wave
|
||||
from pathlib import Path
|
||||
@@ -16,6 +18,7 @@ from cognitive_agent.infrastructure.speech.sensevoice import (
|
||||
from cognitive_agent.infrastructure.speech.understanding import (
|
||||
UnderstandingTranscriptProcessor,
|
||||
)
|
||||
from cognitive_agent.infrastructure.speech.wake_word import WakeWordMatcher
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -52,12 +55,27 @@ 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):
|
||||
@@ -73,6 +91,14 @@ class UnderstandingRegressionTests(unittest.TestCase):
|
||||
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"],
|
||||
@@ -109,10 +135,175 @@ class UnderstandingRegressionTests(unittest.TestCase):
|
||||
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
|
||||
@@ -142,6 +333,433 @@ class SpeechTranscriberTests(unittest.TestCase):
|
||||
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:
|
||||
@@ -186,5 +804,51 @@ class SpeechTranscriberTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user