Files

154 lines
4.6 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
import requests
from agent.tts_provider import TTSProvider
from tools.tool_backend_helpers import resolve_openai_audio_api_key
from tools.tts_streaming import StreamingTTSProvider, register as register_streamer
from tools.tts_tool import _load_tts_config
NAME = "qwen3-stream"
SAMPLE_RATE = 24000
def _settings() -> Dict[str, Any]:
config = _load_tts_config()
own = dict(config.get(NAME) or {})
fallback = dict(config.get("openai") or {})
own.setdefault("base_url", fallback.get("base_url", ""))
own.setdefault("api_key", fallback.get("api_key", ""))
own.setdefault("model", fallback.get("model", "tts-1"))
own.setdefault("voice", fallback.get("voice", "alloy"))
own.setdefault("language", fallback.get("language", "German"))
own.setdefault("chunk_size", 4)
return own
def _url(path: str, section: Optional[Dict[str, Any]] = None) -> str:
cfg = section or _settings()
base = str(cfg.get("base_url") or "").rstrip("/")
if not base:
raise RuntimeError("tts.qwen3-stream.base_url is not configured")
if not base.endswith("/v1"):
base += "/v1"
return base + path
def _headers(section: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
cfg = section or _settings()
key = str(cfg.get("api_key") or resolve_openai_audio_api_key() or "").strip()
headers = {"Accept": "application/octet-stream"}
if key:
headers["Authorization"] = f"Bearer {key}"
return headers
def _payload(text: str, section: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
cfg = section or _settings()
payload: Dict[str, Any] = {
"input": text,
"model": cfg.get("model") or "tts-1",
"voice": cfg.get("voice") or "alloy",
"language": cfg.get("language") or "German",
}
instruct = str(cfg.get("instruct") or "").strip()
if instruct:
payload["instruct"] = instruct
return payload
class Qwen3PCMStreamer(StreamingTTSProvider):
sample_rate = SAMPLE_RATE
channels = 1
sample_width = 2
@staticmethod
def available() -> bool:
try:
return bool(_settings().get("base_url"))
except Exception:
return False
def stream(self, text: str) -> Iterator[bytes]:
cfg = dict(_settings())
cfg.update(self.section or {})
payload = _payload(text, cfg)
payload["chunk_size"] = max(1, int(cfg.get("chunk_size", 4)))
with requests.post(
_url("/audio/speech/pcm-stream", cfg),
json=payload,
headers=_headers(cfg),
stream=True,
timeout=(5, 120),
) as response:
response.raise_for_status()
pending = b""
# An explicit read size prevents urllib3 from buffering the
# unknown-length response until connection close.
for chunk in response.iter_content(chunk_size=4096):
if not chunk:
continue
data = pending + chunk
even = len(data) & ~1
if even:
yield data[:even]
pending = data[even:]
class Qwen3TTSProvider(TTSProvider):
@property
def name(self) -> str:
return NAME
@property
def display_name(self) -> str:
return "Athena Qwen3-TTS Streaming"
def is_available(self) -> bool:
return Qwen3PCMStreamer.available()
def list_voices(self) -> List[Dict[str, Any]]:
voice = str(_settings().get("voice") or "alloy")
return [{"id": voice, "display": voice, "language": "de"}]
def synthesize(
self,
text: str,
output_path: str,
*,
voice: Optional[str] = None,
model: Optional[str] = None,
speed: Optional[float] = None,
format: str = "mp3",
**extra: Any,
) -> str:
cfg = _settings()
payload = _payload(text, cfg)
payload["response_format"] = format
if voice:
payload["voice"] = voice
if model:
payload["model"] = model
if speed is not None:
payload["speed"] = speed
response = requests.post(
_url("/audio/speech", cfg),
json=payload,
headers=_headers(cfg),
timeout=(5, 120),
)
response.raise_for_status()
Path(output_path).write_bytes(response.content)
return output_path
register_streamer(NAME)(Qwen3PCMStreamer)
def register(ctx) -> None:
ctx.register_tts_provider(Qwen3TTSProvider())