commit a42b3e8d737063441571daa51c3eb127c70ec0da Author: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Mon Sep 7 13:39:51 2026 +0200 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 Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3e0a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +.cache/ +.runtime/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.log +.DS_Store diff --git a/astra/__init__.py b/astra/__init__.py new file mode 100644 index 0000000..e387b61 --- /dev/null +++ b/astra/__init__.py @@ -0,0 +1 @@ +"""Astra: a local German voice companion for Apple Silicon.""" diff --git a/astra/core.py b/astra/core.py new file mode 100644 index 0000000..27406c6 --- /dev/null +++ b/astra/core.py @@ -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}"} diff --git a/astra/inference.py b/astra/inference.py new file mode 100644 index 0000000..f1f81fa --- /dev/null +++ b/astra/inference.py @@ -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="= 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}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a5d407 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/scripts/browser_check.py b/scripts/browser_check.py new file mode 100644 index 0000000..b0ca3c4 --- /dev/null +++ b/scripts/browser_check.py @@ -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()) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..655a703 --- /dev/null +++ b/tests/test_core.py @@ -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() diff --git a/tests/test_inference.py b/tests/test_inference.py new file mode 100644 index 0000000..89fbfea --- /dev/null +++ b/tests/test_inference.py @@ -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] diff --git a/tests/test_ollama.py b/tests/test_ollama.py new file mode 100644 index 0000000..339f136 --- /dev/null +++ b/tests/test_ollama.py @@ -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() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..892bd41 --- /dev/null +++ b/tests/test_server.py @@ -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 diff --git a/tests/test_stt.py b/tests/test_stt.py new file mode 100644 index 0000000..a3105d6 --- /dev/null +++ b/tests/test_stt.py @@ -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() diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..461e168 --- /dev/null +++ b/web/app.js @@ -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(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..55b3108 --- /dev/null +++ b/web/index.html @@ -0,0 +1,30 @@ + + + + + + + Astra · Dein lokaler Sprachraum + + + + +
+
astra Nur auf deinem Mac
+
+

DEIN LOKALER SPRACHRAUM

Einfach aussprechen.

Gedanken sortieren. Fragen stellen. Gemeinsam weiterdenken.

+ +

Modelle werden vorbereitet …

+

+ +
+

Mikrofon wird erst nach dem Start freigegeben.

+
+
+

Unser Gespräch

+

Hier erscheinen deine Worte und Astras Antworten.
Der Verlauf bleibt nur für dieses Gespräch im Speicher.

+
+ +
+ + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..b51fc94 --- /dev/null +++ b/web/style.css @@ -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}}