feat: initial commit of astra local voice agent

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
This commit is contained in:
Jeuner 2026-09-07 13:39:51 +02:00
commit a42b3e8d73
17 changed files with 1436 additions and 0 deletions

1
astra/__init__.py Normal file
View file

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

80
astra/core.py Normal file
View file

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

111
astra/inference.py Normal file
View file

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

95
astra/prepare.py Normal file
View file

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

322
astra/server.py Normal file
View file

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

262
astra/services.py Normal file
View file

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