[异步播报打断]:完成播放中麦克风监听和音色隔离,包含后台监听、助手回放抑制和用户音色打断测试
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from owner_voice_pet.barge_in import AsyncBargeInMonitor, BargeInSpeakerGate
|
||||
from owner_voice_pet.models import AudioFrame, AudioSegment
|
||||
from owner_voice_pet.stt import MetadataSttProvider
|
||||
from owner_voice_pet.transport import MemoryAudioTransport
|
||||
from owner_voice_pet.vad import EnergyVadProvider
|
||||
|
||||
|
||||
def speech_frame(idx: int, speaker_id: str, partial: str = "等一下") -> AudioFrame:
|
||||
return AudioFrame(
|
||||
b"\xff\x7f" * 320,
|
||||
16000,
|
||||
1,
|
||||
idx * 20,
|
||||
idx,
|
||||
{
|
||||
"duration_ms": 20,
|
||||
"speech": True,
|
||||
"speaker_id": speaker_id,
|
||||
"partial_transcript": partial,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def segment_for_speaker(speaker_id: str) -> AudioSegment:
|
||||
return AudioSegment(
|
||||
b"\xff\x7f" * 640,
|
||||
16000,
|
||||
1,
|
||||
0,
|
||||
40,
|
||||
{"speaker_id": speaker_id},
|
||||
)
|
||||
|
||||
|
||||
class BargeInTests(unittest.TestCase):
|
||||
def test_async_monitor_ignores_assistant_playback_voice(self) -> None:
|
||||
gate = BargeInSpeakerGate(
|
||||
enabled=True,
|
||||
user_similarity_threshold=0.62,
|
||||
assistant_reject_threshold=0.72,
|
||||
min_rms=0.001,
|
||||
)
|
||||
gate.remember_user_segment(segment_for_speaker("owner"))
|
||||
transport = MemoryAudioTransport([speech_frame(1, "assistant"), speech_frame(2, "assistant")])
|
||||
transport.start_input()
|
||||
monitor = AsyncBargeInMonitor(
|
||||
transport=transport,
|
||||
vad_provider=EnergyVadProvider(threshold=1),
|
||||
realtime_stt=MetadataSttProvider(),
|
||||
speaker_gate=gate,
|
||||
assistant_profile=gate.assistant_profile(segment_for_speaker("assistant")),
|
||||
echo_guard_ms=0,
|
||||
min_speech_ms=40,
|
||||
listen_interval_ms=1,
|
||||
)
|
||||
monitor.vad_provider.load()
|
||||
|
||||
monitor.start()
|
||||
time.sleep(0.05)
|
||||
monitor.stop()
|
||||
|
||||
self.assertFalse(monitor.interrupted)
|
||||
self.assertEqual(monitor.pending_frames(), [])
|
||||
|
||||
def test_async_monitor_accepts_user_voice_and_buffers_frames(self) -> None:
|
||||
gate = BargeInSpeakerGate(
|
||||
enabled=True,
|
||||
user_similarity_threshold=0.62,
|
||||
assistant_reject_threshold=0.72,
|
||||
min_rms=0.001,
|
||||
)
|
||||
gate.remember_user_segment(segment_for_speaker("owner"))
|
||||
transport = MemoryAudioTransport([speech_frame(1, "owner"), speech_frame(2, "owner")])
|
||||
transport.start_input()
|
||||
monitor = AsyncBargeInMonitor(
|
||||
transport=transport,
|
||||
vad_provider=EnergyVadProvider(threshold=1),
|
||||
realtime_stt=MetadataSttProvider(),
|
||||
speaker_gate=gate,
|
||||
assistant_profile=gate.assistant_profile(segment_for_speaker("assistant")),
|
||||
echo_guard_ms=0,
|
||||
min_speech_ms=40,
|
||||
listen_interval_ms=1,
|
||||
)
|
||||
monitor.vad_provider.load()
|
||||
|
||||
monitor.start()
|
||||
deadline = time.monotonic() + 1
|
||||
while not monitor.interrupted and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
monitor.stop()
|
||||
|
||||
self.assertTrue(monitor.interrupted)
|
||||
self.assertEqual([frame.metadata["speaker_id"] for frame in monitor.pending_frames()], ["owner", "owner"])
|
||||
|
||||
def test_user_profile_missing_falls_back_to_non_assistant_with_partial(self) -> None:
|
||||
gate = BargeInSpeakerGate(
|
||||
enabled=True,
|
||||
user_similarity_threshold=0.62,
|
||||
assistant_reject_threshold=0.72,
|
||||
min_rms=0.001,
|
||||
)
|
||||
transport = MemoryAudioTransport([speech_frame(1, "guest"), speech_frame(2, "guest")])
|
||||
transport.start_input()
|
||||
monitor = AsyncBargeInMonitor(
|
||||
transport=transport,
|
||||
vad_provider=EnergyVadProvider(threshold=1),
|
||||
realtime_stt=MetadataSttProvider(),
|
||||
speaker_gate=gate,
|
||||
assistant_profile=gate.assistant_profile(segment_for_speaker("assistant")),
|
||||
echo_guard_ms=0,
|
||||
min_speech_ms=40,
|
||||
listen_interval_ms=1,
|
||||
)
|
||||
monitor.vad_provider.load()
|
||||
|
||||
monitor.start()
|
||||
deadline = time.monotonic() + 1
|
||||
while not monitor.interrupted and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
monitor.stop()
|
||||
|
||||
self.assertTrue(monitor.interrupted)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from owner_voice_pet.config import AppConfig
|
||||
@@ -238,6 +239,7 @@ class PlaybackInjectedTransport(MemoryAudioTransport):
|
||||
if self.play_count == self.inject_after_play_count:
|
||||
for frame in self.injected_frames:
|
||||
self.inject(frame)
|
||||
time.sleep(0.002)
|
||||
return result
|
||||
|
||||
def health(self) -> TransportHealth:
|
||||
@@ -649,15 +651,41 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
|
||||
def test_barge_in_interrupts_playback_and_keeps_only_spoken_context(self) -> None:
|
||||
first_question_frames = [wake_frame(0, 0)]
|
||||
first_question_frames.extend(segment_frames(1, 20, partials=["第一问", "第一问"]))
|
||||
first_question_frames.extend(
|
||||
[
|
||||
AudioFrame(
|
||||
frame.pcm,
|
||||
frame.sample_rate,
|
||||
frame.channels,
|
||||
frame.timestamp_ms,
|
||||
frame.frame_id,
|
||||
{**dict(frame.metadata), "speaker_id": "owner"},
|
||||
)
|
||||
for frame in segment_frames(1, 20, partials=["第一问", "第一问"])
|
||||
]
|
||||
)
|
||||
barge_frames = [
|
||||
AudioFrame(b"\xff\x7f", 16000, 1, 600, 20, {"duration_ms": 20, "speech": True, "partial_transcript": "等一下"}),
|
||||
AudioFrame(b"\xff\x7f", 16000, 1, 620, 21, {"duration_ms": 20, "speech": True, "partial_transcript": "等一下"}),
|
||||
AudioFrame(
|
||||
b"\xff\x7f",
|
||||
16000,
|
||||
1,
|
||||
600,
|
||||
20,
|
||||
{"duration_ms": 20, "speech": True, "partial_transcript": "等一下", "speaker_id": "owner"},
|
||||
),
|
||||
AudioFrame(
|
||||
b"\xff\x7f",
|
||||
16000,
|
||||
1,
|
||||
620,
|
||||
21,
|
||||
{"duration_ms": 20, "speech": True, "partial_transcript": "等一下", "speaker_id": "owner"},
|
||||
),
|
||||
*silence_frames(22, 640, 3),
|
||||
]
|
||||
transport = PlaybackInjectedTransport(
|
||||
first_question_frames,
|
||||
inject_after_play_count=8,
|
||||
inject_after_play_count=18,
|
||||
injected_frames=barge_frames,
|
||||
)
|
||||
stt = QueueSttProvider(["第一问", "打断问题"])
|
||||
@@ -677,8 +705,10 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
speech_provider="cloud",
|
||||
wake_ack_text="",
|
||||
barge_in_enabled=True,
|
||||
barge_in_echo_guard_ms=500,
|
||||
barge_in_echo_guard_ms=0,
|
||||
barge_in_min_speech_ms=40,
|
||||
barge_in_listen_interval_ms=1,
|
||||
barge_in_chunk_ms=20,
|
||||
followup_listen_timeout_ms=3000,
|
||||
),
|
||||
transport=transport,
|
||||
|
||||
@@ -113,6 +113,11 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
self.assertTrue(config.barge_in_enabled)
|
||||
self.assertEqual(config.barge_in_min_speech_ms, 250)
|
||||
self.assertEqual(config.barge_in_echo_guard_ms, 500)
|
||||
self.assertTrue(config.barge_in_speaker_gate_enabled)
|
||||
self.assertEqual(config.barge_in_user_similarity_threshold, 0.62)
|
||||
self.assertEqual(config.barge_in_assistant_reject_threshold, 0.72)
|
||||
self.assertEqual(config.barge_in_listen_interval_ms, 20)
|
||||
self.assertEqual(config.barge_in_chunk_ms, 30)
|
||||
self.assertTrue(config.end_chime_enabled)
|
||||
self.assertEqual(str(config.end_chime_file), "assets/sounds/codex-notification.wav")
|
||||
self.assertEqual(config.end_chime_frequency_hz, 880)
|
||||
@@ -157,6 +162,10 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
followup_listen_timeout_ms=-1,
|
||||
barge_in_min_speech_ms=-1,
|
||||
barge_in_echo_guard_ms=-1,
|
||||
barge_in_user_similarity_threshold=1.5,
|
||||
barge_in_assistant_reject_threshold=0.0,
|
||||
barge_in_listen_interval_ms=0,
|
||||
barge_in_chunk_ms=0,
|
||||
end_chime_frequency_hz=0,
|
||||
end_chime_duration_ms=0,
|
||||
)
|
||||
@@ -167,6 +176,10 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
self.assertTrue(any("OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_MIN_SPEECH_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_ECHO_GUARD_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_USER_SIMILARITY_THRESHOLD" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_ASSISTANT_REJECT_THRESHOLD" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_LISTEN_INTERVAL_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_CHUNK_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_END_CHIME_FREQUENCY_HZ" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_END_CHIME_DURATION_MS" in error.message for error in errors))
|
||||
|
||||
|
||||
@@ -133,6 +133,21 @@ class TransportTests(unittest.TestCase):
|
||||
self.assertGreater(len(fake.output_writes), 1)
|
||||
self.assertEqual(callbacks[-1], result.duration_ms)
|
||||
|
||||
def test_sounddevice_chunk_playback_stops_on_signal(self) -> None:
|
||||
fake = FakeSoundDevice()
|
||||
transport = SoundDeviceAudioTransport(sounddevice_module=fake)
|
||||
segment = AudioSegment(b"\x00\x00" * 1600, 16000, 1, 0, 100)
|
||||
|
||||
result = transport.play_pcm_chunks(
|
||||
segment,
|
||||
chunk_ms=20,
|
||||
should_stop=lambda: len(fake.output_writes) >= 2,
|
||||
)
|
||||
|
||||
self.assertTrue(result.played)
|
||||
self.assertEqual(len(fake.output_writes), 2)
|
||||
self.assertEqual(result.duration_ms, 40)
|
||||
|
||||
def test_sounddevice_device_report_uses_query_devices(self) -> None:
|
||||
fake = FakeSoundDevice()
|
||||
report = SoundDeviceAudioTransport(sounddevice_module=fake).device_report()
|
||||
|
||||
Reference in New Issue
Block a user