[流式回复播放]:完成LLM到TTS流式播报,包含句子切分和可取消播放队列

This commit is contained in:
mkbk
2026-06-19 12:32:32 +08:00
parent f31e3d89d6
commit 79b3e89b79
7 changed files with 206 additions and 11 deletions
+1
View File
@@ -88,6 +88,7 @@ class CliAcceptanceTests(unittest.TestCase):
self.assertFalse(data["full_duplex_runtime_ready"])
self.assertEqual(data["audio_apm_provider"], "webrtc")
self.assertEqual(data["audio_apm_error_code"], "AUDIO_APM_UNAVAILABLE")
self.assertTrue(data["llm_streaming_enabled"])
self.assertEqual(data["turn_based_entry"], "run-live")
def test_run_agent_live_once_invokes_agent_runtime(self) -> None:
+16
View File
@@ -134,6 +134,22 @@ class FullDuplexIntegrationTests(unittest.TestCase):
self.assertEqual(playback.spoken.text, "你好。")
self.assertEqual(render.frame_count, 1)
def test_full_duplex_runtime_streaming_response_writes_render_reference_and_spoken_text(self) -> None:
runtime = FullDuplexAgentRuntime(
config=AppConfig(audio_apm_provider="fake", audio_apm_required=False),
llm_provider=FakeStreamingLlmProvider(
[LlmStreamEvent("delta", "第一句。第二句。"), LlmStreamEvent("finish", finish_reason="stop")]
),
tts_provider=FakeStreamingTtsProvider(),
)
spoken = runtime.run_streaming_response_fixture([Message("user", "你好", 1.0)])
self.assertEqual(spoken, "第一句。第二句。")
self.assertIsNotNone(runtime.audio_hub)
self.assertEqual(runtime.audio_hub.render_reference.frame_count, 2)
self.assertEqual(runtime.playback_queue.pending_items, 0)
def test_memory_restart_and_tool_search_integration(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "memory.sqlite3"
+28 -1
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import unittest
from owner_voice_pet.full_duplex_audio import RenderReferenceRingBuffer
from owner_voice_pet.full_duplex_audio import AudioHub, FakeWebRtcAudioProcessingProvider, RenderReferenceRingBuffer
from owner_voice_pet.full_duplex_control import CancellationGraph
from owner_voice_pet.full_duplex_response import (
AudioSegmentStreamingTtsProvider,
FakeStreamingLlmProvider,
FakeStreamingTtsProvider,
InterruptiblePlaybackQueue,
@@ -13,6 +14,7 @@ from owner_voice_pet.full_duplex_response import (
prepare_tts_sentence,
)
from owner_voice_pet.models import AudioFrame, Message
from owner_voice_pet.tts import SineTtsProvider
class FullDuplexResponseTests(unittest.TestCase):
@@ -74,6 +76,17 @@ class FullDuplexResponseTests(unittest.TestCase):
self.assertEqual(frames, [])
self.assertEqual(session.accepted_text, [])
def test_audio_segment_streaming_tts_wraps_sync_tts_as_pcm_chunks(self) -> None:
provider = AudioSegmentStreamingTtsProvider(SineTtsProvider(), chunk_ms=30)
session = provider.start_stream(voice="default", sample_rate=16000)
frames = session.accept_text("你好。")
self.assertEqual(provider.started, [("default", 16000)])
self.assertEqual(session.accepted_text, ["你好。"])
self.assertGreater(len(frames), 1)
self.assertTrue(all(frame.metadata["tts_text"] == "你好。" for frame in frames))
def test_interruptible_playback_commits_only_fully_played_text(self) -> None:
queue = InterruptiblePlaybackQueue()
render = RenderReferenceRingBuffer(capacity_ms=1000)
@@ -92,6 +105,20 @@ class FullDuplexResponseTests(unittest.TestCase):
self.assertEqual(queue.spoken.text, "完整句。")
self.assertEqual(render.frame_count, 2)
def test_interruptible_playback_writes_audio_hub_render_reference(self) -> None:
queue = InterruptiblePlaybackQueue()
graph = CancellationGraph("turn")
processor = FakeWebRtcAudioProcessingProvider()
hub = AudioHub(processor=processor, render_capacity_ms=1000)
frames = [AudioFrame(b"1", 16000, 1, 0, 1, {"duration_ms": 20})]
queue.enqueue("一句。", frames)
result = queue.play_next(audio_hub=hub, cancellation=graph.root)
self.assertFalse(result.interrupted)
self.assertEqual(hub.render_reference.frame_count, 1)
self.assertEqual([item.frame_id for item in processor.render_frames], [1])
def test_interruptible_playback_does_not_commit_interrupted_text(self) -> None:
queue = InterruptiblePlaybackQueue()
render = RenderReferenceRingBuffer(capacity_ms=1000)