mirror of
https://github.com/Jeuners/astra-local-voice.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
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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue