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

@ -12,13 +12,17 @@ Drei lokale Modelle, verbunden über eine [Pipecat](https://github.com/pipecat-a
|---|---|---| |---|---|---|
| Spracherkennung | Nemotron ASR (streaming) | MLX, on-device | | Spracherkennung | Nemotron ASR (streaming) | MLX, on-device |
| Sprachmodell | Qwen 3.5 | über natives Ollama `/api/chat` | | Sprachmodell | Qwen 3.5 | über natives Ollama `/api/chat` |
| Sprachausgabe | Pocket TTS, Stimme „alba" | MLX, on-device | | Sprachausgabe | Pocket TTS, 26 deutsche Stimmen wählbar | MLX, on-device |
Der Browser spricht per WebRTC direkt mit einem FastAPI-Server auf Der Browser spricht per WebRTC direkt mit einem FastAPI-Server auf
`localhost:7860`. Der Server ist bewusst nur lokal erreichbar: Host- und `localhost:7860`. Der Server ist bewusst nur lokal erreichbar: Host- und
Origin-Prüfung auf jedem Request, strikte Content-Security-Policy, keine Origin-Prüfung auf jedem Request, strikte Content-Security-Policy, keine
offenen Ports nach außen. offenen Ports nach außen.
Die Stimme lässt sich im UI per Dropdown wählen (`/api/voices` listet alle
26, Auswahl wird im Browser gemerkt). Jede Stimme wird beim ersten Gebrauch
lazy geladen und danach für die Laufzeit des Prozesses gecacht.
## Starten ## Starten
```bash ```bash
@ -42,6 +46,7 @@ gezogen sein. Die Seite öffnen, Mikrofon erlauben, sprechen.
| `ASTRA_TTS_LANGUAGE` | `german` | | `ASTRA_TTS_LANGUAGE` | `german` |
| `ASTRA_VOICE` | `alba` | | `ASTRA_VOICE` | `alba` |
| `ASTRA_PORT` | `7860` | | `ASTRA_PORT` | `7860` |
| `ASTRA_TAILNET_HOST` | *(leer)* — z. B. `minim4-1.tail0f2cb2.ts.net` |
## Tests ## Tests
@ -50,9 +55,25 @@ uv run pytest
uv run ruff check . uv run ruff check .
``` ```
## Im Tailnet freigeben
Standardmäßig nur `localhost` erreichbar. Für Zugriff von einem anderen
Gerät im selben Tailscale-Netz:
```bash
tailscale serve --bg 7860
ASTRA_TAILNET_HOST="$(tailscale status --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')" \
uv run python -m astra.server
```
Danach ist die Seite unter `https://<tailnet-host>/` erreichbar (Port 443,
implizit — Tailscale terminiert TLS und proxyt auf 7860). Host- und
Origin-Prüfung lassen dann zusätzlich diesen einen Hostnamen durch.
## Sicherheit ## Sicherheit
- Nur `localhost`/`127.0.0.1` erreichbar, alle anderen Hosts bekommen 403 - Nur `localhost`/`127.0.0.1` (bzw. der optionale Tailnet-Host) erreichbar,
alle anderen Hosts bekommen 403
- POST-Requests werden gegen den erwarteten Origin geprüft - POST-Requests werden gegen den erwarteten Origin geprüft
- `think` ist im Ollama-Request hart auf `false` gesetzt — die Pipeline - `think` ist im Ollama-Request hart auf `false` gesetzt — die Pipeline
wirft, falls das Modell trotzdem Denkausgabe liefert wirft, falls das Modell trotzdem Denkausgabe liefert

View file

@ -13,6 +13,7 @@ class Settings:
voice: str = "alba" voice: str = "alba"
port: int = 7860 port: int = 7860
context_tokens: int = 4096 context_tokens: int = 4096
tailnet_host: str | None = None
@classmethod @classmethod
def from_env(cls): def from_env(cls):
@ -23,9 +24,41 @@ class Settings:
tts_language=os.getenv("ASTRA_TTS_LANGUAGE", cls.tts_language), tts_language=os.getenv("ASTRA_TTS_LANGUAGE", cls.tts_language),
voice=os.getenv("ASTRA_VOICE", cls.voice), voice=os.getenv("ASTRA_VOICE", cls.voice),
port=int(os.getenv("ASTRA_PORT", cls.port)), 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 = ( SYSTEM_PROMPT = (
"Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. " "Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. "
"Antworte natürlich und knapp, normalerweise in ein bis drei kurzen Sätzen. " "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: def local_origin_allowed(origin: str, port: int = 7860, tailnet_host: str | None = None) -> bool:
return origin in {f"http://localhost:{port}", f"http://127.0.0.1:{port}"} 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.tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="astra-tts")
self.stt = None self.stt = None
self.tts = None self.tts = None
self.voice = None self.voice_cache: dict[str, dict] = {}
def load_stt(self): def load_stt(self):
from mlx_audio.stt import load from mlx_audio.stt import load
@ -51,7 +51,12 @@ class Models:
from pocket_tts import TTSModel from pocket_tts import TTSModel
self.tts = TTSModel.load_model(language=self.settings.tts_language) 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): async def close(self):
await asyncio.to_thread(self.stt_executor.shutdown, wait=True, cancel_futures=True) 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) await on_executor(models.stt_executor, recognizer.push, bytes(16000), True)
print("Nemotron bereit. Lade deutsche Pocket-TTS-Stimme …", flush=True) print("Nemotron bereit. Lade deutsche Pocket-TTS-Stimme …", flush=True)
await on_executor(models.tts_executor, models.load_tts) await on_executor(models.tts_executor, models.load_tts)
voice_state = await on_executor(models.tts_executor, models.get_voice, settings.voice)
def synthesize(): def synthesize():
start = time.monotonic() start = time.monotonic()
chunks = [] chunks = []
first_ms = None first_ms = None
for chunk in models.tts.generate_audio_stream( for chunk in models.tts.generate_audio_stream(
models.voice, voice_state,
"Hallo, ich bin Astra. Ich laufe vollständig auf deinem Mac.", "Hallo, ich bin Astra. Ich laufe vollständig auf deinem Mac.",
copy_state=True, copy_state=True,
): ):

