[LLM/TTS 与对话闭环]:完成语音对话主链路,包含上下文管理、NewAPI 兼容 LLM、分句 TTS、状态机和错误恢复测试
This commit is contained in:
@@ -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"})
|
||||
Reference in New Issue
Block a user