mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
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:
commit
a42b3e8d73
17 changed files with 1436 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.venv/
|
||||
.cache/
|
||||
.runtime/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.log
|
||||
.DS_Store
|
||||
1
astra/__init__.py
Normal file
1
astra/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Astra: a local German voice companion for Apple Silicon."""
|
||||
80
astra/core.py
Normal file
80
astra/core.py
Normal 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
111
astra/inference.py
Normal 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
95
astra/prepare.py
Normal 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
322
astra/server.py
Normal 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
262
astra/services.py
Normal 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
25
pyproject.toml
Normal 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
92
scripts/browser_check.py
Normal 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
51
tests/test_core.py
Normal 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
55
tests/test_inference.py
Normal 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
48
tests/test_ollama.py
Normal 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
45
tests/test_server.py
Normal 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
28
tests/test_stt.py
Normal 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
178
web/app.js
Normal 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
30
web/index.html
Normal 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
3
web/style.css
Normal 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}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue