[LLM/TTS 与对话闭环]:完成语音对话主链路,包含上下文管理、NewAPI 兼容 LLM、分句 TTS、状态机和错误恢复测试

This commit is contained in:
mkbk
2026-06-17 18:21:56 +08:00
parent 4d6232ed29
commit e544a60eb3
7 changed files with 636 additions and 6 deletions
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import math
import struct
import subprocess
import tempfile
from pathlib import Path
from .models import AudioSegment, ErrorCode, ProviderError
class SentenceBuffer:
def __init__(self, max_chars: int = 80) -> None:
self.max_chars = max_chars
self._buffer = ""
def feed(self, text: str, force: bool = False) -> list[str]:
if text:
self._buffer += text
chunks: list[str] = []
while self._should_emit(force):
chunks.append(self._pop_chunk(force))
force = False
return [chunk for chunk in chunks if chunk.strip()]
def flush(self) -> list[str]:
return self.feed("", force=True)
def _should_emit(self, force: bool) -> bool:
stripped = self._buffer.strip()
if not stripped:
return False
return force or stripped.endswith(("", "", "", ".", "!", "?", "\n")) or len(stripped) >= self.max_chars
def _pop_chunk(self, force: bool) -> str:
stripped = self._buffer.strip()
if force or len(stripped) <= self.max_chars:
self._buffer = ""
return stripped
chunk = stripped[: self.max_chars]
self._buffer = stripped[self.max_chars :]
return chunk
class SineTtsProvider:
def __init__(self, sample_rate: int = 16000) -> None:
self.sample_rate = sample_rate
self.loaded = False
def load(self) -> None:
self.loaded = True
def synthesize(self, text: str) -> AudioSegment:
if not self.loaded:
raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED,
"TTS provider is not loaded",
False,
"sine-tts",
"tts",
)
clean = text.strip()
if not clean:
raise ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"cannot synthesize empty text",
True,
"sine-tts",
"tts",
)
duration_ms = max(120, min(1200, len(clean) * 45))
samples = int(self.sample_rate * duration_ms / 1000)
pcm = bytearray()
for idx in range(samples):
sample = int(math.sin(2 * math.pi * 440 * idx / self.sample_rate) * 12000)
pcm.extend(struct.pack("<h", sample))
return AudioSegment(bytes(pcm), self.sample_rate, 1, 0, duration_ms, {"text": clean})
class MacSayTtsProvider:
def __init__(self, voice: str | None = None) -> None:
self.voice = voice
self.loaded = False
def load(self) -> None:
self.loaded = True
def synthesize(self, text: str) -> AudioSegment:
clean = text.strip()
if not clean:
raise ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"cannot synthesize empty text",
True,
"macos-say",
"tts",
)
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "speech.aiff"
command = ["say", "-o", str(output)]
if self.voice:
command.extend(["-v", self.voice])
command.append(clean)
try:
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
raise ProviderError(
ErrorCode.TTS_SYNTHESIS_FAILED,
f"macOS say failed: {exc}",
False,
"macos-say",
"tts",
) from exc
data = output.read_bytes()
if not data:
raise ProviderError(
ErrorCode.TTS_EMPTY_AUDIO,
"macOS say produced empty audio",
True,
"macos-say",
"tts",
)
return AudioSegment(data, 16000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "aiff"})