feat: initial commit of astra local voice agent

Lokaler deutscher Sprachagent für Apple Silicon: Pipecat-Pipeline mit
Nemotron-ASR (MLX), Qwen über natives Ollama /api/chat, und Pocket TTS.
Loopback-only WebRTC-Server mit Origin/Host-Härtung.

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 13:39:51 +02:00
commit a42b3e8d73
17 changed files with 1436 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.venv/
.cache/
.runtime/
__pycache__/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
*.log
.DS_Store

1
astra/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Astra: a local German voice companion for Apple Silicon."""

80
astra/core.py Normal file
View file

@ -0,0 +1,80 @@
"""Configuration and pure request policy, independent of audio hardware."""
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
model: str = "qwen3.5:latest"
ollama_url: str = "http://127.0.0.1:11434"
stt_model: str = "mlx-community/nemotron-3.5-asr-streaming-0.6b-8bit"
tts_language: str = "german"
voice: str = "alba"
port: int = 7860
context_tokens: int = 4096
@classmethod
def from_env(cls):
return cls(
model=os.getenv("ASTRA_MODEL", cls.model),
ollama_url=os.getenv("ASTRA_OLLAMA_URL", cls.ollama_url).rstrip("/"),
stt_model=os.getenv("ASTRA_STT_MODEL", cls.stt_model),
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)),
)
SYSTEM_PROMPT = (
"Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. "
"Antworte natürlich und knapp, normalerweise in ein bis drei kurzen Sätzen. "
"Deine Antwort wird vorgelesen: kein Markdown, keine Sternchen, keine Listen. "
"Sprich Zahlen und Abkürzungen verständlich aus. Stelle bei Bedarf eine kurze Rückfrage. "
"Du hast keine Werkzeuge, keinen Internetzugang und keinen Zugriff auf Dateien oder Apps. "
"Behaupte nicht, Aktionen ausgeführt zu haben."
)
def trim_messages(messages: list[dict], max_chars: int = 10000) -> list[dict]:
"""Retain recent whole turns within a conservative context character budget."""
system = [dict(m) for m in messages if m["role"] == "system"][:1]
if system:
system[0]["content"] = system[0]["content"][: max_chars // 2]
budget = max_chars - sum(len(m["content"]) for m in system)
turns = []
for message in reversed(messages):
if message["role"] not in ("user", "assistant"):
continue
content = message.get("content")
if not isinstance(content, str) or not content:
continue
if len(content) > budget:
if not turns:
turns.append({"role": message["role"], "content": content[-budget:]})
break
turns.append({"role": message["role"], "content": content})
budget -= len(content)
turns.reverse()
while turns and turns[0]["role"] != "user":
turns.pop(0)
return system + turns
def build_request(settings: Settings, messages: list[dict]) -> dict:
return {
"model": settings.model,
"messages": trim_messages(messages),
"think": False,
"stream": True,
"keep_alive": -1,
"options": {
"num_ctx": settings.context_tokens,
"num_predict": 256,
"temperature": 0.6,
},
}
def local_origin_allowed(origin: str, port: int = 7860) -> bool:
return origin in {f"http://localhost:{port}", f"http://127.0.0.1:{port}"}

111
astra/inference.py Normal file
View file

@ -0,0 +1,111 @@
"""Thread-confined model inference. No audio is stored on disk."""
import asyncio
from concurrent.futures import ThreadPoolExecutor
import numpy as np
from astra.core import Settings
async def on_executor(executor, function, *args):
"""Finish an in-flight native call before allowing its owner to be reused."""
future = asyncio.get_running_loop().run_in_executor(executor, function, *args)
try:
return await asyncio.shield(future)
except asyncio.CancelledError:
await asyncio.shield(future)
raise
def drain_stream(stream):
"""Let Pocket TTS join its internal threads before reusing the model.
Its upstream generator only joins on normal exhaustion, not generator.close().
Discard the unplayed remainder while the audio transport stops immediately.
"""
for _ in stream:
pass
class Models:
def __init__(self, settings: Settings):
self.settings = settings
self.stt_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="astra-stt")
self.tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="astra-tts")
self.stt = None
self.tts = None
self.voice = None
def load_stt(self):
from mlx_audio.stt import load
self.stt = load(self.settings.stt_model)
# Ensure this installed MLX version really exposes incremental microphone APIs.
from mlx_audio.stt.models.nemotron_asr.audio import StreamingLogMelSpectrogram # noqa: F401
from mlx_audio.stt.models.nemotron_asr.streaming import (
ConformerStreamingState, # noqa: F401
)
def load_tts(self):
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)
async def close(self):
await asyncio.to_thread(self.stt_executor.shutdown, wait=True, cancel_futures=True)
await asyncio.to_thread(self.tts_executor.shutdown, wait=True, cancel_futures=True)
class Recognizer:
"""One utterance's incremental mel, encoder and RNN-T decoder state.
All methods run on Models.stt_executor, including construction. Encoder and
decoder caches survive microphone chunks and are discarded at utterance end.
"""
def __init__(self, model):
from mlx_audio.stt.models.nemotron_asr.audio import StreamingLogMelSpectrogram
from mlx_audio.stt.models.nemotron_asr.streaming import ConformerStreamingState
self.model = model
self.mel = StreamingLogMelSpectrogram(model.preprocessor_config)
self.encoder = ConformerStreamingState(model.encoder, att_context_size=[56, 3])
self.last_token = model.blank_id
self.hidden = None
self.tokens = []
self.closed = False
def push(self, pcm: bytes, final: bool = False) -> str:
import mlx.core as mx
from mlx_audio.stt.models.nemotron_asr import tokenizer
if self.closed:
raise RuntimeError("Utterance already closed")
samples = mx.array(np.frombuffer(pcm, dtype="<i2").astype(np.float32) / 32768.0)
mel = self.mel.push(samples, final=final)
for encoded in self.encoder.push(mel, final=final):
prompted = self.model.apply_prompt(encoded, "de-DE")
self.encoder.materialize(prompted)
for frame_index in range(prompted.shape[1]):
feature = prompted[:, frame_index : frame_index + 1]
for _ in range(self.model.max_symbols or 10):
token = (
mx.array([[self.last_token]], dtype=mx.int32)
if self.last_token != self.model.blank_id
else None
)
output, (h, c) = self.model.decoder(token, self.hidden)
prediction = int(
mx.argmax(self.model.joint(feature, output.astype(feature.dtype)))
)
if prediction == self.model.blank_id:
break
self.last_token = prediction
self.hidden = (h.astype(feature.dtype), c.astype(feature.dtype))
mx.eval(*self.hidden)
if not tokenizer.is_special_token(prediction, self.model.vocabulary):
self.tokens.append(prediction)
self.closed = final
return tokenizer.decode(self.tokens, self.model.vocabulary).strip()

95
astra/prepare.py Normal file
View file

@ -0,0 +1,95 @@
"""Download and warm local models; never opens the microphone."""
import asyncio
import json
import time
from pathlib import Path
import httpx
from astra.core import Settings, build_request
from astra.inference import Models, Recognizer, on_executor
async def main():
import nltk
nltk_path = Path(__file__).resolve().parent.parent / ".cache" / "nltk"
nltk_path.mkdir(parents=True, exist_ok=True, mode=0o700)
if not nltk.download("punkt_tab", download_dir=str(nltk_path), quiet=True):
raise RuntimeError("Satzsegmentierung konnte nicht heruntergeladen werden")
settings = Settings.from_env()
models = Models(settings)
try:
print("Lade Nemotron für deutsche Streaming-Erkennung …", flush=True)
await on_executor(models.stt_executor, models.load_stt)
recognizer = await on_executor(models.stt_executor, Recognizer, models.stt)
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)
def synthesize():
start = time.monotonic()
chunks = []
first_ms = None
for chunk in models.tts.generate_audio_stream(
models.voice,
"Hallo, ich bin Astra. Ich laufe vollständig auf deinem Mac.",
copy_state=True,
):
if first_ms is None:
first_ms = round((time.monotonic() - start) * 1000)
chunks.append(chunk.numpy())
import numpy as np
samples = np.concatenate(chunks)
return samples, {
"first_audio_ms": first_ms,
"seconds": round(len(samples) / models.tts.sample_rate, 2),
"generation_ms": round((time.monotonic() - start) * 1000),
}
samples, metrics = await on_executor(models.tts_executor, synthesize)
print(f"Pocket TTS: {json.dumps(metrics)}", flush=True)
import numpy as np
from scipy.signal import resample_poly
audio16 = resample_poly(samples, 2, 3)
pcm = (np.clip(audio16, -1, 1) * 32767).astype("<i2").tobytes()
recognizer = await on_executor(models.stt_executor, Recognizer, models.stt)
started = time.monotonic()
partials = []
for offset in range(0, len(pcm), 10240):
text = await on_executor(
models.stt_executor, recognizer.push, pcm[offset : offset + 10240], False
)
if text and (not partials or text != partials[-1]):
partials.append(text)
text = await on_executor(models.stt_executor, recognizer.push, b"", True)
print(f"ASR-Rücktest: {text}", flush=True)
print(
f"ASR: {len(partials)} Live-Zwischenstände, {round((time.monotonic() - started) * 1000)} ms",
flush=True,
)
if "astra" not in text.lower() or "mac" not in text.lower():
raise RuntimeError("Deutscher Sprach-Rücktest fehlgeschlagen")
async with httpx.AsyncClient(timeout=180) as client:
payload = build_request(
settings, [{"role": "user", "content": "Sage kurz Hallo auf Deutsch."}]
)
response = await client.post(f"{settings.ollama_url}/api/chat", json=payload)
response.raise_for_status()
events = [json.loads(line) for line in response.text.splitlines() if line]
if any(e.get("message", {}).get("thinking") for e in events):
raise RuntimeError("Thinking ist nicht deaktiviert")
answer = "".join(e.get("message", {}).get("content", "") for e in events)
if not answer or not events[-1].get("done"):
raise RuntimeError("Ollama hat keine vollständige Antwort geliefert")
print(f"Ollama ohne Thinking: {answer}", flush=True)
print("Alle drei lokalen Modelle erfolgreich geprüft.", flush=True)
finally:
await models.close()
if __name__ == "__main__":
asyncio.run(main())

322
astra/server.py Normal file
View file

@ -0,0 +1,322 @@
"""Loopback-only WebRTC voice app. One live session shares the warm models."""
import asyncio
import contextlib
import os
import sys
from collections import deque
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
import httpx
import uvicorn
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 astra.core import SYSTEM_PROMPT, Settings, build_request, local_origin_allowed
from astra.inference import Models, on_executor
ROOT = Path(__file__).resolve().parent.parent
class Offer(BaseModel):
sdp: str = Field(min_length=10, max_length=65536)
type: Literal["offer"]
class Disconnect(BaseModel):
pc_id: str = Field(max_length=100)
async def run_voice(connection, models, config):
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
InterruptionFrame,
LLMFullResponseStartFrame,
VADUserStartedSpeakingFrame,
)
from pipecat.observers.base_observer import BaseObserver
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
from pipecat.workers.runner import WorkerRunner
from astra.services import LocalPocketTTSService, NativeOllamaService, NemotronSTTService
notify = connection.send_app_message
class UIObserver(BaseObserver):
def __init__(self):
super().__init__()
self.seen = deque(maxlen=128)
async def on_push_frame(self, data):
frame = data.frame
relevant = (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
InterruptionFrame,
LLMFullResponseStartFrame,
VADUserStartedSpeakingFrame,
)
if not isinstance(frame, relevant) or frame.id in self.seen:
return
self.seen.append(frame.id)
if isinstance(frame, BotStartedSpeakingFrame):
notify({"type": "state", "state": "speaking"})
elif isinstance(frame, LLMFullResponseStartFrame):
notify({"type": "state", "state": "responding"})
else:
notify({"type": "state", "state": "listening"})
transport = SmallWebRTCTransport(
connection,
TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
),
)
context = LLMContext([{"role": "system", "content": SYSTEM_PROMPT}])
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(
vad_analyzer=SileroVADAnalyzer(
params=VADParams(
confidence=0.7,
start_secs=0.1,
stop_secs=0.7,
min_volume=0.6,
)
),
),
)
stt = NemotronSTTService(models, notify)
llm = NativeOllamaService(config, notify)
tts = LocalPocketTTSService(models)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
llm,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(audio_in_sample_rate=16000, audio_out_sample_rate=24000),
enable_rtvi=False,
idle_timeout_secs=None,
observers=[UIObserver()],
)
@transport.event_handler("on_client_connected")
async def connected(transport, client):
notify({"type": "state", "state": "listening"})
@transport.event_handler("on_client_disconnected")
async def disconnected(transport, client):
await worker.cancel()
@aggregators.user().event_handler("on_user_turn_stopped")
async def user_turn(aggregator, strategy, message):
if message.content:
notify({"type": "transcript", "role": "user", "text": message.content})
@aggregators.assistant().event_handler("on_assistant_turn_stopped")
async def assistant_turn(aggregator, message):
if message.content:
notify(
{
"type": "transcript",
"role": "assistant",
"text": message.content,
"interrupted": message.interrupted,
}
)
@worker.event_handler("on_pipeline_error")
async def error(worker, frame):
notify({"type": "error", "message": frame.error})
runner = WorkerRunner(handle_sigint=False)
await runner.add_workers(worker)
await runner.run()
def create_app(config=None, *, load_models=True):
config = config or Settings.from_env()
models = Models(config)
status = {"ready": False, "stage": "starting", "error": None}
sessions = {}
lock = asyncio.Lock()
async def warmup():
try:
status["stage"] = "Gesprächssteuerung wird vorbereitet"
def prepare_pipeline():
import nltk
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import (
LocalSmartTurnAnalyzerV3,
)
from pipecat.audio.vad.silero import SileroVADAnalyzer
from astra import services # noqa: F401
nltk.data.path.insert(0, str(ROOT / ".cache" / "nltk"))
nltk.data.find("tokenizers/punkt_tab")
nltk.sent_tokenize("Hallo. Alles bereit.")
LocalSmartTurnAnalyzerV3()
SileroVADAnalyzer()
await asyncio.to_thread(prepare_pipeline)
status["stage"] = "Spracherkennung wird geladen"
await on_executor(models.stt_executor, models.load_stt)
status["stage"] = "Deutsche Stimme wird geladen"
await on_executor(models.tts_executor, models.load_tts)
status["stage"] = "Qwen wird vorbereitet"
async with httpx.AsyncClient(timeout=180) as client:
payload = build_request(config, [{"role": "user", "content": "Sage Hallo."}])
payload["stream"] = False
payload["options"]["num_predict"] = 12
response = await client.post(f"{config.ollama_url}/api/chat", json=payload)
response.raise_for_status()
result = response.json()
if result.get("error") or not result.get("message", {}).get("content"):
raise RuntimeError(f"Ollama: {result.get('error', 'Keine Antwort')}")
if result["message"].get("thinking"):
raise RuntimeError("Ollama hat Thinking nicht ausgeschaltet")
status.update(ready=True, stage="Bereit")
logger.info("Astra bereit auf http://localhost:{}", config.port)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception("Modelle konnten nicht geladen werden")
message = str(exc) or type(exc).__name__
if isinstance(exc, httpx.TimeoutException):
message = "Ollama antwortet nicht rechtzeitig. Bitte den lokalen Modelldienst prüfen."
status.update(ready=False, stage="Start fehlgeschlagen", error=message)
@asynccontextmanager
async def lifespan(app):
warming = asyncio.create_task(warmup()) if load_models else None
try:
yield
finally:
if warming:
warming.cancel()
with contextlib.suppress(asyncio.CancelledError):
await warming
for connection, task in list(sessions.values()):
await connection.disconnect()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await models.close()
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.status = status
@app.middleware("http")
async def local_only(request: Request, call_next):
if request.url.hostname not in {"localhost", "127.0.0.1"}:
return JSONResponse({"detail": "Nur lokal erreichbar"}, status_code=403)
if request.method == "POST" and not local_origin_allowed(
request.headers.get("origin", ""), config.port
):
return JSONResponse({"detail": "Ungültiger Ursprung"}, status_code=403)
response = await call_next(request)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self'; "
"connect-src 'self'; media-src 'self' blob:; img-src 'self' data:; frame-ancestors 'none'"
)
return response
@app.get("/")
async def index():
return FileResponse(ROOT / "web" / "index.html")
@app.get("/api/status")
async def health():
return {**status, "busy": bool(sessions), "model": config.model, "thinking": False}
@app.post("/api/offer")
async def offer(body: Offer):
if not status["ready"]:
raise HTTPException(503, status["error"] or status["stage"])
async with lock:
if sessions:
raise HTTPException(
409, "Ein Gespräch läuft bereits. Beende es im anderen Fenster."
)
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
connection = SmallWebRTCConnection(ice_servers=[], connection_timeout_secs=20)
try:
await connection.initialize(body.sdp, body.type)
except Exception:
await connection.disconnect()
raise
async def session():
try:
await run_voice(connection, models, config)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception("Gespräch fehlgeschlagen")
connection.send_app_message({"type": "error", "message": str(exc)})
finally:
await connection.disconnect()
sessions.pop(connection.pc_id, None)
task = asyncio.create_task(session())
sessions[connection.pc_id] = (connection, task)
return connection.get_answer()
@app.post("/api/disconnect")
async def disconnect(body: Disconnect):
pair = sessions.get(body.pc_id)
if pair:
connection, task = pair
await connection.disconnect()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return {"ok": True}
app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static")
return app
def main():
os.environ.setdefault("HF_HUB_OFFLINE", "1")
logger.remove()
logger.add(sys.stderr, level=os.getenv("ASTRA_LOG_LEVEL", "INFO"))
config = Settings.from_env()
uvicorn.run(create_app(config), host="127.0.0.1", port=config.port, access_log=False)
if __name__ == "__main__":
main()

262
astra/services.py Normal file
View file

@ -0,0 +1,262 @@
"""Pipecat adapters for native Ollama, live Nemotron ASR, and Pocket TTS."""
import asyncio
import json
import time
from contextlib import aclosing
import httpx
from loguru import logger
from openai.types.chat import ChatCompletionChunk
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
ErrorFrame,
Frame,
InputAudioRawFrame,
InterimTranscriptionFrame,
StartFrame,
TranscriptionFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.settings import STTSettings, TTSSettings
from pipecat.services.stt_service import STTService
from pipecat.services.tts_service import TTSService
from pipecat.utils.time import time_now_iso8601
from astra.core import Settings, build_request
from astra.inference import Models, Recognizer, drain_stream, on_executor
class NativeOllamaService(OpenAILLMService):
"""Use /api/chat so think=False cannot be lost in compatibility translation."""
supports_developer_role = False
def __init__(self, config: Settings, notify):
super().__init__(
api_key="local",
base_url=f"{config.ollama_url}/v1",
settings=self.Settings(model=config.model),
)
self.config = config
self.notify = notify
async def get_chat_completions(self, context):
payload = build_request(self.config, context.get_messages())
context.set_messages(payload["messages"])
async def chunks():
start = time.monotonic()
first = True
complete = False
async with httpx.AsyncClient(timeout=httpx.Timeout(90, connect=5)) as client:
async with client.stream(
"POST", f"{self.config.ollama_url}/api/chat", json=payload
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
event = json.loads(line)
if event.get("error"):
raise RuntimeError(event["error"])
message = event.get("message", {})
if message.get("thinking"):
raise RuntimeError("Ollama liefert Thinking trotz think=false.")
content = message.get("content", "")
if content:
if first:
self.notify(
{
"type": "metric",
"name": "llm_ms",
"value": round((time.monotonic() - start) * 1000),
}
)
first = False
yield ChatCompletionChunk(
id="local",
object="chat.completion.chunk",
created=int(time.time()),
model=self.config.model,
choices=[
{
"index": 0,
"delta": {"content": content},
"finish_reason": None,
}
],
)
if event.get("done"):
complete = True
if not complete:
raise RuntimeError("Ollama hat den Antwortstream vorzeitig geschlossen.")
return chunks()
class NemotronSTTService(STTService):
def __init__(self, models: Models, notify):
super().__init__(
sample_rate=16000,
audio_passthrough=True,
ttfs_p99_latency=1.0,
settings=STTSettings(model=models.settings.stt_model, language="de-DE"),
)
self.models = models
self.notify = notify
self.queue = asyncio.Queue(maxsize=100)
self.worker = None
self.active = False
self.preroll = bytearray()
self.pending = bytearray()
self.failed = False
async def run_stt(self, audio):
# Audio is fed by process_audio_frame to avoid blocking VAD on inference.
if False:
yield None
async def start(self, frame: StartFrame):
await super().start(frame)
self.worker = self.create_task(self._worker())
async def stop(self, frame: EndFrame):
if self.worker:
await self.cancel_task(self.worker)
self.worker = None
await super().stop(frame)
async def cancel(self, frame: CancelFrame):
if self.worker:
await self.cancel_task(self.worker)
self.worker = None
await super().cancel(frame)
def _enqueue(self, item):
if self.failed:
return
try:
self.queue.put_nowait(item)
except asyncio.QueueFull:
self.failed = True
self.notify(
{"type": "error", "message": "Spracherkennung überlastet. Bitte neu verbinden."}
)
async def process_audio_frame(self, frame: InputAudioRawFrame, direction: FrameDirection):
if frame.sample_rate != 16000 or frame.num_channels != 1:
raise ValueError("Nemotron benötigt 16 kHz Mono-PCM")
if self.active:
self.pending.extend(frame.audio)
while len(self.pending) >= 10240:
self._enqueue(("audio", bytes(self.pending[:10240])))
del self.pending[:10240]
else:
self.preroll.extend(frame.audio)
del self.preroll[:-6400] # 200 ms, includes VAD start delay.
async def process_frame(self, frame: Frame, direction: FrameDirection):
if isinstance(frame, VADUserStartedSpeakingFrame) and not self.active:
self.active = True
self._enqueue(("start", bytes(self.preroll)))
self.preroll.clear()
elif isinstance(frame, VADUserStoppedSpeakingFrame) and self.active:
self.active = False
self._enqueue(("final", bytes(self.pending)))
self.pending.clear()
await super().process_frame(frame, direction)
async def _worker(self):
recognizer = None
previous = ""
try:
while True:
kind, pcm = await self.queue.get()
logger.debug("Nemotron chunk: {}, {} bytes", kind, len(pcm))
if kind == "start":
recognizer = await on_executor(
self.models.stt_executor, Recognizer, self.models.stt
)
previous = ""
if recognizer is None:
continue
text = await on_executor(
self.models.stt_executor, recognizer.push, pcm, kind == "final"
)
logger.debug("Nemotron result: {}, {} characters", kind, len(text))
if kind == "final":
if text:
await self.push_frame(
TranscriptionFrame(
text=text,
user_id="local",
timestamp=time_now_iso8601(),
finalized=True,
)
)
self.notify({"type": "partial", "text": ""})
recognizer = None
elif text and text != previous:
await self.push_frame(
InterimTranscriptionFrame(
text=text, user_id="local", timestamp=time_now_iso8601()
)
)
self.notify({"type": "partial", "text": text})
previous = text
except asyncio.CancelledError:
raise
except Exception as exc:
self.failed = True
self.notify({"type": "error", "message": f"Spracherkennung: {exc}"})
await self.push_error_frame(ErrorFrame(error=f"Nemotron: {exc}", fatal=True))
def next_chunk(stream):
return next(stream, None)
class LocalPocketTTSService(TTSService):
def __init__(self, models: Models):
super().__init__(
push_start_frame=True,
push_stop_frames=True,
settings=TTSSettings(model="pocket-tts", voice=models.settings.voice, language="de"),
)
self.models = models
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)
async def audio():
try:
while True:
chunk = await on_executor(self.models.tts_executor, next_chunk, stream)
if chunk is None:
break
yield (chunk.clamp(-1, 1) * 32767).to(torch.int16).numpy().tobytes()
finally:
await on_executor(self.models.tts_executor, drain_stream, stream)
try:
async with aclosing(audio()) as audio_stream:
async with aclosing(
self._stream_audio_frames_from_iterator(
audio_stream,
in_sample_rate=self.models.tts.sample_rate,
context_id=context_id,
)
) as frames:
async for frame in frames:
yield frame
except asyncio.CancelledError:
raise
except Exception as exc:
yield ErrorFrame(error=f"Sprachausgabe: {exc}")

25
pyproject.toml Normal file
View file

@ -0,0 +1,25 @@
[project]
name = "astra-local-voice"
version = "0.1.0"
description = "Lokaler deutscher Sprachagent für Apple Silicon und Ollama"
requires-python = ">=3.12,<3.13"
dependencies = [
"pipecat-ai[silero,webrtc,pocket-tts]",
"mlx-audio",
"fastapi",
"uvicorn",
"httpx",
]
[dependency-groups]
dev = ["pytest", "pytest-asyncio", "pytest-cov", "playwright", "ruff"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "B", "UP"]

92
scripts/browser_check.py Normal file
View file

@ -0,0 +1,92 @@
"""Exercise actual WebRTC with synthetic German mic audio, never the real mic."""
import asyncio
import json
from pathlib import Path
import numpy as np
from playwright.async_api import async_playwright, expect
from scipy.io.wavfile import write
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ROOT / ".runtime"
def make_fixture():
from pocket_tts import TTSModel
model = TTSModel.load_model(language="german")
voice = model.get_state_for_audio_prompt("alba")
samples = model.generate_audio(voice, "Hallo Astra. Wie heißt du?").numpy()
# The initial silence lets the pipeline finish establishing WebRTC.
audio = np.concatenate([np.zeros(24000 * 15), samples, np.zeros(24000 * 35)])
ARTIFACTS.mkdir(exist_ok=True)
path = ARTIFACTS / "test-microphone.wav"
write(path, 24000, (np.clip(audio, -1, 1) * 32767).astype(np.int16))
return path
async def main():
fixture = ARTIFACTS / "test-microphone.wav"
if not fixture.exists():
fixture = await asyncio.to_thread(make_fixture)
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(
executable_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless=True,
args=[
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
f"--use-file-for-fake-audio-capture={fixture}%noloop",
"--autoplay-policy=no-user-gesture-required",
],
)
page = await browser.new_page(viewport={"width": 1440, "height": 1000})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
await page.goto("http://localhost:7860")
await expect(page.locator("#connect")).to_be_enabled(timeout=120000)
await page.screenshot(path=str(ARTIFACTS / "desktop.png"))
await page.get_by_role("button", name="Gespräch starten").click()
try:
await page.locator(".message.assistant").wait_for(timeout=60000)
transcript = await page.locator("#messages").inner_text()
audio_stats = await page.evaluate("""async () => {
const reports = await peer.getStats();
return [...reports.values()].filter(report =>
report.type === 'inbound-rtp' && report.kind === 'audio'
).map(report => ({
packets: report.packetsReceived,
energy: report.totalAudioEnergy,
duration: report.totalSamplesDuration,
}));
}""")
assert any(report.get("energy", 0) > 0 for report in audio_stats), audio_stats
assert await page.evaluate("!output.paused && output.currentTime > 0")
await page.get_by_role("button", name="Mikrofon pausieren").click()
assert await page.locator("#mute").get_attribute("aria-pressed") == "true"
await page.get_by_role("button", name="Mikrofon aktivieren").click()
await page.get_by_role("button", name="Gespräch beenden").click()
assert await page.locator("#mute").is_hidden()
await page.get_by_role("button", name="Verlauf leeren").click()
assert await page.locator(".message").count() == 0
await page.set_viewport_size({"width": 390, "height": 844})
await page.screenshot(path=str(ARTIFACTS / "mobile.png"))
assert await page.evaluate("document.documentElement.scrollWidth <= innerWidth")
assert not errors, errors
print(
json.dumps(
{"passed": True, "transcript": transcript,
"audio": audio_stats, "js_errors": errors},
ensure_ascii=False,
)
)
finally:
await page.screenshot(path=str(ARTIFACTS / "last-test.png"))
print("Browser status:", await page.locator("#status").inner_text())
print("Browser error:", await page.locator("#error").inner_text())
await browser.close()
if __name__ == "__main__":
asyncio.run(main())

51
tests/test_core.py Normal file
View file

@ -0,0 +1,51 @@
import unittest
from astra.core import Settings, build_request, local_origin_allowed, trim_messages
class CoreTests(unittest.TestCase):
def test_thinking_is_disabled_in_every_native_request(self):
request = build_request(Settings(), [{"role": "user", "content": "Hallo"}])
self.assertIs(request["think"], False)
self.assertIs(request["stream"], True)
self.assertEqual(request["model"], "qwen3.5:latest")
self.assertNotIn("think", request["options"])
self.assertEqual(request["options"]["num_ctx"], 4096)
def test_history_stays_bounded_and_preserves_system_and_latest_user(self):
messages = [{"role": "system", "content": "Deutsch"}]
for i in range(40):
messages.extend(
[
{"role": "user", "content": f"Frage {i}"},
{"role": "assistant", "content": "Antwort " * 100},
]
)
messages.append({"role": "user", "content": "Neueste Frage"})
trimmed = trim_messages(messages, max_chars=5000)
self.assertEqual(trimmed[0], messages[0])
self.assertEqual(trimmed[1]["role"], "user")
self.assertEqual(trimmed[-1], messages[-1])
self.assertLessEqual(sum(len(m["content"]) for m in trimmed), 5000)
self.assertEqual(len(messages), 82)
def test_rejects_foreign_web_origins(self):
self.assertTrue(local_origin_allowed("http://localhost:7860"))
self.assertTrue(local_origin_allowed("http://127.0.0.1:7860"))
self.assertFalse(local_origin_allowed("https://evil.example"))
self.assertFalse(local_origin_allowed("http://localhost.evil.example:7860"))
self.assertFalse(local_origin_allowed("null"))
def test_huge_last_message_is_bounded(self):
trimmed = trim_messages(
[
{"role": "system", "content": "Deutsch"},
{"role": "user", "content": "x" * 20000},
],
max_chars=5000,
)
self.assertLessEqual(sum(len(m["content"]) for m in trimmed), 5000)
if __name__ == "__main__":
unittest.main()

55
tests/test_inference.py Normal file
View file

@ -0,0 +1,55 @@
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
import pytest
from astra.inference import drain_stream, on_executor
@pytest.mark.asyncio
async def test_cancel_waits_for_native_inference_before_releasing_model():
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def inference():
started.set()
release.wait(timeout=5)
finished.set()
return "audio"
with ThreadPoolExecutor(max_workers=1) as executor:
task = asyncio.create_task(on_executor(executor, inference))
await asyncio.to_thread(started.wait, 3)
task.cancel()
await asyncio.sleep(0.01)
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert finished.is_set()
@pytest.mark.asyncio
async def test_native_exception_is_visible():
def fail():
raise RuntimeError("Model failed")
with ThreadPoolExecutor(max_workers=1) as executor:
with pytest.raises(RuntimeError, match="Model failed"):
await on_executor(executor, fail)
def test_abandoned_tts_stream_reaches_normal_join_before_reuse():
joined = []
def threaded_stream():
yield b"first audio"
yield b"remaining audio"
joined.append(True) # Represents Pocket TTS's join after its yield loop.
stream = threaded_stream()
next(stream)
drain_stream(stream)
assert joined == [True]

48
tests/test_ollama.py Normal file
View file

@ -0,0 +1,48 @@
import json
from unittest.mock import patch
import httpx
import pytest
from pipecat.processors.aggregators.llm_context import LLMContext
from astra.core import Settings
from astra.services import NativeOllamaService
@pytest.mark.asyncio
async def test_native_stream_sends_think_false_and_returns_content():
requests = []
def handler(request):
requests.append(json.loads(request.content))
return httpx.Response(200, text='{"message":{"content":"Hallo"}}\n{"done":true}\n')
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
service = NativeOllamaService(Settings(), lambda message: None)
with patch("astra.services.httpx.AsyncClient", return_value=client):
stream = await service.get_chat_completions(LLMContext([{"role": "user", "content": "Hi"}]))
chunks = [chunk async for chunk in stream]
assert requests[0]["think"] is False
assert chunks[0].choices[0].delta.content == "Hallo"
await service._client.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body",
[
'{"message":{"thinking":"secret reasoning"}}\n',
'{"error":"out of memory"}\n',
'{"message":{"content":"incomplete"}}\n',
],
)
async def test_bad_ollama_streams_fail_explicitly(body):
client = httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: httpx.Response(200, text=body))
)
service = NativeOllamaService(Settings(), lambda message: None)
with patch("astra.services.httpx.AsyncClient", return_value=client):
stream = await service.get_chat_completions(LLMContext([{"role": "user", "content": "Hi"}]))
with pytest.raises(RuntimeError):
_ = [chunk async for chunk in stream]
await service._client.close()

45
tests/test_server.py Normal file
View file

@ -0,0 +1,45 @@
from fastapi.testclient import TestClient
from astra.server import create_app
def test_status_and_static_ui_work_before_models_are_ready():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
status = client.get("/api/status")
assert status.status_code == 200
assert status.json()["thinking"] is False
assert status.json()["ready"] is False
page = client.get("/")
assert "Einfach aussprechen" in page.text
assert "frame-ancestors 'none'" in page.headers["content-security-policy"]
assert client.get("/static/app.js").status_code == 200
def test_other_websites_cannot_open_a_microphone_session():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
for origin in ["https://evil.example", "null", "http://localhost.evil.example:7860"]:
response = client.post("/api/offer", headers={"Origin": origin}, json={})
assert response.status_code == 403
assert client.post("/api/offer", json={}).status_code == 403
def test_unready_returns_actionable_status_and_invalid_sdp_is_rejected():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
headers = {"Origin": "http://localhost:7860"}
assert client.post("/api/offer", headers=headers, json={}).status_code == 422
response = client.post(
"/api/offer", headers=headers, json={"sdp": "a" * 20, "type": "offer"}
)
assert response.status_code == 503
assert response.json()["detail"]
def test_disconnect_is_idempotent_and_host_is_checked():
with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client:
response = client.post(
"/api/disconnect",
headers={"Origin": "http://localhost:7860"},
json={"pc_id": "already-gone"},
)
assert response.json() == {"ok": True}
assert client.get("/api/status", headers={"Host": "evil.example"}).status_code == 403

28
tests/test_stt.py Normal file
View file

@ -0,0 +1,28 @@
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.frame_processor import FrameDirection
from astra.core import Settings
from astra.inference import Models
from astra.services import NemotronSTTService
@pytest.mark.asyncio
async def test_recognition_failure_reaches_pipeline_upstream_and_notifies_browser():
models = Models(Settings())
notices = []
service = NemotronSTTService(models, notices.append)
service.push_frame = AsyncMock()
try:
service.queue.put_nowait(("start", b""))
with patch("astra.services.Recognizer", side_effect=RuntimeError("Model failed")):
await service._worker()
assert service.failed
assert notices[0]["type"] == "error"
assert "Model failed" in notices[0]["message"]
frame, direction = service.push_frame.call_args.args
assert frame.fatal
assert direction == FrameDirection.UPSTREAM
finally:
await models.close()

178
web/app.js Normal file
View file

@ -0,0 +1,178 @@
"use strict";
const $ = (id) => document.getElementById(id);
const labels = {listening: "Ich höre dir zu.", responding: "Einen Moment …", speaking: "Astra spricht. Du kannst jederzeit unterbrechen."};
let peer = null, stream = null, channel = null, pcId = null, heartbeat = null;
let connecting = false, disconnecting = false, muted = false, ready = false;
const output = new Audio();
output.autoplay = true;
function state(value) {
document.body.dataset.state = value;
$("status").textContent = muted ? "Mikrofon pausiert" : (labels[value] || value);
}
function showError(message) {
$("error").textContent = message;
$("error").hidden = false;
}
function addMessage(event) {
$("messages").querySelector(".empty")?.remove();
const article = document.createElement("article");
article.className = `message ${event.role === "user" ? "user" : "assistant"}`;
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = event.role === "user" ? "Du" : "Astra";
const text = document.createElement("p");
text.textContent = event.text;
article.append(speaker, text);
if (event.interrupted) {
const note = document.createElement("small");
note.textContent = "Unterbrochen";
article.append(note);
}
$("messages").append(article);
while ($("messages").children.length > 80) $("messages").firstElementChild.remove();
$("messages").scrollTop = $("messages").scrollHeight;
}
function receive(event) {
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "state") state(message.state);
if (message.type === "partial") $("partial").textContent = message.text;
if (message.type === "transcript") addMessage(message);
if (message.type === "error") showError(message.message);
if (message.type === "metric" && message.name === "llm_ms") {
$("latency").textContent = `Erstes Antwortwort · ${(message.value / 1000).toFixed(2)} s`;
}
}
async function request(path, body, timeout = 20000) {
const response = await fetch(path, {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(body), signal: AbortSignal.timeout(timeout)});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : "Verbindung fehlgeschlagen.");
return data;
}
async function waitIce(pc) {
if (pc.iceGatheringState === "complete") return;
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error("Lokaler Verbindungsaufbau dauert zu lange.")); }, 10000);
function cleanup() { clearTimeout(timeout); pc.removeEventListener("icegatheringstatechange", check); }
function check() { if (pc.iceGatheringState === "complete") { cleanup(); resolve(); } }
pc.addEventListener("icegatheringstatechange", check);
check();
});
}
async function connect() {
if (connecting || peer) return;
connecting = true;
$("error").hidden = true;
$("connect").disabled = true;
state("Mikrofon wird verbunden …");
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error("Bitte diese Seite unter http://localhost:7860 öffnen.");
stream = await navigator.mediaDevices.getUserMedia({audio: {
echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1,
}, video: false});
const pc = new RTCPeerConnection({iceServers: []});
peer = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
channel = pc.createDataChannel("astra");
channel.onmessage = receive;
channel.onopen = () => {
heartbeat = setInterval(() => { if (channel?.readyState === "open") channel.send("ping"); }, 1000);
};
pc.ontrack = (event) => {
output.srcObject = event.streams[0] || new MediaStream([event.track]);
output.play().catch(() => showError("Audioausgabe blockiert. Bitte die Audiowiedergabe im Browser erlauben und neu starten."));
};
pc.onconnectionstatechange = () => {
if (peer !== pc) return;
if (pc.connectionState === "connected") state("listening");
if (["failed", "disconnected", "closed"].includes(pc.connectionState) && !disconnecting) {
showError("Verbindung beendet. Du kannst das Gespräch erneut starten.");
void disconnect();
}
};
await pc.setLocalDescription(await pc.createOffer());
await waitIce(pc);
const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer"});
pcId = answer.pc_id;
await pc.setRemoteDescription({sdp: answer.sdp, type: answer.type});
$("connect").textContent = "Gespräch beenden";
$("mute").hidden = false;
$("clear").textContent = "Neues Gespräch";
$("hint").textContent = "Sprich frei. Beim Dazwischenreden hält Astra an.";
} catch (error) {
await disconnect();
const messages = {NotAllowedError: "Mikrofonzugriff nicht erlaubt. Bitte in den Browser-Einstellungen freigeben.",
NotFoundError: "Kein Mikrofon gefunden. Bitte ein Mikrofon anschließen.",
NotReadableError: "Das Mikrofon ist gerade nicht verfügbar."};
showError(messages[error.name] || error.message);
} finally {
connecting = false;
$("connect").disabled = !ready;
}
}
async function disconnect() {
if (disconnecting) return;
disconnecting = true;
const id = pcId;
pcId = null;
clearInterval(heartbeat);
heartbeat = null;
stream?.getTracks().forEach(track => track.stop());
stream = null;
const oldPeer = peer;
peer = null;
channel?.close();
channel = null;
oldPeer?.close();
output.pause();
output.srcObject = null;
muted = false;
$("mute").hidden = true;
$("mute").setAttribute("aria-pressed", "false");
$("mute").textContent = "Mikrofon pausieren";
$("connect").textContent = "Gespräch starten";
$("clear").textContent = "Verlauf leeren";
$("partial").textContent = "";
$("hint").textContent = "Mikrofon ist aus. Beim nächsten Start beginnt ein neues Gespräch.";
state("Bereit, wenn du es bist.");
try { if (id) await request("/api/disconnect", {pc_id: id}, 10000); }
catch { showError("Der Server hat das Beenden nicht bestätigt. Das Mikrofon ist aus; bitte kurz warten, bevor du neu startest."); }
finally { disconnecting = false; }
}
$("connect").addEventListener("click", () => { if (peer) void disconnect(); else void connect(); });
$("mute").addEventListener("click", () => {
muted = !muted;
stream?.getAudioTracks().forEach(track => { track.enabled = !muted; });
$("mute").setAttribute("aria-pressed", String(muted));
$("mute").textContent = muted ? "Mikrofon aktivieren" : "Mikrofon pausieren";
state("listening");
});
$("clear").addEventListener("click", async () => {
const reconnect = Boolean(peer);
if (reconnect) await disconnect();
$("messages").replaceChildren();
if (reconnect) await connect();
});
window.addEventListener("pagehide", () => {
stream?.getTracks().forEach(track => track.stop());
peer?.close();
});
async function poll() {
try {
const response = await fetch("/api/status", {signal: AbortSignal.timeout(5000)});
if (!response.ok) throw new Error("Status nicht verfügbar");
const status = await response.json();
ready = status.ready;
if (!peer && !connecting) {
$("connect").disabled = !ready;
state(ready ? "Bereit, wenn du es bist." : status.stage);
if (status.error) showError(status.error);
}
} catch {
ready = false;
if (!peer) { $("connect").disabled = true; state("Lokaler Server nicht erreichbar."); }
} finally { setTimeout(poll, ready ? 5000 : 1500); }
}
void poll();

30
web/index.html Normal file
View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#111820">
<title>Astra · Dein lokaler Sprachraum</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<main>
<header><a class="brand" href="/" aria-label="Astra Startseite"><span class="star"></span> astra</a><span class="local"><i></i> Nur auf deinem Mac</span></header>
<section class="conversation" aria-labelledby="title">
<div class="intro"><p class="eyebrow">DEIN LOKALER SPRACHRAUM</p><h1 id="title">Einfach aussprechen.</h1><p class="description">Gedanken sortieren. Fragen stellen. Gemeinsam weiterdenken.</p></div>
<div class="orb-wrap" aria-hidden="true"><div class="orbit"></div><div class="orb"><div class="wave"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div></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="error" class="error" role="alert" hidden></p>
<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>
</section>
<section class="transcript" aria-labelledby="transcript-title">
<div class="section-top"><h2 id="transcript-title">Unser Gespräch</h2><button id="clear" class="text-button">Verlauf leeren</button></div>
<div id="messages" role="log" aria-live="polite" aria-relevant="additions"><p class="empty">Hier erscheinen deine Worte und Astras Antworten.<br>Der Verlauf bleibt nur für dieses Gespräch im Speicher.</p></div>
</section>
<footer><span>Deutsch <b>·</b> Lokale Spracherkennung & Stimme</span><span id="latency">Bereit für deine Gedanken</span></footer>
</main>
</body>
</html>

3
web/style.css Normal file
View file

@ -0,0 +1,3 @@
@font-face{font-family:system;src:local("Avenir Next")}
:root{color-scheme:dark;--bg:#111820;--panel:#19222b;--text:#eef2ef;--muted:#a3b1b8;--accent:#b4e5cc;--border:#303c44}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% 25%,#1c2d34 0%,var(--bg) 58%);color:var(--text);font-family:system,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}main{max-width:1060px;margin:auto;padding:36px 48px 25px}header{display:flex;justify-content:space-between;align-items:center}.brand{text-decoration:none;color:var(--text);font-size:30px;letter-spacing:-1.5px;font-weight:600}.star{color:var(--accent);font-size:35px;vertical-align:middle;margin-right:7px}.local{display:flex;align-items:center;gap:9px;font-size:12px;color:#c3d3ce;border:1px solid var(--border);padding:9px 13px;border-radius:30px}.local i{height:6px;width:6px;background:var(--accent);border-radius:50%}.conversation{text-align:center;padding:60px 0 34px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:2.8px;color:var(--accent);margin-bottom:17px}h1{font-size:clamp(32px,5vw,49px);font-weight:500;letter-spacing:-1.8px;margin:0 0 13px}.description{font-size:14px;color:var(--muted);margin:0}.orb-wrap{position:relative;width:210px;height:210px;display:grid;place-items:center;margin:31px auto 15px}.orb{width:145px;height:145px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,#a3e1cf77,#4e938a22 55%,#172b3544);border:1px solid #a3d3c35c;box-shadow:inset 0 0 38px #92e1ce19,0 0 65px #84c5b215}.orbit{position:absolute;inset:6px;border:1px solid #9acdb01b;border-radius:50%;box-shadow:0 0 0 16px #98cdb104}.wave{height:40px;display:flex;align-items:center;gap:5px}.wave i{display:block;width:4px;height:13px;border-radius:5px;background:#d0f3e4;transition:height .3s}.wave i:nth-child(2),.wave i:nth-child(6){height:22px}.wave i:nth-child(3),.wave i:nth-child(5){height:32px}.wave i:nth-child(4){height:41px}body[data-state="speaking"] .wave i,body[data-state="listening"] .wave i{animation:voice 1.2s ease-in-out infinite alternate}.wave i:nth-child(2n){animation-delay:-.8s!important}.wave i:nth-child(3n){animation-delay:-.4s!important}@keyframes voice{to{height:8px;opacity:.6}}.live-status{display:flex;justify-content:center;align-items:center;gap:8px;height:27px}.live-status p{font-size:14px;margin:0}#state-dot{height:6px;width:6px;border-radius:50%;background:var(--muted)}body[data-state="listening"] #state-dot,body[data-state="speaking"] #state-dot{background:var(--accent)}.partial{color:var(--muted);font-size:13px;min-height:22px;max-width:640px;margin:13px auto 17px;overflow-wrap:anywhere}.controls{display:flex;justify-content:center;gap:12px;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:0;transition:background .2s,transform .2s}button:active{transform:translateY(1px)}button:focus-visible,a:focus-visible{outline:3px solid #d5f3e5;outline-offset:5px}.primary{background:var(--accent);color:#162c25;border-radius:9px;font-size:14px;font-weight:600;padding:15px 24px;min-width:211px}.primary:hover{background:#cef2df}.primary span{margin-right:9px}.primary:disabled{background:#43594f;color:#bfcbc4;cursor:wait}.secondary{border:1px solid #576b68;border-radius:9px;padding:14px 18px;background:transparent;color:var(--text);font-size:13px}.secondary[aria-pressed="true"]{background:#553a38;border-color:#c2887c}.hint{font-size:11px;color:var(--muted);margin:16px 0 0}.error{color:#ffd5cd;background:#4a2c2a;border:1px solid #8e564f;border-radius:9px;padding:12px;max-width:640px;margin:15px auto;font-size:13px;overflow-wrap:anywhere}.transcript{border:1px solid var(--border);border-radius:14px;background:#19222b9c;padding:22px 26px}.section-top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);padding-bottom:16px}h2{margin:0;font-size:13px;font-weight:500}.text-button{color:var(--muted);background:none;font-size:11px;padding:7px}.text-button:hover{color:var(--text)}#messages{max-height:330px;overflow:auto;scroll-behavior:smooth;padding-top:5px}.empty{font-size:12px;color:var(--muted);line-height:1.9;text-align:center;padding:18px 0 12px}.message{padding:15px 0 7px}.message .speaker{font-size:10px;letter-spacing:1px;font-weight:600;text-transform:uppercase;color:var(--accent)}.message.user .speaker{color:#b1bdcb}.message p{font-size:14px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.message small{color:var(--muted);font-size:11px}footer{display:flex;justify-content:space-between;gap:15px;margin-top:21px;color:var(--muted);font-size:10px}footer b{padding:0 6px;font-weight:400}[hidden]{display:none!important}@media(max-width:620px){main{padding:23px 20px}.conversation{padding-top:44px}.description{max-width:270px;margin:auto;line-height:1.6}.local{font-size:10px;padding:8px 10px}.transcript{padding:18px}footer{flex-direction:column;gap:8px}.orb-wrap{margin-top:21px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}