feat: Audio-Streaming (chunked TTS) über WebSocket (#4 Ausbau)
- SentenceChunker (pipeline/sentence_chunker.py): inkrementelle Satzsegmentierung
- Orchestrator.chat_stream(on_audio): satzweise TTS, Audio-Chunk pro fertigem Satz;
Gesamtaudio zusaetzlich an den Output-Endpunkt
- WS /ws/chat {"audio_stream":true}: audio-Events (json seq + binaerer Frame) live,
kein finales Vollaudio; mit stream kombinierbar
- Tests: 45 gruen (+2: Sentence-Chunker, Audio-Streaming)
- Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
379e002460
commit
b5913b0a44
7 changed files with 157 additions and 19 deletions
|
|
@ -5,10 +5,21 @@ from fastapi.testclient import TestClient
|
|||
import app.dependencies as deps
|
||||
from app.main import app
|
||||
from app.providers.llm.base import sse_delta, LLMProvider
|
||||
from app.pipeline.sentence_chunker import SentenceChunker
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_sentence_chunker_incremental():
|
||||
ch = SentenceChunker()
|
||||
emitted = []
|
||||
for tok in ["Hallo", " Anna", ". ", "Wie", " geht", " es", "? ", "Tschuess"]:
|
||||
emitted += ch.feed(tok)
|
||||
assert emitted == ["Hallo Anna.", "Wie geht es?"]
|
||||
assert ch.flush() == "Tschuess"
|
||||
assert SentenceChunker().feed("Eins. Zwei! Drei? Vier") == ["Eins.", "Zwei!", "Drei?"]
|
||||
|
||||
|
||||
def test_sse_delta_parsing():
|
||||
assert sse_delta('data: {"choices":[{"delta":{"content":"Hal"}}]}') == "Hal"
|
||||
assert sse_delta("data: [DONE]") is None
|
||||
|
|
@ -92,3 +103,42 @@ def test_ws_stream_fallback_for_nonstreaming_llm(monkeypatch):
|
|||
token = ws.receive_json()
|
||||
assert token["type"] == "token" and token["text"] == "komplett"
|
||||
assert ws.receive_json()["type"] == "semantic"
|
||||
|
||||
|
||||
def test_ws_audio_stream_sends_chunks_per_sentence(monkeypatch):
|
||||
tts_calls = []
|
||||
|
||||
class StreamLLM:
|
||||
async def complete(self, text, history=None, session_id=None):
|
||||
return "Satz eins. Satz zwei."
|
||||
|
||||
async def stream(self, text, history=None, session_id=None):
|
||||
for tok in ["Satz ", "eins. ", "Satz ", "zwei."]:
|
||||
yield tok
|
||||
|
||||
class CountTTS:
|
||||
async def synthesize(self, text, voice=None, audio_format="pcm"):
|
||||
tts_calls.append(text)
|
||||
return b"A" * len(tts_calls)
|
||||
|
||||
monkeypatch.setitem(deps.LLM_REGISTRY, "stream", lambda s: StreamLLM())
|
||||
monkeypatch.setitem(deps.TTS_REGISTRY, "cnt", lambda s: CountTTS())
|
||||
base = {"llm_provider": "stream", "tts_provider": "cnt", "output_endpoint": "loopback"}
|
||||
|
||||
with client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"text": "x", "audio_stream": True, **base})
|
||||
assert ws.receive_json()["type"] == "ack"
|
||||
|
||||
audio_events = 0
|
||||
event = ws.receive_json()
|
||||
while event["type"] != "semantic":
|
||||
assert event["type"] == "audio"
|
||||
assert ws.receive_bytes() # binärer Audio-Chunk folgt
|
||||
audio_events += 1
|
||||
event = ws.receive_json()
|
||||
|
||||
assert audio_events == 2 # zwei Sätze -> zwei Chunks
|
||||
# Kein finales Vollaudio mehr -> direkt done.
|
||||
assert ws.receive_json()["type"] == "done"
|
||||
|
||||
assert len(tts_calls) == 2 # TTS pro Satz
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue