mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
feat: initial commit of astra local voice agent
Lokaler deutscher Sprachagent für Apple Silicon: Pipecat-Pipeline mit Nemotron-ASR (MLX), Qwen über natives Ollama /api/chat, und Pocket TTS. Loopback-only WebRTC-Server mit Origin/Host-Härtung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
This commit is contained in:
commit
a42b3e8d73
17 changed files with 1436 additions and 0 deletions
178
web/app.js
Normal file
178
web/app.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const labels = {listening: "Ich höre dir zu.", responding: "Einen Moment …", speaking: "Astra spricht. Du kannst jederzeit unterbrechen."};
|
||||
let peer = null, stream = null, channel = null, pcId = null, heartbeat = null;
|
||||
let connecting = false, disconnecting = false, muted = false, ready = false;
|
||||
const output = new Audio();
|
||||
output.autoplay = true;
|
||||
|
||||
function state(value) {
|
||||
document.body.dataset.state = value;
|
||||
$("status").textContent = muted ? "Mikrofon pausiert" : (labels[value] || value);
|
||||
}
|
||||
function showError(message) {
|
||||
$("error").textContent = message;
|
||||
$("error").hidden = false;
|
||||
}
|
||||
function addMessage(event) {
|
||||
$("messages").querySelector(".empty")?.remove();
|
||||
const article = document.createElement("article");
|
||||
article.className = `message ${event.role === "user" ? "user" : "assistant"}`;
|
||||
const speaker = document.createElement("span");
|
||||
speaker.className = "speaker";
|
||||
speaker.textContent = event.role === "user" ? "Du" : "Astra";
|
||||
const text = document.createElement("p");
|
||||
text.textContent = event.text;
|
||||
article.append(speaker, text);
|
||||
if (event.interrupted) {
|
||||
const note = document.createElement("small");
|
||||
note.textContent = "Unterbrochen";
|
||||
article.append(note);
|
||||
}
|
||||
$("messages").append(article);
|
||||
while ($("messages").children.length > 80) $("messages").firstElementChild.remove();
|
||||
$("messages").scrollTop = $("messages").scrollHeight;
|
||||
}
|
||||
function receive(event) {
|
||||
let message;
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
if (message.type === "state") state(message.state);
|
||||
if (message.type === "partial") $("partial").textContent = message.text;
|
||||
if (message.type === "transcript") addMessage(message);
|
||||
if (message.type === "error") showError(message.message);
|
||||
if (message.type === "metric" && message.name === "llm_ms") {
|
||||
$("latency").textContent = `Erstes Antwortwort · ${(message.value / 1000).toFixed(2)} s`;
|
||||
}
|
||||
}
|
||||
async function request(path, body, timeout = 20000) {
|
||||
const response = await fetch(path, {method: "POST", headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body), signal: AbortSignal.timeout(timeout)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : "Verbindung fehlgeschlagen.");
|
||||
return data;
|
||||
}
|
||||
async function waitIce(pc) {
|
||||
if (pc.iceGatheringState === "complete") return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error("Lokaler Verbindungsaufbau dauert zu lange.")); }, 10000);
|
||||
function cleanup() { clearTimeout(timeout); pc.removeEventListener("icegatheringstatechange", check); }
|
||||
function check() { if (pc.iceGatheringState === "complete") { cleanup(); resolve(); } }
|
||||
pc.addEventListener("icegatheringstatechange", check);
|
||||
check();
|
||||
});
|
||||
}
|
||||
async function connect() {
|
||||
if (connecting || peer) return;
|
||||
connecting = true;
|
||||
$("error").hidden = true;
|
||||
$("connect").disabled = true;
|
||||
state("Mikrofon wird verbunden …");
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) throw new Error("Bitte diese Seite unter http://localhost:7860 öffnen.");
|
||||
stream = await navigator.mediaDevices.getUserMedia({audio: {
|
||||
echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1,
|
||||
}, video: false});
|
||||
const pc = new RTCPeerConnection({iceServers: []});
|
||||
peer = pc;
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
channel = pc.createDataChannel("astra");
|
||||
channel.onmessage = receive;
|
||||
channel.onopen = () => {
|
||||
heartbeat = setInterval(() => { if (channel?.readyState === "open") channel.send("ping"); }, 1000);
|
||||
};
|
||||
pc.ontrack = (event) => {
|
||||
output.srcObject = event.streams[0] || new MediaStream([event.track]);
|
||||
output.play().catch(() => showError("Audioausgabe blockiert. Bitte die Audiowiedergabe im Browser erlauben und neu starten."));
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (peer !== pc) return;
|
||||
if (pc.connectionState === "connected") state("listening");
|
||||
if (["failed", "disconnected", "closed"].includes(pc.connectionState) && !disconnecting) {
|
||||
showError("Verbindung beendet. Du kannst das Gespräch erneut starten.");
|
||||
void disconnect();
|
||||
}
|
||||
};
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
await waitIce(pc);
|
||||
const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer"});
|
||||
pcId = answer.pc_id;
|
||||
await pc.setRemoteDescription({sdp: answer.sdp, type: answer.type});
|
||||
$("connect").textContent = "Gespräch beenden";
|
||||
$("mute").hidden = false;
|
||||
$("clear").textContent = "Neues Gespräch";
|
||||
$("hint").textContent = "Sprich frei. Beim Dazwischenreden hält Astra an.";
|
||||
} catch (error) {
|
||||
await disconnect();
|
||||
const messages = {NotAllowedError: "Mikrofonzugriff nicht erlaubt. Bitte in den Browser-Einstellungen freigeben.",
|
||||
NotFoundError: "Kein Mikrofon gefunden. Bitte ein Mikrofon anschließen.",
|
||||
NotReadableError: "Das Mikrofon ist gerade nicht verfügbar."};
|
||||
showError(messages[error.name] || error.message);
|
||||
} finally {
|
||||
connecting = false;
|
||||
$("connect").disabled = !ready;
|
||||
}
|
||||
}
|
||||
async function disconnect() {
|
||||
if (disconnecting) return;
|
||||
disconnecting = true;
|
||||
const id = pcId;
|
||||
pcId = null;
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
stream?.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
const oldPeer = peer;
|
||||
peer = null;
|
||||
channel?.close();
|
||||
channel = null;
|
||||
oldPeer?.close();
|
||||
output.pause();
|
||||
output.srcObject = null;
|
||||
muted = false;
|
||||
$("mute").hidden = true;
|
||||
$("mute").setAttribute("aria-pressed", "false");
|
||||
$("mute").textContent = "Mikrofon pausieren";
|
||||
$("connect").textContent = "Gespräch starten";
|
||||
$("clear").textContent = "Verlauf leeren";
|
||||
$("partial").textContent = "";
|
||||
$("hint").textContent = "Mikrofon ist aus. Beim nächsten Start beginnt ein neues Gespräch.";
|
||||
state("Bereit, wenn du es bist.");
|
||||
try { if (id) await request("/api/disconnect", {pc_id: id}, 10000); }
|
||||
catch { showError("Der Server hat das Beenden nicht bestätigt. Das Mikrofon ist aus; bitte kurz warten, bevor du neu startest."); }
|
||||
finally { disconnecting = false; }
|
||||
}
|
||||
$("connect").addEventListener("click", () => { if (peer) void disconnect(); else void connect(); });
|
||||
$("mute").addEventListener("click", () => {
|
||||
muted = !muted;
|
||||
stream?.getAudioTracks().forEach(track => { track.enabled = !muted; });
|
||||
$("mute").setAttribute("aria-pressed", String(muted));
|
||||
$("mute").textContent = muted ? "Mikrofon aktivieren" : "Mikrofon pausieren";
|
||||
state("listening");
|
||||
});
|
||||
$("clear").addEventListener("click", async () => {
|
||||
const reconnect = Boolean(peer);
|
||||
if (reconnect) await disconnect();
|
||||
$("messages").replaceChildren();
|
||||
if (reconnect) await connect();
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
stream?.getTracks().forEach(track => track.stop());
|
||||
peer?.close();
|
||||
});
|
||||
async function poll() {
|
||||
try {
|
||||
const response = await fetch("/api/status", {signal: AbortSignal.timeout(5000)});
|
||||
if (!response.ok) throw new Error("Status nicht verfügbar");
|
||||
const status = await response.json();
|
||||
ready = status.ready;
|
||||
if (!peer && !connecting) {
|
||||
$("connect").disabled = !ready;
|
||||
state(ready ? "Bereit, wenn du es bist." : status.stage);
|
||||
if (status.error) showError(status.error);
|
||||
}
|
||||
} catch {
|
||||
ready = false;
|
||||
if (!peer) { $("connect").disabled = true; state("Lokaler Server nicht erreichbar."); }
|
||||
} finally { setTimeout(poll, ready ? 5000 : 1500); }
|
||||
}
|
||||
void poll();
|
||||
30
web/index.html
Normal file
30
web/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#111820">
|
||||
<title>Astra · Dein lokaler Sprachraum</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header><a class="brand" href="/" aria-label="Astra Startseite"><span class="star">✳</span> astra</a><span class="local"><i></i> Nur auf deinem Mac</span></header>
|
||||
<section class="conversation" aria-labelledby="title">
|
||||
<div class="intro"><p class="eyebrow">DEIN LOKALER SPRACHRAUM</p><h1 id="title">Einfach aussprechen.</h1><p class="description">Gedanken sortieren. Fragen stellen. Gemeinsam weiterdenken.</p></div>
|
||||
<div class="orb-wrap" aria-hidden="true"><div class="orbit"></div><div class="orb"><div class="wave"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div></div>
|
||||
<div class="live-status"><span id="state-dot"></span><p id="status" role="status" aria-live="polite">Modelle werden vorbereitet …</p></div>
|
||||
<p id="partial" class="partial" aria-live="off"></p>
|
||||
<p id="error" class="error" role="alert" hidden></p>
|
||||
<div class="controls"><button id="connect" class="primary" disabled><span aria-hidden="true">◉</span> Gespräch starten</button><button id="mute" class="secondary" hidden aria-pressed="false">Mikrofon pausieren</button></div>
|
||||
<p class="hint" id="hint">Mikrofon wird erst nach dem Start freigegeben.</p>
|
||||
</section>
|
||||
<section class="transcript" aria-labelledby="transcript-title">
|
||||
<div class="section-top"><h2 id="transcript-title">Unser Gespräch</h2><button id="clear" class="text-button">Verlauf leeren</button></div>
|
||||
<div id="messages" role="log" aria-live="polite" aria-relevant="additions"><p class="empty">Hier erscheinen deine Worte und Astras Antworten.<br>Der Verlauf bleibt nur für dieses Gespräch im Speicher.</p></div>
|
||||
</section>
|
||||
<footer><span>Deutsch <b>·</b> Lokale Spracherkennung & Stimme</span><span id="latency">Bereit für deine Gedanken</span></footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
3
web/style.css
Normal file
3
web/style.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@font-face{font-family:system;src:local("Avenir Next")}
|
||||
:root{color-scheme:dark;--bg:#111820;--panel:#19222b;--text:#eef2ef;--muted:#a3b1b8;--accent:#b4e5cc;--border:#303c44}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% 25%,#1c2d34 0%,var(--bg) 58%);color:var(--text);font-family:system,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}main{max-width:1060px;margin:auto;padding:36px 48px 25px}header{display:flex;justify-content:space-between;align-items:center}.brand{text-decoration:none;color:var(--text);font-size:30px;letter-spacing:-1.5px;font-weight:600}.star{color:var(--accent);font-size:35px;vertical-align:middle;margin-right:7px}.local{display:flex;align-items:center;gap:9px;font-size:12px;color:#c3d3ce;border:1px solid var(--border);padding:9px 13px;border-radius:30px}.local i{height:6px;width:6px;background:var(--accent);border-radius:50%}.conversation{text-align:center;padding:60px 0 34px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:2.8px;color:var(--accent);margin-bottom:17px}h1{font-size:clamp(32px,5vw,49px);font-weight:500;letter-spacing:-1.8px;margin:0 0 13px}.description{font-size:14px;color:var(--muted);margin:0}.orb-wrap{position:relative;width:210px;height:210px;display:grid;place-items:center;margin:31px auto 15px}.orb{width:145px;height:145px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,#a3e1cf77,#4e938a22 55%,#172b3544);border:1px solid #a3d3c35c;box-shadow:inset 0 0 38px #92e1ce19,0 0 65px #84c5b215}.orbit{position:absolute;inset:6px;border:1px solid #9acdb01b;border-radius:50%;box-shadow:0 0 0 16px #98cdb104}.wave{height:40px;display:flex;align-items:center;gap:5px}.wave i{display:block;width:4px;height:13px;border-radius:5px;background:#d0f3e4;transition:height .3s}.wave i:nth-child(2),.wave i:nth-child(6){height:22px}.wave i:nth-child(3),.wave i:nth-child(5){height:32px}.wave i:nth-child(4){height:41px}body[data-state="speaking"] .wave i,body[data-state="listening"] .wave i{animation:voice 1.2s ease-in-out infinite alternate}.wave i:nth-child(2n){animation-delay:-.8s!important}.wave i:nth-child(3n){animation-delay:-.4s!important}@keyframes voice{to{height:8px;opacity:.6}}.live-status{display:flex;justify-content:center;align-items:center;gap:8px;height:27px}.live-status p{font-size:14px;margin:0}#state-dot{height:6px;width:6px;border-radius:50%;background:var(--muted)}body[data-state="listening"] #state-dot,body[data-state="speaking"] #state-dot{background:var(--accent)}.partial{color:var(--muted);font-size:13px;min-height:22px;max-width:640px;margin:13px auto 17px;overflow-wrap:anywhere}.controls{display:flex;justify-content:center;gap:12px;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:0;transition:background .2s,transform .2s}button:active{transform:translateY(1px)}button:focus-visible,a:focus-visible{outline:3px solid #d5f3e5;outline-offset:5px}.primary{background:var(--accent);color:#162c25;border-radius:9px;font-size:14px;font-weight:600;padding:15px 24px;min-width:211px}.primary:hover{background:#cef2df}.primary span{margin-right:9px}.primary:disabled{background:#43594f;color:#bfcbc4;cursor:wait}.secondary{border:1px solid #576b68;border-radius:9px;padding:14px 18px;background:transparent;color:var(--text);font-size:13px}.secondary[aria-pressed="true"]{background:#553a38;border-color:#c2887c}.hint{font-size:11px;color:var(--muted);margin:16px 0 0}.error{color:#ffd5cd;background:#4a2c2a;border:1px solid #8e564f;border-radius:9px;padding:12px;max-width:640px;margin:15px auto;font-size:13px;overflow-wrap:anywhere}.transcript{border:1px solid var(--border);border-radius:14px;background:#19222b9c;padding:22px 26px}.section-top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);padding-bottom:16px}h2{margin:0;font-size:13px;font-weight:500}.text-button{color:var(--muted);background:none;font-size:11px;padding:7px}.text-button:hover{color:var(--text)}#messages{max-height:330px;overflow:auto;scroll-behavior:smooth;padding-top:5px}.empty{font-size:12px;color:var(--muted);line-height:1.9;text-align:center;padding:18px 0 12px}.message{padding:15px 0 7px}.message .speaker{font-size:10px;letter-spacing:1px;font-weight:600;text-transform:uppercase;color:var(--accent)}.message.user .speaker{color:#b1bdcb}.message p{font-size:14px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.message small{color:var(--muted);font-size:11px}footer{display:flex;justify-content:space-between;gap:15px;margin-top:21px;color:var(--muted);font-size:10px}footer b{padding:0 6px;font-weight:400}[hidden]{display:none!important}@media(max-width:620px){main{padding:23px 20px}.conversation{padding-top:44px}.description{max-width:270px;margin:auto;line-height:1.6}.local{font-size:10px;padding:8px 10px}.transcript{padding:18px}footer{flex-direction:column;gap:8px}.orb-wrap{margin-top:21px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue