[主说话人端点]:完成音色消失结束录音,包含临时音色画像、端点配置和回归测试
This commit is contained in:
@@ -22,9 +22,10 @@ from owner_voice_pet.events import (
|
||||
from owner_voice_pet.llm import MockLlmProvider
|
||||
from owner_voice_pet.models import AudioFrame, AudioSegment, Transcript
|
||||
from owner_voice_pet.assistant_pipeline import VoiceAssistantPipeline
|
||||
from owner_voice_pet.runtime import build_live_runtime
|
||||
from owner_voice_pet.transport import MemoryAudioTransport
|
||||
from owner_voice_pet.tts import SineTtsProvider
|
||||
from owner_voice_pet.vad import EnergyVadProvider, VadRecorder
|
||||
from owner_voice_pet.vad import EnergyVadProvider, PrimarySpeakerVadRecorder, VadRecorder
|
||||
from owner_voice_pet.wakeword import KeywordWakeWordProvider
|
||||
|
||||
|
||||
@@ -181,6 +182,10 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
self.assertEqual(llm.calls[0][-1].content, "第一问")
|
||||
self.assertNotIn("小杰小杰", llm.calls[0][-1].content)
|
||||
|
||||
def test_live_runtime_uses_primary_speaker_endpoint_by_default(self) -> None:
|
||||
runtime = build_live_runtime(AppConfig(llm_api_key="secret"))
|
||||
self.assertIsInstance(runtime.vad_recorder, PrimarySpeakerVadRecorder)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -74,6 +74,12 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
self.assertEqual(config.wake_kws_score, 1.0)
|
||||
self.assertEqual(config.wake_ack_text, "我在")
|
||||
self.assertEqual(config.post_playback_drain_ms, 50)
|
||||
self.assertEqual(config.pipeline_mode, "live_turn_based")
|
||||
self.assertEqual(config.endpoint_mode, "primary_speaker")
|
||||
self.assertEqual(config.speaker_profile_ms, 600)
|
||||
self.assertEqual(config.speaker_absent_ms, 300)
|
||||
self.assertEqual(config.speaker_similarity_threshold, 0.70)
|
||||
self.assertEqual(config.speaker_min_rms, 0.012)
|
||||
self.assertEqual(config.vad_provider, "hybrid")
|
||||
self.assertEqual(config.vad_threshold, 0.5)
|
||||
self.assertEqual(config.vad_min_duration_ms, 250)
|
||||
@@ -85,6 +91,7 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
self.assertEqual(config.tts_model, "mimo-v2.5-tts")
|
||||
self.assertEqual(config.tts_voice, "mimo_default")
|
||||
self.assertEqual(str(config.speech_models_dir), "models")
|
||||
self.assertEqual(config.context_mode, "session_memory")
|
||||
self.assertTrue(config.llm_stream)
|
||||
self.assertEqual(config.validate_basic(), [])
|
||||
|
||||
@@ -103,6 +110,16 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
errors = config.validate_basic()
|
||||
self.assertTrue(any("OWNER_VAD_PROVIDER" in error.message for error in errors))
|
||||
|
||||
def test_endpoint_provider_must_be_primary_speaker_or_vad(self) -> None:
|
||||
config = AppConfig(endpoint_mode="invalid")
|
||||
errors = config.validate_basic()
|
||||
self.assertTrue(any("OWNER_ENDPOINT_MODE" in error.message for error in errors))
|
||||
|
||||
def test_speaker_similarity_threshold_range_is_validated(self) -> None:
|
||||
config = AppConfig(speaker_similarity_threshold=1.5)
|
||||
errors = config.validate_basic()
|
||||
self.assertTrue(any("OWNER_SPEAKER_SIMILARITY_THRESHOLD" in error.message for error in errors))
|
||||
|
||||
def test_missing_dotenv_uses_non_secret_defaults(self) -> None:
|
||||
config = AppConfig.from_dotenv("/tmp/owner-voice-pet-missing.env")
|
||||
self.assertEqual(config.llm_base_url, "https://token-plan-cn.xiaomimimo.com/v1")
|
||||
|
||||
@@ -7,7 +7,7 @@ import unittest
|
||||
from owner_voice_pet.models import AudioFrame, AudioSegment, ErrorCode, ProviderError
|
||||
from owner_voice_pet.config import AppConfig
|
||||
from owner_voice_pet.stt import CloudAsrSttProvider, MetadataSttProvider, SherpaOnnxSttProvider, is_valid_transcript_text
|
||||
from owner_voice_pet.vad import EnergyVadProvider, HybridVadProvider, VadRecorder
|
||||
from owner_voice_pet.vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, VadRecorder
|
||||
from owner_voice_pet.wakeword import (
|
||||
KeywordWakeWordProvider,
|
||||
MissingWakeWordModelProvider,
|
||||
@@ -111,6 +111,82 @@ class WakeVadSttTests(unittest.TestCase):
|
||||
self.assertTrue(result.is_speech)
|
||||
self.assertEqual(result.speech_ms, 20)
|
||||
|
||||
def test_primary_speaker_endpoint_stops_on_background_noise(self) -> None:
|
||||
provider = EnergyVadProvider()
|
||||
provider.load()
|
||||
recorder = PrimarySpeakerVadRecorder(
|
||||
provider,
|
||||
min_duration_ms=40,
|
||||
end_silence_ms=1000,
|
||||
speaker_profile_ms=40,
|
||||
speaker_absent_ms=40,
|
||||
)
|
||||
frames = [
|
||||
make_frame(1, 0, speech=True, metadata={"speaker_id": "owner", "transcript": "你是谁"}),
|
||||
make_frame(2, 20, speech=True, metadata={"speaker_id": "owner"}),
|
||||
make_frame(3, 40, speech=True, metadata={"speaker_id": "background"}),
|
||||
make_frame(4, 60, speech=True, metadata={"speaker_id": "background"}),
|
||||
make_frame(5, 80, speech=True, metadata={"speaker_id": "owner", "transcript": "你是谁第二次"}),
|
||||
]
|
||||
segment = None
|
||||
consumed = 0
|
||||
for item in frames:
|
||||
consumed += 1
|
||||
result = recorder.feed(item)
|
||||
if isinstance(result, AudioSegment):
|
||||
segment = result
|
||||
break
|
||||
self.assertIsNotNone(segment)
|
||||
assert segment is not None
|
||||
self.assertEqual(segment.metadata["end_reason"], "primary_speaker_absent")
|
||||
self.assertEqual(consumed, 4)
|
||||
self.assertEqual(segment.metadata["transcript"], "你是谁")
|
||||
|
||||
def test_primary_speaker_endpoint_allows_short_pause(self) -> None:
|
||||
provider = EnergyVadProvider()
|
||||
provider.load()
|
||||
recorder = PrimarySpeakerVadRecorder(
|
||||
provider,
|
||||
min_duration_ms=40,
|
||||
end_silence_ms=1000,
|
||||
speaker_profile_ms=40,
|
||||
speaker_absent_ms=60,
|
||||
)
|
||||
frames = [
|
||||
make_frame(1, 0, speech=True, metadata={"speaker_id": "owner"}),
|
||||
make_frame(2, 20, speech=True, metadata={"speaker_id": "owner"}),
|
||||
make_frame(3, 40, speech=False),
|
||||
make_frame(4, 60, speech=True, metadata={"speaker_id": "owner"}),
|
||||
make_frame(5, 80, speech=False),
|
||||
make_frame(6, 100, speech=False),
|
||||
make_frame(7, 120, speech=False),
|
||||
]
|
||||
results = [recorder.feed(item) for item in frames]
|
||||
self.assertIsNone(results[2])
|
||||
self.assertIsNone(results[3])
|
||||
self.assertIsInstance(results[-1], AudioSegment)
|
||||
assert isinstance(results[-1], AudioSegment)
|
||||
self.assertEqual(results[-1].metadata["end_reason"], "primary_speaker_absent")
|
||||
|
||||
def test_primary_speaker_endpoint_falls_back_to_vad_when_profile_missing(self) -> None:
|
||||
provider = EnergyVadProvider()
|
||||
provider.load()
|
||||
recorder = PrimarySpeakerVadRecorder(provider, min_duration_ms=40, end_silence_ms=40)
|
||||
frames = [
|
||||
AudioFrame(b"", 16000, 1, 0, 1, {"duration_ms": 20, "speech": True, "transcript": "你好"}),
|
||||
AudioFrame(b"", 16000, 1, 20, 2, {"duration_ms": 20, "speech": True}),
|
||||
make_frame(3, 40, speech=False),
|
||||
make_frame(4, 60, speech=False),
|
||||
]
|
||||
segment = None
|
||||
for item in frames:
|
||||
result = recorder.feed(item)
|
||||
if isinstance(result, AudioSegment):
|
||||
segment = result
|
||||
self.assertIsNotNone(segment)
|
||||
assert segment is not None
|
||||
self.assertEqual(segment.metadata["end_reason"], "silence")
|
||||
|
||||
def test_metadata_stt_transcribes_fixture_text(self) -> None:
|
||||
provider = MetadataSttProvider()
|
||||
provider.load()
|
||||
|
||||
Reference in New Issue
Block a user