feat(barge-in): Unterbrechen der KI-Antwort per Mikrofon-Button und Enter

Web-Interface (app.js):
- Mic-Button hat jetzt 3 Zustände: idle (grün 🎤) / Aufnahme (rot pulsierend) /
  KI antwortet (amber ⏹, klicken = Barge-in)
- Barge-in sendet {"type":"interrupt"} auf der aktiven WS-Verbindung (activeWs)
  und stoppt alle laufenden AudioBufferSourceNodes sofort (stopAudio)
- busy + Button-Reset erfolgen automatisch wenn die WS schliesst

Terminal (voice_loop.py):
- _stdin_watcher(): polling via select.select (nicht-blockierend, kein Thread-Leak)
  wartet auf Enter-Tastendruck waehrend Server antwortet
- _send_and_play() raced one_turn gegen _stdin_watcher mit asyncio.wait;
  bei Barge-in: interrupt-Frame an Server, Player.terminate(), feeder beenden
- Feedback: "↩ Unterbrochen — drücke [Enter] für neue Aufnahme …"

Server-Seite war bereits vollstaendig implementiert (ws.py, kein Handlungsbedarf).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 18:38:33 +02:00
commit 0d907a1886
2 changed files with 111 additions and 12 deletions

View file

@ -16,6 +16,9 @@ function overrides() {
}
let busy = false;
let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar)
let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in)
// Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
let sessionId = null;
@ -99,6 +102,32 @@ function addMessage(role, text) {
return div;
}
// ---------- Barge-in-Hilfsfunktionen ----------
function stopAudio() {
activeSources.forEach((s) => { try { s.stop(); } catch (e) {} });
activeSources = [];
}
function setMicIdle() {
micBtn.textContent = "🎤";
micBtn.title = "Mikrofon";
micBtn.classList.remove("bg-red-600", "animate-pulse", "bg-amber-500");
micBtn.classList.add("bg-emerald-600");
}
function setMicRecording() {
micBtn.classList.remove("bg-emerald-600", "bg-amber-500");
micBtn.classList.add("bg-red-600", "animate-pulse");
micBtn.title = "Aufnahme stoppen";
}
function setMicBusy() {
micBtn.textContent = "⏹";
micBtn.title = "KI unterbrechen (Barge-in)";
micBtn.classList.remove("bg-emerald-600", "bg-red-600", "animate-pulse");
micBtn.classList.add("bg-amber-500");
}
// ---------- Audio-Wiedergabe (PCM s16le) ----------
let audioCtx = null;
function playPcm(chunks, sampleRate) {
@ -116,6 +145,8 @@ function playPcm(chunks, sampleRate) {
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
activeSources.push(src);
src.onended = () => { activeSources = activeSources.filter((s) => s !== src); };
src.start();
}
@ -129,6 +160,7 @@ function wsUrl(path) {
function runTurn(path, onopen) {
return new Promise((resolve) => {
const ws = new WebSocket(wsUrl(path));
activeWs = ws;
ws.binaryType = "arraybuffer";
const pcm = [];
let answerEl = null;
@ -136,7 +168,7 @@ function runTurn(path, onopen) {
ws.onopen = () => onopen(ws);
ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); };
ws.onclose = () => resolve();
ws.onclose = () => { activeWs = null; resolve(); };
ws.onmessage = (event) => {
if (typeof event.data !== "string") { pcm.push(event.data); return; }
@ -179,6 +211,7 @@ function runTurn(path, onopen) {
async function sendText(text) {
if (busy || !text.trim()) return;
busy = true;
setMicBusy();
await ensureSession();
addMessage("user", text);
statusEl.textContent = "denkt …";
@ -186,6 +219,7 @@ async function sendText(text) {
ws.send(JSON.stringify({ text, stream: true, audio_stream: true, ...overrides() }));
});
busy = false;
setMicIdle();
}
formEl.addEventListener("submit", (e) => {
@ -236,20 +270,19 @@ async function startRecording() {
sendVoice(bytes, mime.includes("mp4") || mime.includes("mpeg") ? "mp4" : "webm");
};
mediaRecorder.start();
micBtn.classList.remove("bg-emerald-600");
micBtn.classList.add("bg-red-600", "animate-pulse");
setMicRecording();
statusEl.textContent = "Aufnahme … (zum Stoppen erneut tippen)";
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
micBtn.classList.remove("bg-red-600", "animate-pulse");
micBtn.classList.add("bg-emerald-600");
setMicIdle();
}
async function sendVoice(bytes, fmt = "webm") {
if (busy) return;
busy = true;
setMicBusy();
await ensureSession();
statusEl.textContent = "verarbeite Sprache …";
await runTurn("/ws/voice", (ws) => {
@ -258,11 +291,21 @@ async function sendVoice(bytes, fmt = "webm") {
ws.send(JSON.stringify({ type: "end" }));
});
busy = false;
setMicIdle();
}
micBtn.addEventListener("click", () => {
if (mediaRecorder && mediaRecorder.state === "recording") stopRecording();
else startRecording();
if (busy) {
// Barge-in: laufende KI-Antwort sofort unterbrechen
if (activeWs) activeWs.send(JSON.stringify({ type: "interrupt" }));
stopAudio();
statusEl.textContent = "Unterbrochen";
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
} else if (mediaRecorder && mediaRecorder.state === "recording") {
stopRecording();
} else {
startRecording();
}
});
$("#reload-users").addEventListener("click", loadUsers);