210 lines
6.7 KiB
Python
210 lines
6.7 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
import json
|
||
import socket
|
||
import struct
|
||
import subprocess
|
||
import tempfile
|
||
import urllib.error
|
||
import urllib.request
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from .config import AppConfig
|
||
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"})
|
||
|
||
|
||
class CloudTtsProvider:
|
||
def __init__(
|
||
self,
|
||
config: AppConfig,
|
||
timeout_s: float = 60.0,
|
||
urlopen: Callable[..., Any] | None = None,
|
||
) -> None:
|
||
self.config = config
|
||
self.timeout_s = timeout_s
|
||
self.urlopen = urlopen or urllib.request.urlopen
|
||
self.loaded = False
|
||
|
||
def load(self) -> None:
|
||
self.config.require_llm_credentials()
|
||
self.loaded = True
|
||
|
||
def synthesize(self, text: str) -> AudioSegment:
|
||
if not self.loaded:
|
||
raise ProviderError(
|
||
ErrorCode.TTS_SYNTHESIS_FAILED,
|
||
"cloud TTS provider is not loaded",
|
||
False,
|
||
"newapi-tts",
|
||
"tts",
|
||
)
|
||
clean = text.strip()
|
||
if not clean:
|
||
raise ProviderError(
|
||
ErrorCode.TTS_EMPTY_AUDIO,
|
||
"cannot synthesize empty text",
|
||
True,
|
||
"newapi-tts",
|
||
"tts",
|
||
)
|
||
payload = {
|
||
"model": self.config.tts_model,
|
||
"input": clean,
|
||
"voice": self.config.tts_voice,
|
||
"response_format": "mp3",
|
||
}
|
||
request = urllib.request.Request(
|
||
self.config.api_url("/v1/audio/speech"),
|
||
data=json.dumps(payload).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {self.config.llm_api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with self.urlopen(request, timeout=self.timeout_s) as response:
|
||
data = response.read()
|
||
except urllib.error.HTTPError as exc:
|
||
raise ProviderError(
|
||
ErrorCode.TTS_SYNTHESIS_FAILED,
|
||
f"cloud TTS HTTP error {exc.code}",
|
||
exc.code >= 500,
|
||
"newapi-tts",
|
||
"tts",
|
||
) from exc
|
||
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
||
raise ProviderError(
|
||
ErrorCode.TTS_SYNTHESIS_FAILED,
|
||
f"cloud TTS request failed: {exc}",
|
||
True,
|
||
"newapi-tts",
|
||
"tts",
|
||
) from exc
|
||
if not data:
|
||
raise ProviderError(
|
||
ErrorCode.TTS_EMPTY_AUDIO,
|
||
"cloud TTS returned empty audio",
|
||
True,
|
||
"newapi-tts",
|
||
"tts",
|
||
)
|
||
return AudioSegment(data, 16000, 1, 0, max(120, len(clean) * 45), {"text": clean, "format": "mp3"})
|