Initial commit: Voice Assistant Gateway mit Konfig-/Routing-Fundament

- FastAPI-Gateway mit REST-Endpunkten (chat/speak/transcribe/devices/sessions/config)
- Geschichtete Konfiguration mit Profilen (local-dev/hybrid/cloud) via TOML + ENV
- Registry-Pattern + einheitliche Route-Aufloesung (Default->Profil->ENV->Session->Request)
- Device Router (strikt, Singleton) und Output-Lifecycle im Orchestrator
- OpenRouter-Adapter (STT multipart/LLM/TTS) + lokale Provider-Stubs
- Regelbasierte Pipeline (Cleaner/Spoken-Adapter/TTS-Normalizer)
- 22 automatisierte Tests; Doku: README, BEDIENUNGSANLEITUNG, Architektur
- Secrets ausschliesslich ueber Umgebung; .env und lokale config gitignored

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 01:48:56 +02:00
commit 293ed257db
72 changed files with 2612 additions and 0 deletions

0
app/__init__.py Normal file
View file

0
app/api/__init__.py Normal file
View file

92
app/api/chat.py Normal file
View file

@ -0,0 +1,92 @@
from io import BytesIO
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse, StreamingResponse
from app.config import settings
from app.errors import RoutingError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
)
from app.schemas import ChatRequest
router = APIRouter()
def _route_headers(route) -> dict:
return {
"X-Input-Endpoint": route.input_endpoint,
"X-Output-Endpoint": route.output_endpoint,
"X-STT-Provider": route.stt_provider,
"X-LLM-Provider": route.llm_provider,
"X-TTS-Provider": route.tts_provider,
}
@router.post("/chat")
async def chat(
payload: ChatRequest,
debug: bool = Query(
default=False,
description="Return JSON trace instead of audio response",
),
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
):
overrides = {
"input_endpoint": payload.input_endpoint,
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"stt_provider": payload.stt_provider,
"llm_provider": payload.llm_provider,
"tts_provider": payload.tts_provider,
}
route = resolve_route(session_id, overrides)
voice = payload.voice or settings.openrouter_tts_voice
try:
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
try:
trace, audio = await orchestrator.chat_text(
payload.text,
language=route.language,
voice=voice,
output=output,
)
if debug:
return JSONResponse(
content={
"ok": True,
"voice": voice,
"route": route.as_dict(),
"trace": {
"raw_transcript": trace.raw_transcript,
"cleaned_transcript": trace.cleaned_transcript,
"semantic_response": trace.semantic_response,
"spoken_response": trace.spoken_response,
"tts_ready_text": trace.tts_ready_text,
},
}
)
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
**_route_headers(route),
}
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))

37
app/api/config.py Normal file
View file