View file

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

View file

@ -222,18 +222,19 @@ def next_chunk(stream):
class LocalPocketTTSService(TTSService): class LocalPocketTTSService(TTSService):
def __init__(self, models: Models): def __init__(self, models: Models, voice_state: dict, voice_name: str):
super().__init__( super().__init__(
push_start_frame=True, push_start_frame=True,
push_stop_frames=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.models = models
self.voice_state = voice_state
async def run_tts(self, text: str, context_id: str): async def run_tts(self, text: str, context_id: str):
import torch 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(): async def audio():
try: try:

View file

@ -34,6 +34,27 @@ def test_unready_returns_actionable_status_and_invalid_sdp_is_rejected():
assert response.json()["detail"] assert response.json()["detail"]
def test_voices_endpoint_lists_selectable_voices():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
response = client.get("/api/voices")
assert response.status_code == 200
body = response.json()
assert body["default"] == "alba"
names = {voice["name"] for voice in body["voices"]}
assert "alba" in names
assert all({"name", "display_name", "gender"} <= voice.keys() for voice in body["voices"])
def test_offer_rejects_unknown_voice():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
response = client.post(
"/api/offer",
headers={"Origin": "http://localhost:7860"},
json={"sdp": "a" * 20, "type": "offer", "voice": "not-a-real-voice"},
)
assert response.status_code == 422
def test_disconnect_is_idempotent_and_host_is_checked(): def test_disconnect_is_idempotent_and_host_is_checked():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client: with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
response = client.post( response = client.post(

View file

@ -61,11 +61,33 @@ async function waitIce(pc) {
check(); check();
}); });
} }
async function loadVoices() {
try {
const response = await fetch("/api/voices", {signal: AbortSignal.timeout(5000)});
const data = await response.json();
const groups = {weiblich: document.createElement("optgroup"), männlich: document.createElement("optgroup")};
groups.weiblich.label = "Weiblich";
groups.männlich.label = "Männlich";
for (const voice of data.voices) {
const option = document.createElement("option");
option.value = voice.name;
option.textContent = voice.display_name;
groups[voice.gender]?.append(option);
}
$("voice").replaceChildren(groups.weiblich, groups.männlich);
const saved = localStorage.getItem("astra-voice");
$("voice").value = data.voices.some(v => v.name === saved) ? saved : data.default;
} catch { showError("Stimmenliste konnte nicht geladen werden."); }
}
$("voice").addEventListener("change", () => {
try { localStorage.setItem("astra-voice", $("voice").value); } catch { /* ignore */ }
});
async function connect() { async function connect() {
if (connecting || peer) return; if (connecting || peer) return;
connecting = true; connecting = true;
$("error").hidden = true; $("error").hidden = true;
$("connect").disabled = true; $("connect").disabled = true;
$("voice").disabled = true;
state("Mikrofon wird verbunden …"); state("Mikrofon wird verbunden …");
try { try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error("Bitte diese Seite unter http://localhost:7860 öffnen."); if (!navigator.mediaDevices?.getUserMedia) throw new Error("Bitte diese Seite unter http://localhost:7860 öffnen.");
@ -94,7 +116,7 @@ async function connect() {
}; };
await pc.setLocalDescription(await pc.createOffer()); await pc.setLocalDescription(await pc.createOffer());
await waitIce(pc); await waitIce(pc);
const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer"}); const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer", voice: $("voice").value});
pcId = answer.pc_id; pcId = answer.pc_id;
await pc.setRemoteDescription({sdp: answer.sdp, type: answer.type}); await pc.setRemoteDescription({sdp: answer.sdp, type: answer.type});
$("connect").textContent = "Gespräch beenden"; $("connect").textContent = "Gespräch beenden";
@ -129,6 +151,7 @@ async function disconnect() {
output.pause(); output.pause();
output.srcObject = null; output.srcObject = null;
muted = false; muted = false;
$("voice").disabled = !ready;
$("mute").hidden = true; $("mute").hidden = true;
$("mute").setAttribute("aria-pressed", "false"); $("mute").setAttribute("aria-pressed", "false");
$("mute").textContent = "Mikrofon pausieren"; $("mute").textContent = "Mikrofon pausieren";
@ -167,12 +190,14 @@ async function poll() {
ready = status.ready; ready = status.ready;
if (!peer && !connecting) { if (!peer && !connecting) {
$("connect").disabled = !ready; $("connect").disabled = !ready;
$("voice").disabled = !ready;
state(ready ? "Bereit, wenn du es bist." : status.stage); state(ready ? "Bereit, wenn du es bist." : status.stage);
if (status.error) showError(status.error); if (status.error) showError(status.error);
} }
} catch { } catch {
ready = false; ready = false;
if (!peer) { $("connect").disabled = true; state("Lokaler Server nicht erreichbar."); } if (!peer) { $("connect").disabled = true; $("voice").disabled = true; state("Lokaler Server nicht erreichbar."); }
} finally { setTimeout(poll, ready ? 5000 : 1500); } } finally { setTimeout(poll, ready ? 5000 : 1500); }
} }
void poll(); void poll();
void loadVoices();

