Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ RUN uv sync --extra api --extra cpu --no-dev \
&& uv run glados download

EXPOSE 5050
CMD ["uv", "run", "litestar", "--app", "glados.api.app:app", "run", "--host", "0.0.0.0", "--port", "5050"]
CMD ["uv", "run", "litestar", "--app", "glados.api.app:create_app", "run", "--host", "0.0.0.0", "--port", "5050"]
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,19 @@ Or Docker:
docker compose up -d --build
```

By default the API reuses one `SpeechSynthesizer` across requests (`reuse_tts: true` in `configs/api_config.yaml`). Disable it to restore per-request model loading:

```yaml
Api:
reuse_tts: false
```

Environment override:

```bash
GLADOS_API_REUSE_TTS=false ./scripts/serve
```

Generate speech:
```bash
curl -X POST http://localhost:5050/v1/audio/speech \
Expand Down
3 changes: 3 additions & 0 deletions configs/api_config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Api:
# Reuse one SpeechSynthesizer across requests instead of reloading ONNX each call.
reuse_tts: true
2 changes: 1 addition & 1 deletion scripts/serve
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Start the API server.
set -e

uv run litestar --app glados.api.app:app run --host 0.0.0.0 --port 5050 --debug --reload
uv run litestar --app glados.api.app:create_app run --host 0.0.0.0 --port 5050 --debug --reload
34 changes: 32 additions & 2 deletions src/glados/api/app.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
import io
from pathlib import Path
from typing import Literal

from litestar import Litestar, post
from litestar.response import Stream
import yaml

from glados.utils.resources import resource_path

from .config import ApiConfig
from .log import structlog_plugin
from .tts import write_glados_audio_file
from .tts import configure_tts, write_glados_audio_file

Voice = Literal["glados"]
ResponseFormat = Literal["mp3", "wav", "ogg"]

DEFAULT_API_CONFIG = resource_path("configs/api_config.yaml")


@dataclass
class RequestData:
Expand All @@ -24,6 +33,16 @@ class RequestData:
CONTENT_TYPES: dict[ResponseFormat, str] = {"mp3": "audio/mpeg", "wav": "audio/wav", "ogg": "audio/ogg"}


def load_api_config(path: str | Path | None = None) -> ApiConfig:
"""Load API settings from configs/api_config.yaml (Api: section)."""
config_path = Path(path) if path is not None else DEFAULT_API_CONFIG
data: dict[str, object] = {}
if config_path.exists():
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = raw.get("Api", {}) or {}
return ApiConfig.model_validate(data)


@post("/v1/audio/speech")
async def create_speech(data: RequestData) -> Stream:
"""
Expand All @@ -49,4 +68,15 @@ async def create_speech(data: RequestData) -> Stream:
)


app = Litestar([create_speech], plugins=[structlog_plugin])
def create_app() -> Litestar:
"""Create the Litestar application for the TTS API server."""

@asynccontextmanager
async def lifespan(app: Litestar) -> AsyncIterator[None]:
configure_tts(load_api_config())
yield

return Litestar([create_speech], plugins=[structlog_plugin], lifespan=[lifespan])


app = create_app()
19 changes: 19 additions & 0 deletions src/glados/api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os

from pydantic import BaseModel, Field, model_validator


class ApiConfig(BaseModel):
"""Configuration for the OpenAI-compatible TTS API server."""

reuse_tts: bool = Field(
default=True,
description="Reuse a single SpeechSynthesizer instance across requests instead of reloading ONNX each call.",
)

@model_validator(mode="after")
def _apply_env_overrides(self) -> "ApiConfig":
env_reuse_tts = os.environ.get("GLADOS_API_REUSE_TTS")
if env_reuse_tts is not None:
self.reuse_tts = env_reuse_tts.lower() in ("1", "true", "yes")
return self
41 changes: 37 additions & 4 deletions src/glados/api/tts.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
from functools import lru_cache
import io

import soundfile as sf

from glados.TTS import tts_glados
from glados.TTS import SpeechSynthesizerProtocol, get_speech_synthesizer
from glados.utils import spoken_text_converter

from .config import ApiConfig

_api_config = ApiConfig()


def configure_tts(config: ApiConfig) -> None:
"""Apply API TTS settings and warm models when reuse is enabled."""
global _api_config
_api_config = config
if config.reuse_tts:
warm_tts()


@lru_cache(maxsize=1)
def _get_synthesizer() -> SpeechSynthesizerProtocol:
return get_speech_synthesizer("glados")


@lru_cache(maxsize=1)
def _get_text_converter() -> spoken_text_converter.SpokenTextConverter:
return spoken_text_converter.SpokenTextConverter()


def warm_tts() -> None:
"""Load ONNX session and text converter at startup."""
_get_synthesizer()
_get_text_converter()


def write_glados_audio_file(f: str | io.BytesIO, text: str, *, format: str) -> None:
"""Generate GLaDOS-style speech audio from text and write to a file.
Expand All @@ -14,9 +43,13 @@ def write_glados_audio_file(f: str | io.BytesIO, text: str, *, format: str) -> N
text: Text to convert to speech
format: Audio format (e.g., "mp3", "wav", "ogg")
"""
glados_tts = tts_glados.SpeechSynthesizer()
converter = spoken_text_converter.SpokenTextConverter()
converted_text = converter.text_to_spoken(text)
if _api_config.reuse_tts:
glados_tts = _get_synthesizer()
converted_text = _get_text_converter().text_to_spoken(text)
else:
glados_tts = get_speech_synthesizer("glados")
converted_text = spoken_text_converter.SpokenTextConverter().text_to_spoken(text)

audio = glados_tts.generate_speech_audio(converted_text)
sf.write(
f,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_api_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from pathlib import Path

import pytest

from glados.api.app import load_api_config
from glados.api.config import ApiConfig


def test_api_config_defaults_to_reuse_tts() -> None:
config = ApiConfig()
assert config.reuse_tts is True


def test_api_config_yaml_section(tmp_path: Path) -> None:
config_file = tmp_path / "api_config.yaml"
config_file.write_text("Api:\n reuse_tts: false\n", encoding="utf-8")

config = load_api_config(str(config_file))

assert config.reuse_tts is False


def test_api_config_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GLADOS_API_REUSE_TTS", "false")

config = ApiConfig.model_validate({})

assert config.reuse_tts is False


def test_load_api_config_uses_defaults_when_file_missing(tmp_path: Path) -> None:
config = load_api_config(str(tmp_path / "missing.yaml"))

assert config.reuse_tts is True