@ -0,0 +1,37 @@
from fastapi import APIRouter
from app.config import settings, active_profile
from app.dependencies import (
resolve_route,
get_audio_router,
STT_REGISTRY,
LLM_REGISTRY,
TTS_REGISTRY,
)
router = APIRouter()
@router.get("/config")
async def get_config():
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.
Bewusst OHNE Secrets - API-Keys werden nur als 'gesetzt/nicht gesetzt' gemeldet.
"""
route = resolve_route()
audio_router = get_audio_router()
return {
"profile": active_profile(),
"app_env": settings.app_env,
"default_route": route.as_dict(),
"available": {
"stt_providers": sorted(STT_REGISTRY),
"llm_providers": sorted(LLM_REGISTRY),
"tts_providers": sorted(TTS_REGISTRY),
"input_endpoints": [c.model_dump() for c in await audio_router.list_inputs()],
"output_endpoints": [c.model_dump() for c in await audio_router.list_outputs()],
},
"secrets": {
"openrouter_api_key_set": bool(settings.openrouter_api_key.strip()),
},
}

12
app/api/devices.py Normal file
View file

@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.dependencies import get_audio_router
router = APIRouter()
@router.get("/devices")
async def list_devices():
audio_router = get_audio_router()
return {
"inputs": [item.model_dump() for item in await audio_router.list_inputs()],
"outputs": [item.model_dump() for item in await audio_router.list_outputs()],
}

7
app/api/health.py Normal file
View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}

10
app/api/sessions.py Normal file
View file

@ -0,0 +1,10 @@
from fastapi import APIRouter
from app.schemas import SessionRouteRequest
from app.dependencies import session_manager
router = APIRouter()
@router.post("/sessions/{session_id}/route")
async def set_session_route(session_id: str, payload: SessionRouteRequest):
session = session_manager.update(session_id, payload.model_dump())
return {"session_id": session_id, "route": session}

60
app/api/speak.py Normal file
View file

@ -0,0 +1,60 @@
from io import BytesIO
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.config import settings
from app.errors import RoutingError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_output_endpoint,
)
from app.schemas import SpeakRequest
router = APIRouter()
@router.post("/speak")
async def speak(
payload: SpeakRequest,
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
):
overrides = {
"output_endpoint": payload.output_endpoint,
"language": payload.language,
"tts_provider": payload.tts_provider,
}
route = resolve_route(session_id, overrides)
voice = payload.voice or settings.openrouter_tts_voice
try:
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
try:
audio = await orchestrator.speak_only(
payload.text,
voice=voice,
language=route.language,
output=output,
)
headers = {
"Content-Language": route.language,
"X-Audio-Format": "pcm",
"X-Audio-Sample-Rate": "24000",
"X-Audio-Channels": "1",
"X-Audio-Sample-Width": "16",
"X-Output-Endpoint": route.output_endpoint,
"X-TTS-Provider": route.tts_provider,
}
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))

50
app/api/transcribe.py Normal file
View file

@ -0,0 +1,50 @@
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.errors import RoutingError
from app.dependencies import (
resolve_route,
build_orchestrator,
resolve_input_endpoint,
)
router = APIRouter()
@router.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
language: str | None = Form(default=None),
input_endpoint: str | None = Form(default=None),
stt_provider: str | None = Form(default=None),
session_id: str | None = Query(
default=None,
description="Optional session id to apply a stored route",
),
):
overrides = {
"input_endpoint": input_endpoint,
"language": language,
"stt_provider": stt_provider,
}
route = resolve_route(session_id, overrides)
try:
orchestrator = build_orchestrator(route)
source = await resolve_input_endpoint(route)
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
content = await file.read()
suffix = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower()
try:
trace = await orchestrator.transcribe_only(
content,
fmt=suffix,
language=route.language,
input=source,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))
return {"route": route.as_dict(), "trace": trace.model_dump()}

0
app/audio/__init__.py Normal file
View file

View file

View file

View file

@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from app.schemas import AudioChunk, EndpointCapabilities
class AudioInputEndpoint(ABC):
endpoint_id: str
@abstractmethod
async def capabilities(self) -> EndpointCapabilities: ...
@abstractmethod
async def open(self) -> None: ...
@abstractmethod
async def read_chunk(self) -> AudioChunk: ...
@abstractmethod
async def close(self) -> None: ...

View file

@ -0,0 +1,23 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class BluetoothInput(AudioInputEndpoint):
endpoint_id = "bt-headset-01"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="bluetooth",
direction="input",
latency_class="medium",
bluetooth=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

@ -0,0 +1,24 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class LocalDefaultInput(AudioInputEndpoint):
endpoint_id = "local-default-mic"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="local-default",
direction="input",
latency_class="low",
supports_barge_in=True,
default=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

@ -0,0 +1,25 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebRTCInput(AudioInputEndpoint):
endpoint_id = "mobile-webrtc-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-webrtc",
direction="input",
latency_class="low",
networked=True,
mobile=True,
supports_barge_in=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

@ -0,0 +1,24 @@
from app.audio.endpoints.input.base import AudioInputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebSocketInput(AudioInputEndpoint):
endpoint_id = "mobile-ws-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-ws",
direction="input",
latency_class="medium",
networked=True,
mobile=True,
)
async def open(self) -> None:
return None
async def read_chunk(self) -> AudioChunk:
return AudioChunk(data=b"", format="wav", timestamp_ms=0)
async def close(self) -> None:
return None

View file

View file

@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
from app.schemas import AudioChunk, EndpointCapabilities
class AudioOutputEndpoint(ABC):
endpoint_id: str
@abstractmethod
async def capabilities(self) -> EndpointCapabilities: ...
@abstractmethod
async def open(self) -> None: ...
@abstractmethod
async def write_chunk(self, chunk: AudioChunk) -> None: ...
@abstractmethod
async def flush(self) -> None: ...
@abstractmethod
async def close(self) -> None: ...

View file

@ -0,0 +1,26 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class BluetoothOutput(AudioOutputEndpoint):
endpoint_id = "bt-speaker-01"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="bluetooth",
direction="output",
latency_class="medium",
bluetooth=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,26 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class LocalDefaultOutput(AudioOutputEndpoint):
endpoint_id = "local-default-speaker"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="local-default",
direction="output",
latency_class="low",
default=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,28 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class LoopbackOutput(AudioOutputEndpoint):
endpoint_id = "loopback-output"
def __init__(self):
self.chunks = []
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="loopback",
direction="output",
latency_class="low",
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
self.chunks.append(chunk)
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,27 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebRTCOutput(AudioOutputEndpoint):
endpoint_id = "mobile-webrtc-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-webrtc",
direction="output",
latency_class="low",
networked=True,
mobile=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

View file

@ -0,0 +1,27 @@
from app.audio.endpoints.output.base import AudioOutputEndpoint
from app.schemas import AudioChunk, EndpointCapabilities
class MobileWebSocketOutput(AudioOutputEndpoint):
endpoint_id = "mobile-ws-client"
async def capabilities(self) -> EndpointCapabilities:
return EndpointCapabilities(
id=self.endpoint_id,
kind="mobile-ws",
direction="output",
latency_class="medium",
networked=True,
mobile=True,
)
async def open(self) -> None:
return None
async def write_chunk(self, chunk: AudioChunk) -> None:
return None
async def flush(self) -> None:
return None
async def close(self) -> None:
return None

38
app/audio/router.py Normal file
View file

@ -0,0 +1,38 @@
from app.errors import UnknownEndpointError
class AudioRouter:
def __init__(self, inputs, outputs):
self.inputs = inputs
self.outputs = outputs
async def list_inputs(self):
return [await endpoint.capabilities() for endpoint in self.inputs]
async def list_outputs(self):
return [await endpoint.capabilities() for endpoint in self.outputs]
async def select_input(self, preferred: str | None = None):
return await self._select(self.inputs, preferred, direction="input")
async def select_output(self, preferred: str | None = None):
return await self._select(self.outputs, preferred, direction="output")
async def _select(self, endpoints, preferred: str | None, direction: str):
# Capabilities einmal sammeln (Basis fuer spaetere capability-basierte Auswahl).
pairs = [(endpoint, await endpoint.capabilities()) for endpoint in endpoints]
if preferred:
for endpoint, caps in pairs:
if caps.id == preferred or caps.kind == preferred:
return endpoint
# Angefragter Endpunkt existiert nicht -> KEIN stiller Default-Fallback.
available = sorted({caps.id for _, caps in pairs} | {caps.kind for _, caps in pairs})
raise UnknownEndpointError(
f"Unbekannter {direction}-Endpunkt {preferred!r}. Verfuegbar: {available}"
)
for endpoint, caps in pairs:
if caps.default:
return endpoint
raise RuntimeError(f"Kein {direction}-Standardendpunkt verfuegbar")

View file

@ -0,0 +1,11 @@
class TransportRouter:
def __init__(self, local_registry: dict, remote_registry: dict):
self.local_registry = local_registry
self.remote_registry = remote_registry
def resolve(self, module_type: str, provider_name: str):
if provider_name in self.local_registry.get(module_type, {}):
return self.local_registry[module_type][provider_name]
if provider_name in self.remote_registry.get(module_type, {}):
return self.remote_registry[module_type][provider_name]
raise KeyError(f"Unknown provider: {module_type}/{provider_name}")

146
app/config.py Normal file
View file

@ -0,0 +1,146 @@
import os
from pathlib import Path
try:
import tomllib # Python >= 3.11 (stdlib)
except ModuleNotFoundError: # pragma: no cover - Fallback fuer aeltere Interpreter
import tomli as tomllib # type: ignore
from pydantic.fields import FieldInfo
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
BASE_DIR = Path(__file__).resolve().parent.parent
ENV_FILE = BASE_DIR / ".env"
DEFAULT_CONFIG_FILE = BASE_DIR / "config" / "voice-assistant.toml"
def _setting_lookup(key: str) -> str | None:
"""Liest einen Steuer-Schluessel: echte Umgebung zuerst, dann die .env-Datei.
Noetig fuer VA_PROFILE/VA_CONFIG_FILE, weil diese gebraucht werden, BEVOR
pydantic-settings die .env laedt - und .env-Werte sonst nicht in os.environ stehen.
"""
value = os.getenv(key)
if value is not None:
return value
try:
from dotenv import dotenv_values
except ModuleNotFoundError: # pragma: no cover
return None
if ENV_FILE.is_file():
return dotenv_values(ENV_FILE).get(key)
return None
def _config_file_path() -> Path:
return Path(_setting_lookup("VA_CONFIG_FILE") or str(DEFAULT_CONFIG_FILE))
def active_profile() -> str | None:
"""Name des aktiven Profils (VA_PROFILE) aus Umgebung oder .env, falls gesetzt."""
profile = _setting_lookup("VA_PROFILE")
return profile.strip() or None if profile else None
def load_profile_config() -> dict:
"""Liest die zentrale TOML-Config und merged [defaults] + [profiles.<VA_PROFILE>].
- Fehlt die Datei, gilt ein leeres dict (nur ENV/Defaults greifen) - kein Fehler,
damit reine Cloud-Deployments ohne Datei (nur ENV) funktionieren.
- Ein gesetztes, aber unbekanntes VA_PROFILE ist ein Konfigurationsfehler.
"""
path = _config_file_path()
if not path.is_file():
return {}
with path.open("rb") as handle:
data = tomllib.load(handle)
merged: dict = dict(data.get("defaults", {}))
profile = active_profile()
if profile:
profiles = data.get("profiles", {})
if profile not in profiles:
raise ValueError(
f"Unbekanntes VA_PROFILE {profile!r}. "
f"Verfuegbar: {sorted(profiles)}"
)
merged.update(profiles[profile])
return merged
class TomlProfileSource(PydanticBaseSettingsSource):
"""Settings-Quelle aus der zentralen TOML-Config (inkl. aktivem Profil).
Liegt in der Praezedenz unter ENV/.env, aber ueber den eingebauten Defaults.
Es werden nur Schluessel durchgereicht, die auch als Settings-Feld existieren.
"""
def __init__(self, settings_cls):
super().__init__(settings_cls)
raw = load_profile_config()
known = set(settings_cls.model_fields)
self._values = {
key.lower(): value
for key, value in raw.items()
if key.lower() in known
}
def get_field_value(self, field: FieldInfo, field_name: str):
if field_name in self._values:
return self._values[field_name], field_name, False
return None, field_name, False
def __call__(self) -> dict:
return dict(self._values)
class Settings(BaseSettings):
app_env: str = "dev"
host: str = "0.0.0.0"
port: int = 8080
log_level: str = "info"
openrouter_api_key: str = ""
openrouter_stt_model: str = "openai/whisper-large-v3"
openrouter_tts_model: str = "openai/gpt-4o-mini-tts"
openrouter_tts_voice: str = "alloy"
openrouter_llm_model: str = "openai/gpt-4.1-mini"
default_language: str = "de"
default_input_endpoint: str = "local-default"
default_output_endpoint: str = "local-default"
default_stt_provider: str = "openrouter"
default_llm_provider: str = "local-openai-compatible"
default_tts_provider: str = "openrouter"
local_llm_base_url: str = "http://127.0.0.1:11434/v1"
local_llm_api_key: str = "dummy"
local_llm_model: str = "llama3.1"
model_config = SettingsConfigDict(
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
)
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
):
# Praezedenz (frueher = hoeher): init > ENV > .env > TOML/Profil > Defaults
return (
init_settings,
env_settings,
dotenv_settings,
TomlProfileSource(settings_cls),
file_secret_settings,
)
settings = Settings()

0
app/core/__init__.py Normal file
View file

105
app/core/orchestrator.py Normal file
View file

@ -0,0 +1,105 @@
from app.schemas import AudioChunk, PipelineTrace
# Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono).
TTS_AUDIO_FORMAT = "pcm"
TTS_SAMPLE_RATE = 24000
TTS_CHANNELS = 1
class Orchestrator:
def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer):
self.stt = stt
self.llm = llm
self.tts = tts
self.input_cleaner = input_cleaner
self.spoken_adapter = spoken_adapter
self.tts_normalizer = tts_normalizer
async def _emit_to_output(self, audio: bytes, output) -> None:
"""Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt.
Der HTTP-Stream bleibt davon unberuehrt (additiv). Bei lokalen Geraeten
ist write_chunk heute ein no-op; LoopbackOutput sammelt die Chunks.
"""
if output is None:
return
chunk = AudioChunk(
data=audio,
sample_rate=TTS_SAMPLE_RATE,
channels=TTS_CHANNELS,
format=TTS_AUDIO_FORMAT,
)
await output.open()
try:
await output.write_chunk(chunk)
await output.flush()
finally:
await output.close()
async def transcribe_only(
self,
audio_bytes: bytes,
fmt: str,
language: str | None = None,
input=None,
):
trace = PipelineTrace()
# input dient hier nur der Validierung/Metadaten; das Audio kommt per Upload.
if input is not None:
await input.capabilities()
trace.raw_transcript = await self.stt.transcribe(
audio_bytes,
fmt=fmt,
language=language,
)
trace.cleaned_transcript = await self.input_cleaner.run(
trace.raw_transcript or ""
)
return trace
async def speak_only(
self,
text: str,
voice: str | None = None,
language: str | None = None,
output=None,
):
spoken = await self.spoken_adapter.run(text, language=language)
normalized = await self.tts_normalizer.run(spoken, language=language)
audio = await self.tts.synthesize(normalized, voice=voice)
await self._emit_to_output(audio, output)
return audio
async def chat_text(
self,
text: str,
language: str | None = None,
voice: str | None = None,
output=None,
):
trace = PipelineTrace()
trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "")
trace.semantic_response = await self.llm.complete(
trace.cleaned_transcript or ""
)
if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response")
trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response,
language=language,
)
trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response,
language=language,
)
audio = await self.tts.synthesize(
trace.tts_ready_text,
voice=voice,
)
await self._emit_to_output(audio, output)
return trace, audio

View file

@ -0,0 +1,11 @@
class SessionManager:
def __init__(self):
self._sessions = {}
def get(self, session_id: str) -> dict:
return self._sessions.setdefault(session_id, {})
def update(self, session_id: str, values: dict) -> dict:
session = self.get(session_id)
session.update({k: v for k, v in values.items() if v is not None})
return session

187
app/dependencies.py Normal file
View file

@ -0,0 +1,187 @@
from dataclasses import dataclass
from app.config import Settings, settings
from app.errors import UnknownComponentError
from app.audio.router import AudioRouter
from app.audio.endpoints.input.local_default import LocalDefaultInput
from app.audio.endpoints.input.bluetooth import BluetoothInput
from app.audio.endpoints.input.mobile_ws import MobileWebSocketInput
from app.audio.endpoints.input.mobile_webrtc import MobileWebRTCInput
from app.audio.endpoints.output.local_default import LocalDefaultOutput
from app.audio.endpoints.output.bluetooth import BluetoothOutput
from app.audio.endpoints.output.mobile_ws import MobileWebSocketOutput
from app.audio.endpoints.output.mobile_webrtc import MobileWebRTCOutput
from app.audio.endpoints.output.loopback import LoopbackOutput
from app.providers.stt.openrouter import OpenRouterSTTProvider
from app.providers.stt.faster_whisper import FasterWhisperProvider
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
from app.providers.llm.openrouter import OpenRouterLLMProvider
from app.providers.tts.openrouter import OpenRouterTTSProvider
from app.providers.tts.chatterbox import ChatterboxTTSProvider
from app.providers.tts.piper import PiperTTSProvider
from app.pipeline.input_cleaner import InputCleaner
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
from app.pipeline.tts_normalizer import TTSNormalizer
from app.core.orchestrator import Orchestrator
from app.core.session_manager import SessionManager
session_manager = SessionManager()
# ---------------------------------------------------------------------------
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
# Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError.
# ---------------------------------------------------------------------------
STT_REGISTRY = {
"openrouter": lambda s: OpenRouterSTTProvider(s.openrouter_api_key, s.openrouter_stt_model),
"faster-whisper": lambda s: FasterWhisperProvider(),
}
LLM_REGISTRY = {
"openrouter": lambda s: OpenRouterLLMProvider(s.openrouter_api_key, s.openrouter_llm_model),
"local-openai-compatible": lambda s: LocalOpenAICompatibleLLM(
s.local_llm_base_url, s.local_llm_api_key, s.local_llm_model
),
}
TTS_REGISTRY = {
"openrouter": lambda s: OpenRouterTTSProvider(
s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice
),
"chatterbox": lambda s: ChatterboxTTSProvider(),
"piper": lambda s: PiperTTSProvider(),
}
def _from_registry(registry: dict, name: str, kind: str, cfg: Settings):
try:
factory = registry[name]
except KeyError as exc:
raise UnknownComponentError(
f"Unbekannter {kind}-Provider {name!r}. Verfuegbar: {sorted(registry)}"
) from exc
return factory(cfg)
def get_stt_provider(name: str | None = None, cfg: Settings = settings):
return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg)
def get_llm_provider(name: str | None = None, cfg: Settings = settings):
return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg)
def get_tts_provider(name: str | None = None, cfg: Settings = settings):
return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg)
# ---------------------------------------------------------------------------
# Audio-Router: Modul-Singleton, damit zustandsbehaftete Endpunkte
# (z. B. LoopbackOutput.chunks) ueber Requests hinweg stabil bleiben.
# ---------------------------------------------------------------------------
_audio_router: AudioRouter | None = None
def get_audio_router() -> AudioRouter:
global _audio_router
if _audio_router is None:
_audio_router = AudioRouter(
inputs=[
LocalDefaultInput(),
BluetoothInput(),
MobileWebSocketInput(),
MobileWebRTCInput(),
],
outputs=[
LocalDefaultOutput(),
BluetoothOutput(),
MobileWebSocketOutput(),
MobileWebRTCOutput(),
LoopbackOutput(),
],
)
return _audio_router
# ---------------------------------------------------------------------------
# Session-Routing und einheitliche Route-Aufloesung ueber alle Achsen.
# Praezedenz: Settings-Defaults < Session-Route < Request-Overrides.
# ---------------------------------------------------------------------------
ROUTE_KEYS = (
"input_endpoint",
"output_endpoint",
"stt_provider",
"llm_provider",
"tts_provider",
"language",
)
@dataclass
class ResolvedRoute:
input_endpoint: str
output_endpoint: str
stt_provider: str
llm_provider: str
tts_provider: str
language: str
def as_dict(self) -> dict:
return {
"input_endpoint": self.input_endpoint,
"output_endpoint": self.output_endpoint,
"stt_provider": self.stt_provider,
"llm_provider": self.llm_provider,
"tts_provider": self.tts_provider,
"language": self.language,
}
def get_session_route(session_id: str | None) -> dict:
"""Liefert die gespeicherte Route einer Session (leeres dict ohne session_id)."""
return session_manager.get(session_id) if session_id else {}
def resolve_route(
session_id: str | None = None,
overrides: dict | None = None,
cfg: Settings = settings,
) -> ResolvedRoute:
"""Loest die effektive Route aus Defaults, Session und Request-Overrides auf."""
resolved = {
"input_endpoint": cfg.default_input_endpoint,
"output_endpoint": cfg.default_output_endpoint,
"stt_provider": cfg.default_stt_provider,
"llm_provider": cfg.default_llm_provider,
"tts_provider": cfg.default_tts_provider,
"language": cfg.default_language,
}
session_route = get_session_route(session_id)
request_overrides = overrides or {}
for layer in (session_route, request_overrides):
for key in ROUTE_KEYS:
value = layer.get(key)
if value is not None:
resolved[key] = value
return ResolvedRoute(**resolved)
def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator:
return Orchestrator(
stt=get_stt_provider(route.stt_provider, cfg),
llm=get_llm_provider(route.llm_provider, cfg),
tts=get_tts_provider(route.tts_provider, cfg),
input_cleaner=InputCleaner(),
spoken_adapter=SpokenResponseAdapter(),
tts_normalizer=TTSNormalizer(),
)
async def resolve_output_endpoint(route: ResolvedRoute):
return await get_audio_router().select_output(route.output_endpoint)
async def resolve_input_endpoint(route: ResolvedRoute):
return await get_audio_router().select_input(route.input_endpoint)

13
app/errors.py Normal file
View file

@ -0,0 +1,13 @@
class RoutingError(Exception):
"""Basis fuer Fehler bei der Routing-/Komponentenauswahl.
Wird in der API-Schicht zu HTTP 422 uebersetzt (Client-Konfigurationsfehler).
"""
class UnknownComponentError(RoutingError):
"""Unbekannter Provider-Name fuer STT, LLM oder TTS."""
class UnknownEndpointError(RoutingError):
"""Ein angefragter Audio-Endpunkt (input/output) existiert nicht."""

17
app/main.py Normal file
View file

@ -0,0 +1,17 @@
from fastapi import FastAPI
from app.api.health import router as health_router
from app.api.chat import router as chat_router
from app.api.transcribe import router as transcribe_router
from app.api.speak import router as speak_router
from app.api.devices import router as devices_router
from app.api.sessions import router as sessions_router
from app.api.config import router as config_router
app = FastAPI(title="Voice Assistant Gateway")
app.include_router(health_router)
app.include_router(chat_router, prefix="/api")
app.include_router(transcribe_router, prefix="/api")
app.include_router(speak_router, prefix="/api")
app.include_router(devices_router, prefix="/api")
app.include_router(sessions_router, prefix="/api")
app.include_router(config_router, prefix="/api")

0
app/pipeline/__init__.py Normal file
View file

View file

@ -0,0 +1,4 @@
class InputCleaner:
async def run(self, text: str) -> str:
cleaned = " ".join(text.strip().split())
return cleaned.replace(" äh ", " ").replace(" hm ", " ")

View file

@ -0,0 +1,37 @@
import re
class SpokenResponseAdapter:
async def run(self, text: str, language: str = "de") -> str:
if not text:
return ""
text = text.strip()
# Markdown / Formatierung entfernen
text = re.sub(r"```[\s\S]*?```", " ", text) # code blocks
text = re.sub(r"`([^`]*)`", r"\1", text) # inline code
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links
text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols
# Listen entschärfen
text = re.sub(r"(?m)^\s*[-•]\s+", "", text)
text = re.sub(r"(?m)^\s*\d+\.\s+", "", text)
# Mehrfache Leerzeichen / Zeilenumbrüche glätten
text = re.sub(r"\s+", " ", text).strip()
# Für Voice natürlicher machen: Doppelpunkte/Semikolons etwas beruhigen,
# aber Uhrzeiten/Verhältnisse (10:30) nicht zerstören -> nur am Wortende ersetzen.
text = re.sub(r"[:;](?=\s|$)", ",", text)
# Klammern meist nicht gut für TTS
text = text.replace("(", ", ")
text = text.replace(")", " ")
# Abschlusspunktion sicherstellen
if text and not text.endswith((".", "!", "?")):
text += "."
return text

View file

@ -0,0 +1,59 @@
import re
class TTSNormalizer:
async def run(self, text: str, language: str = "de") -> str:
if not text:
return ""
normalized = text
if language == "de":
replacements = {
"24/7": "vierundzwanzig sieben",
"&": " und ",
"%": " Prozent",
"": " Euro",
"$": " Dollar",
"km/h": " Kilometer pro Stunde",
"z.B.": "zum Beispiel",
"bzw.": "beziehungsweise",
"u.a.": "unter anderem",
"ca.": "circa",
}
else:
replacements = {
"24/7": "twenty four seven",
"&": " and ",
"%": " percent",
"": " euros",
"$": " dollars",
"km/h": " kilometers per hour",
"e.g.": "for example",
"i.e.": "that is",
}
for old, new in replacements.items():
normalized = normalized.replace(old, new)
# Slashes zwischen Wörtern/Zahlen sprachfreundlicher machen
normalized = re.sub(r"(\w)/(\w)", r"\1 oder \2", normalized)
# Datums-/Versions-/Bereichsstriche etwas entschärfen
normalized = normalized.replace("", " bis ")
normalized = normalized.replace("", ", ")
normalized = normalized.replace(" - ", ", ")
# URLs und E-Mails nicht roh vorlesen
normalized = re.sub(r"https?://\S+", "Link", normalized)
normalized = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", normalized)
# Mehrfache Leerzeichen glätten
normalized = re.sub(r"\s+", " ", normalized).strip()
if normalized and not normalized.endswith((".", "!", "?")):
normalized += "."
return normalized

View file

View file

View file

@ -0,0 +1,5 @@
from abc import ABC, abstractmethod
class LLMProvider(ABC):
@abstractmethod
async def complete(self, text: str, session_id: str | None = None) -> str: ...

View file

@ -0,0 +1,24 @@
import httpx
from app.providers.llm.base import LLMProvider
class LocalOpenAICompatibleLLM(LLMProvider):
def __init__(self, base_url: str, api_key: str, model: str):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
async def complete(self, text: str, session_id: str | None = None) -> str:
payload = {
"model": self.model,
"messages": [{"role": "user", "content": text}],
"temperature": 0.3,
}
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json=payload,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]

View file

@ -0,0 +1,101 @@
import httpx
from app.providers.llm.base import LLMProvider
SYSTEM_PROMPT = """
You are a voice assistant for spoken conversations with older adults.
Speak naturally, clearly, and calmly.
Use short, simple sentences.
Prefer plain everyday language over technical wording.
Answer in the same language as the user, unless the user asks to switch languages.
Important response rules:
- Output plain text only.
- No markdown.
- No bullet points.
- No numbered lists.
- No tables.
- No code.
- No emojis.
- No URLs unless the user explicitly asks for one.
- Do not use asterisks, hashtags, or formatting symbols.
- Do not write headings.
- Do not use long disclaimers.
Voice style rules:
- Sound helpful, warm, and patient.
- Keep answers brief by default: 1 to 3 short sentences.
- If more detail is needed, explain step by step in natural spoken sentences.
- Ask at most one follow-up question at a time.
- If the answer contains several items, present them as natural speech, not as a list.
- Use wording that sounds good when spoken aloud.
- Avoid abbreviations when possible.
- Avoid symbols when words are better.
- Prefer complete spoken forms for dates, times, and numbers when useful.
Safety and honesty rules:
- If you are unsure, say so briefly and clearly.
- Do not invent facts.
- If current real-world information is needed and unavailable, say that clearly.
Always optimize your answer for listening, not for reading.
""".strip()
class OpenRouterLLMProvider(LLMProvider):
def __init__(self, api_key: str, model: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
async def complete(self, text: str, session_id: str | None = None) -> str:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_LLM_MODEL is empty")
if not text or not text.strip():
raise ValueError("LLM input text is empty")
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text.strip()},
],
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter LLM timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc
data = response.json()
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc
if not content or not str(content).strip():
raise RuntimeError("OpenRouter LLM returned empty content")
return str(content).strip()

View file

View file

@ -0,0 +1,5 @@
from abc import ABC, abstractmethod
class STTProvider(ABC):
@abstractmethod
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ...

View file

@ -0,0 +1,5 @@
from app.providers.stt.base import STTProvider
class FasterWhisperProvider(STTProvider):
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
return "[local transcription placeholder]"

View file

@ -0,0 +1,46 @@
import httpx
from app.providers.stt.base import STTProvider
class OpenRouterSTTProvider(STTProvider):
def __init__(self, api_key: str, model: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_STT_MODEL is empty")
if not audio_bytes:
raise ValueError("STT input audio is empty")
# OpenAI-kompatibler /audio/transcriptions-Endpunkt erwartet multipart/form-data
# mit binärem file-Feld, nicht JSON mit base64.
files = {"file": (f"audio.{fmt}", audio_bytes, f"audio/{fmt}")}
data: dict[str, str] = {"model": self.model}
if language:
data["language"] = language
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {self.api_key}"},
files=files,
data=data,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter STT timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc
return response.json().get("text", "")

View file

View file

@ -0,0 +1,5 @@
from abc import ABC, abstractmethod
class TTSProvider(ABC):
@abstractmethod
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ...

View file

@ -0,0 +1,5 @@
from app.providers.tts.base import TTSProvider
class ChatterboxTTSProvider(TTSProvider):
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes:
return b""

View file

@ -0,0 +1,62 @@
import httpx
from app.providers.tts.base import TTSProvider
class OpenRouterTTSProvider(TTSProvider):
def __init__(self, api_key: str, model: str, voice: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
self.voice = (voice or "").strip()
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
) -> bytes:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_TTS_MODEL is empty")
if not text or not text.strip():
raise ValueError("TTS input text is empty")
effective_voice = (voice or self.voice).strip()
if not effective_voice:
raise ValueError("TTS voice is required for OpenRouter TTS")
payload = {
"model": self.model,
"input": text.strip(),
"voice": effective_voice,
"response_format": audio_format,
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/speech",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"OpenRouter TTS error {exc.response.status_code}: {exc.response.text}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError("OpenRouter TTS timeout") from exc
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc
if not response.content:
raise RuntimeError("OpenRouter TTS returned empty audio content")
return response.content

View file

@ -0,0 +1,5 @@
from app.providers.tts.base import TTSProvider
class PiperTTSProvider(TTSProvider):
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes:
return b""

71
app/schemas.py Normal file
View file

@ -0,0 +1,71 @@
from typing import Literal
from pydantic import BaseModel, Field
class EndpointCapabilities(BaseModel):
id: str
kind: str
direction: Literal["input", "output"]
sample_rate: int = 16000
channels: int = 1
latency_class: Literal["low", "medium", "high"] = "medium"
supports_aec: bool = False
supports_barge_in: bool = False
networked: bool = False
bluetooth: bool = False
mobile: bool = False
default: bool = False
class AudioChunk(BaseModel):
data: bytes
sample_rate: int = 16000
channels: int = 1
format: str = "wav"
timestamp_ms: int = 0
class PipelineTrace(BaseModel):
raw_transcript: str | None = None
cleaned_transcript: str | None = None
semantic_response: str | None = None
spoken_response: str | None = None
tts_ready_text: str | None = None
class SpeakRequest(BaseModel):
text: str = Field(min_length=1)
voice: str | None = None
language: str | None = None
output_endpoint: str | None = None
tts_provider: str | None = None
class ChatRequest(BaseModel):
text: str = Field(min_length=1)
input_endpoint: str | None = None
output_endpoint: str | None = None
language: str | None = None
voice: str | None = None
stt_provider: str | None = None
llm_provider: str | None = None
tts_provider: str | None = None
class SessionRouteRequest(BaseModel):
input_endpoint: str | None = None
output_endpoint: str | None = None
stt_provider: str | None = None
llm_provider: str | None = None
tts_provider: str | None = None
language: str | None = None
class RouteInfo(BaseModel):
input_endpoint: str
output_endpoint: str
stt_provider: str
llm_provider: str
tts_provider: str
language: str

0
app/utils/__init__.py Normal file
View file