View file

@ -18,6 +18,10 @@
<div class="live-status"><span id="state-dot"></span><p id="status" role="status" aria-live="polite">Modelle werden vorbereitet …</p></div> <div class="live-status"><span id="state-dot"></span><p id="status" role="status" aria-live="polite">Modelle werden vorbereitet …</p></div>
<p id="partial" class="partial" aria-live="off"></p> <p id="partial" class="partial" aria-live="off"></p>
<p id="error" class="error" role="alert" hidden></p> <p id="error" class="error" role="alert" hidden></p>
<div class="voice-picker">
<label for="voice">Stimme</label>
<select id="voice" disabled></select>
</div>
<div class="controls"><button id="connect" class="primary" disabled><span aria-hidden="true"></span> Gespräch starten</button><button id="mute" class="secondary" hidden aria-pressed="false">Mikrofon pausieren</button></div> <div class="controls"><button id="connect" class="primary" disabled><span aria-hidden="true"></span> Gespräch starten</button><button id="mute" class="secondary" hidden aria-pressed="false">Mikrofon pausieren</button></div>
<p class="hint" id="hint">Mikrofon wird erst nach dem Start freigegeben.</p> <p class="hint" id="hint">Mikrofon wird erst nach dem Start freigegeben.</p>
</section> </section>

File diff suppressed because one or more lines are too long