feat: add voice picker UI and optional Tailnet exposure

Stimmen werden jetzt lazy pro Name geladen und gecacht statt einer
fest verdrahteten Default-Stimme; das UI bekommt ein Dropdown mit
allen 26 deutschen Pocket-TTS-Stimmen (/api/voices), Auswahl wird im
Browser gemerkt und ist während eines laufenden Gesprächs gesperrt.

Zusätzlich ein optionaler ASTRA_TAILNET_HOST, damit die App über
`tailscale serve` auch von einem anderen Gerät im selben Tailnet
erreichbar ist, ohne die Loopback-only-Härtung für alle anderen
Hosts aufzuweichen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
This commit is contained in:
Jeuner 2026-09-07 15:15:13 +02:00
parent 0d75eb953c
commit 991f0d1eb5
10 changed files with 159 additions and 20 deletions

View file

@ -13,6 +13,7 @@ class Settings:
voice: str = "alba"
port: int = 7860
context_tokens: int = 4096
tailnet_host: str | None = None
@classmethod
def from_env(cls):
@ -23,9 +24,41 @@ class Settings:
tts_language=os.getenv("ASTRA_TTS_LANGUAGE", cls.tts_language),
voice=os.getenv("ASTRA_VOICE", cls.voice),
port=int(os.getenv("ASTRA_PORT", cls.port)),
tailnet_host=os.getenv("ASTRA_TAILNET_HOST", cls.tailnet_host),
)
VOICES = (
{"name": "alba", "display_name": "Alba", "gender": "weiblich"},
{"name": "anna", "display_name": "Anna", "gender": "weiblich"},
{"name": "azelma", "display_name": "Azelma", "gender": "weiblich"},
{"name": "bill_boerst", "display_name": "Bill Boerst", "gender": "männlich"},
{"name": "caro_davy", "display_name": "Caro Davy", "gender": "weiblich"},
{"name": "charles", "display_name": "Charles", "gender": "männlich"},
{"name": "cosette", "display_name": "Cosette", "gender": "weiblich"},
{"name": "eponine", "display_name": "Eponine", "gender": "weiblich"},
{"name": "estelle", "display_name": "Estelle", "gender": "weiblich"},
{"name": "eve", "display_name": "Eve", "gender": "weiblich"},
{"name": "fantine", "display_name": "Fantine", "gender": "weiblich"},
{"name": "george", "display_name": "George", "gender": "männlich"},
{"name": "giovanni", "display_name": "Giovanni", "gender": "männlich"},
{"name": "jane", "display_name": "Jane", "gender": "weiblich"},
{"name": "javert", "display_name": "Javert", "gender": "männlich"},
{"name": "jean", "display_name": "Jean", "gender": "männlich"},
{"name": "juergen", "display_name": "Juergen", "gender": "männlich"},
{"name": "lola", "display_name": "Lola", "gender": "weiblich"},
{"name": "marius", "display_name": "Marius", "gender": "männlich"},
{"name": "mary", "display_name": "Mary", "gender": "weiblich"},
{"name": "michael", "display_name": "Michael", "gender": "männlich"},
{"name": "paul", "display_name": "Paul", "gender": "männlich"},
{"name": "peter_yearsley", "display_name": "Peter Yearsley", "gender": "männlich"},
{"name": "rafael", "display_name": "Rafael", "gender": "männlich"},
{"name": "stuart_bell", "display_name": "Stuart Bell", "gender": "männlich"},
{"name": "vera", "display_name": "Vera", "gender": "weiblich"},
)
VOICE_NAMES = frozenset(voice["name"] for voice in VOICES)
SYSTEM_PROMPT = (
"Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. "
"Antworte natürlich und knapp, normalerweise in ein bis drei kurzen Sätzen. "
@ -76,5 +109,8 @@ def build_request(settings: Settings, messages: list[dict]) -> dict:
}
def local_origin_allowed(origin: str, port: int = 7860) -> bool:
return origin in {f"http://localhost:{port}", f"http://127.0.0.1:{port}"}
def local_origin_allowed(origin: str, port: int = 7860, tailnet_host: str | None = None) -> bool:
allowed = {f"http://localhost:{port}", f"http://127.0.0.1:{port}"}
if tailnet_host:
allowed.add(f"https://{tailnet_host}")
return origin in allowed

View file

@ -35,7 +35,7 @@ class Models:
self.tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="astra-tts")
self.stt = None
self.tts = None
self.voice = None
self.voice_cache: dict[str, dict] = {}
def load_stt(self):
from mlx_audio.stt import load
@ -51,7 +51,12 @@ class Models:
from pocket_tts import TTSModel
self.tts = TTSModel.load_model(language=self.settings.tts_language)
self.voice = self.tts.get_state_for_audio_prompt(self.settings.voice)
def get_voice(self, name: str) -> dict:
"""Compute (or reuse) one voice's conditioning state. Runs on tts_executor."""
if name not in self.voice_cache:
self.voice_cache[name] = self.tts.get_state_for_audio_prompt(name)
return self.voice_cache[name]
async def close(self):
await asyncio.to_thread(self.stt_executor.shutdown, wait=True, cancel_futures=True)

