mirror of
https://github.com/Jeuners/astra-local-voice.git
synced 2026-09-09 15:02:35 +02:00
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:
parent
0d75eb953c
commit
991f0d1eb5
10 changed files with 159 additions and 20 deletions
25
README.md
25
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
29
web/app.js
29
web/app.js
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue