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
|
|
@ -96,11 +96,25 @@ async def ws_chat(
|
|||
|
||||
voice = msg.get("voice") or settings.openrouter_tts_voice
|
||||
stream = bool(msg.get("stream"))
|
||||
try:
|
||||
if stream:
|
||||
async def on_token(delta):
|
||||
await websocket.send_json({"type": "token", "text": delta})
|
||||
audio_stream = bool(msg.get("audio_stream"))
|
||||
|
||||
on_token = None
|
||||
if stream:
|
||||
async def on_token(delta):
|
||||
await websocket.send_json({"type": "token", "text": delta})
|
||||
|
||||
on_audio = None
|
||||
if audio_stream:
|
||||
audio_seq = 0
|
||||
|
||||
async def on_audio(chunk):
|
||||
nonlocal audio_seq
|
||||
await websocket.send_json({"type": "audio", "seq": audio_seq})
|
||||
audio_seq += 1
|
||||
await websocket.send_bytes(chunk)
|
||||
|
||||
try:
|
||||
if stream or audio_stream:
|
||||
trace, audio = await orchestrator.chat_stream(
|
||||
text,
|
||||
language=route.language,
|
||||
|
|
@ -108,6 +122,7 @@ async def ws_chat(
|
|||
output=output,
|
||||
history=llm_context,
|
||||
on_token=on_token,
|
||||
on_audio=on_audio,
|
||||
)
|
||||
else:
|
||||
trace, audio = await orchestrator.chat_text(
|
||||
|
|
@ -132,7 +147,9 @@ async def ws_chat(
|
|||
"spoken": trace.spoken_response,
|
||||
}
|
||||
)
|
||||
await websocket.send_bytes(audio)
|
||||
# Bei audio_stream wurden die Audio-Chunks bereits live gesendet.
|
||||
if not audio_stream:
|
||||
await websocket.send_bytes(audio)
|
||||
await websocket.send_json(
|
||||
{"type": "done", "audio_format": "pcm", "sample_rate": 24000}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from app.schemas import AudioChunk, PipelineTrace
|
||||
from app.pipeline.sentence_chunker import SentenceChunker
|
||||
|
||||
# Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono).
|
||||
TTS_AUDIO_FORMAT = "pcm"
|
||||
|
|
@ -114,16 +115,30 @@ class Orchestrator:
|
|||
output=None,
|
||||
history: list[dict] | None = None,
|
||||
on_token=None,
|
||||
on_audio=None,
|
||||
):
|
||||
"""Wie chat_text, aber die LLM-Antwort wird tokenweise gestreamt.
|
||||
"""Wie chat_text, aber gestreamt.
|
||||
|
||||
`on_token(delta)` (async) wird pro Token-Delta aufgerufen. Audio/Output
|
||||
werden erst nach der vollstaendigen Antwort erzeugt (TTS ist nicht streamend).
|
||||
`on_token(delta)` (async) wird pro LLM-Token-Delta aufgerufen.
|
||||
Ist `on_audio(chunk)` gesetzt, wird das Audio satzweise erzeugt (chunked TTS)
|
||||
und pro fertigem Satz ausgeliefert, statt erst am Ende komplett.
|
||||
"""
|
||||
trace = PipelineTrace()
|
||||
trace.raw_transcript = text
|
||||
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
|
||||
|
||||
chunker = SentenceChunker() if on_audio else None
|
||||
audio_parts: list[bytes] = []
|
||||
|
||||
async def _emit_sentence(sentence: str) -> None:
|
||||
spoken = await self.spoken_adapter.run(sentence, language=language)
|
||||
ready = await self.tts_normalizer.run(spoken, language=language)
|
||||
if not ready.strip():
|
||||
return
|
||||
chunk = await self.tts.synthesize(ready, voice=voice)
|
||||
audio_parts.append(chunk)
|
||||
await on_audio(chunk)
|
||||
|
||||
parts: list[str] = []
|
||||
stream_fn = getattr(self.llm, "stream", None)
|
||||
if stream_fn is not None:
|
||||
|
|
@ -131,12 +146,18 @@ class Orchestrator:
|
|||
parts.append(delta)
|
||||
if on_token:
|
||||
await on_token(delta)
|
||||
if chunker:
|
||||
for sentence in chunker.feed(delta):
|
||||
await _emit_sentence(sentence)
|
||||
else:
|
||||
# Provider ohne Streaming -> komplette Antwort als ein Token.
|
||||
result = await self.llm.complete(trace.cleaned_transcript or "", history=history)
|
||||
parts.append(result)
|
||||
if on_token:
|
||||
await on_token(result)
|
||||
if chunker:
|
||||
for sentence in chunker.feed(result):
|
||||
await _emit_sentence(sentence)
|
||||
|
||||
trace.semantic_response = "".join(parts)
|
||||
if not trace.semantic_response:
|
||||
|
|
@ -151,6 +172,13 @@ class Orchestrator:
|
|||
language=language,
|
||||
)
|
||||
|
||||
audio = await self.tts.synthesize(trace.tts_ready_text, voice=voice)
|
||||
if chunker:
|
||||
tail = chunker.flush()
|
||||
if tail:
|
||||
await _emit_sentence(tail)
|
||||
audio = b"".join(audio_parts)
|
||||
else:
|
||||
audio = await self.tts.synthesize(trace.tts_ready_text, voice=voice)
|
||||
|
||||
await self._emit_to_output(audio, output)
|
||||
return trace, audio
|
||||
|
|
|
|||
36
app/pipeline/sentence_chunker.py
Normal file
36
app/pipeline/sentence_chunker.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import re
|
||||
|
||||
# Satzende: . ! ? … gefolgt von Whitespace (oder Stringende beim flush).
|
||||
_SENTENCE_END = re.compile(r"[.!?…]+(?=\s)")
|
||||
|
||||
|
||||
class SentenceChunker:
|
||||
"""Inkrementelle Satzsegmentierung fuer gestreamte LLM-Token.
|
||||
|
||||
`feed(delta)` liefert die seit dem letzten Aufruf fertig gewordenen Saetze,
|
||||
`flush()` den verbleibenden Rest (z. B. der letzte Satz ohne abschliessendes
|
||||
Leerzeichen). Damit kann pro Satz schon TTS erzeugt werden, waehrend das LLM
|
||||
noch weiterschreibt.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._buffer = ""
|
||||
|
||||
def feed(self, text: str) -> list[str]:
|
||||
self._buffer += text
|
||||
sentences: list[str] = []
|
||||
while True:
|
||||
match = _SENTENCE_END.search(self._buffer)
|
||||
if not match:
|
||||
break
|
||||
end = match.end()
|
||||
sentence = self._buffer[:end].strip()
|
||||
self._buffer = self._buffer[end:]
|
||||
if sentence:
|
||||
sentences.append(sentence)
|
||||
return sentences
|
||||
|
||||
def flush(self) -> str:
|
||||
rest = self._buffer.strip()
|
||||
self._buffer = ""
|
||||
return rest
|
||||
Loading…
Add table
Add a link
Reference in a new issue