View file

@ -27,13 +27,14 @@ async def main():
await on_executor(models.stt_executor, recognizer.push, bytes(16000), True)
print("Nemotron bereit. Lade deutsche Pocket-TTS-Stimme …", flush=True)
await on_executor(models.tts_executor, models.load_tts)
voice_state = await on_executor(models.tts_executor, models.get_voice, settings.voice)
def synthesize():
start = time.monotonic()
chunks = []
first_ms = None
for chunk in models.tts.generate_audio_stream(
models.voice,
voice_state,
"Hallo, ich bin Astra. Ich laufe vollständig auf deinem Mac.",
copy_state=True,
):

View file

@ -15,9 +15,16 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from astra.core import SYSTEM_PROMPT, Settings, build_request, local_origin_allowed
from astra.core import (
SYSTEM_PROMPT,
VOICE_NAMES,
VOICES,
Settings,
build_request,
local_origin_allowed,
)
from astra.inference import Models, on_executor
ROOT = Path(__file__).resolve().parent.parent
@ -26,13 +33,21 @@ ROOT = Path(__file__).resolve().parent.parent
class Offer(BaseModel):
sdp: str = Field(min_length=10, max_length=65536)
type: Literal["offer"]
voice: str | None = None
@field_validator("voice")
@classmethod
def voice_must_be_known(cls, value: str | None) -> str | None:
if value is not None and value not in VOICE_NAMES:
raise ValueError("Unbekannte Stimme")
return value
class Disconnect(BaseModel):
pc_id: str = Field(max_length=100)
async def run_voice(connection, models, config):
async def run_voice(connection, models, config, voice_state, voice_name):
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import (
@ -107,7 +122,7 @@ async def run_voice(connection, models, config):
)
stt = NemotronSTTService(models, notify)
llm = NativeOllamaService(config, notify)
tts = LocalPocketTTSService(models)
tts = LocalPocketTTSService(models, voice_state, voice_name)
pipeline = Pipeline(
[
transport.input(),
@ -192,6 +207,7 @@ def create_app(config=None, *, load_models=True):
await on_executor(models.stt_executor, models.load_stt)
status["stage"] = "Deutsche Stimme wird geladen"
await on_executor(models.tts_executor, models.load_tts)
await on_executor(models.tts_executor, models.get_voice, config.voice)
status["stage"] = "Qwen wird vorbereitet"
async with httpx.AsyncClient(timeout=180) as client:
payload = build_request(config, [{"role": "user", "content": "Sage Hallo."}])
@ -237,10 +253,13 @@ def create_app(config=None, *, load_models=True):
@app.middleware("http")
async def local_only(request: Request, call_next):
if request.url.hostname not in {"localhost", "127.0.0.1"}:
allowed_hosts = {"localhost", "127.0.0.1"}
if config.tailnet_host:
allowed_hosts.add(config.tailnet_host)
if request.url.hostname not in allowed_hosts:
return JSONResponse({"detail": "Nur lokal erreichbar"}, status_code=403)
if request.method == "POST" and not local_origin_allowed(
request.headers.get("origin", ""), config.port
request.headers.get("origin", ""), config.port, config.tailnet_host
):
return JSONResponse({"detail": "Ungültiger Ursprung"}, status_code=403)
response = await call_next(request)
@ -261,6 +280,10 @@ def create_app(config=None, *, load_models=True):
async def health():
return {**status, "busy": bool(sessions), "model": config.model, "thinking": False}
@app.get("/api/voices")
async def voices():
return {"voices": list(VOICES), "default": config.voice}
@app.post("/api/offer")
async def offer(body: Offer):
if not status["ready"]:
@ -270,6 +293,8 @@ def create_app(config=None, *, load_models=True):
raise HTTPException(
409, "Ein Gespräch läuft bereits. Beende es im anderen Fenster."
)
voice_name = body.voice or config.voice
voice_state = await on_executor(models.tts_executor, models.get_voice, voice_name)
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
connection = SmallWebRTCConnection(ice_servers=[], connection_timeout_secs=20)
@ -281,7 +306,7 @@ def create_app(config=None, *, load_models=True):
async def session():
try:
await run_voice(connection, models, config)
await run_voice(connection, models, config, voice_state, voice_name)
except asyncio.CancelledError:
raise
except Exception as exc:

View file

@ -222,18 +222,19 @@ def next_chunk(stream):
class LocalPocketTTSService(TTSService):
def __init__(self, models: Models):
def __init__(self, models: Models, voice_state: dict, voice_name: str):
super().__init__(
push_start_frame=True,
push_stop_frames=True,
settings=TTSSettings(model="pocket-tts", voice=models.settings.voice, language="de"),
settings=TTSSettings(model="pocket-tts", voice=voice_name, language="de"),
)
self.models = models
self.voice_state = voice_state
async def run_tts(self, text: str, context_id: str):
import torch
stream = self.models.tts.generate_audio_stream(self.models.voice, text, copy_state=True)
stream = self.models.tts.generate_audio_stream(self.voice_state, text, copy_state=True)
async def audio():
try: