|
|
|
@@ -0,0 +1,314 @@
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
|
|
|
import { usePushToTalk } from "./usePushToTalk"
|
|
|
|
|
import { useVAD } from "./useVAD"
|
|
|
|
|
import { AudioQueue } from "./audio"
|
|
|
|
|
import { sentimentToEmotion, type Emotion } from "./sentiment"
|
|
|
|
|
import { BOX_URL, TTS_URL, SYSTEM_PROMPT } from "../../config"
|
|
|
|
|
|
|
|
|
|
// Voll-Duplex-Schleife: PTT -> Box /stt -> Box /chat (SSE Hermes) -> CHUNKS -> lokal /tts -> AudioQueue.
|
|
|
|
|
// Hirn+STT = Box (wie WebUI), Stimme = lokal (pocket-tts, CPU). Antwort wird in groessere Bloecke
|
|
|
|
|
// gebuendelt (>=MIN_CHUNK Zeichen, kein Mini-Satz-Babble) und satzweise frueh gesprochen.
|
|
|
|
|
// Warm-Gate beim Start: Bedienung erst frei, wenn der TTS-Dienst antwortet (Modell geladen).
|
|
|
|
|
|
|
|
|
|
export type VoiceStatus = "warming" | "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error"
|
|
|
|
|
export interface ChatMsg { role: "user" | "assistant"; text: string }
|
|
|
|
|
|
|
|
|
|
// Hermes schreibt manchmal ASCII-Umlaute (ue/ae/oe/ss) statt ä/ö/ü/ß -> pocket-tts liest die falsch vor.
|
|
|
|
|
// Gezielt häufige UMLAUT-Stämme zurückwandeln. KONSERVATIV: nur Muster, bei denen ASCII fast immer ein
|
|
|
|
|
// Umlaut ist (echte "ue"-Wörter wie aktuell/neue/Quelle bleiben unangetastet — die sind hier NICHT gelistet).
|
|
|
|
|
const UMLAUT_FIX: [RegExp, string][] = [
|
|
|
|
|
[/ueber/gi, "über"], [/\bfuer\b/gi, "für"], [/natuerlich/gi, "natürlich"], [/zurueck/gi, "zurück"],
|
|
|
|
|
[/unterstuetz/gi, "unterstütz"], [/ueberpruef/gi, "überprüf"], [/\bpruef/gi, "prüf"], [/gefuehl/gi, "gefühl"],
|
|
|
|
|
[/\bfuehl/gi, "fühl"], [/\bfuehr/gi, "führ"], [/\bfuenf/gi, "fünf"], [/\bgruen/gi, "grün"], [/\bfrueh/gi, "früh"],
|
|
|
|
|
[/\bglueck/gi, "glück"], [/\bstueck/gi, "stück"], [/gemuetlich/gi, "gemütlich"], [/verfueg/gi, "verfüg"],
|
|
|
|
|
[/\bmoecht/gi, "möcht"], [/\bkoenn/gi, "könn"], [/\bwuerd/gi, "würd"], [/\bmuess/gi, "müss"], [/\bduerf/gi, "dürf"],
|
|
|
|
|
[/\bhaett/gi, "hätt"], [/\bwaer/gi, "wär"], [/\bspaet/gi, "spät"],
|
|
|
|
|
[/\btaeglich/gi, "täglich"], [/\bnaechst/gi, "nächst"], [/\baehnlich/gi, "ähnlich"], [/waehrend/gi, "während"],
|
|
|
|
|
[/\bwaehl/gi, "wähl"], [/erklaer/gi, "erklär"], [/\bschoen/gi, "schön"], [/\bgroess/gi, "größ"], [/\bhoer/gi, "hör"],
|
|
|
|
|
[/\boeffn/gi, "öffn"], [/\bboese/gi, "böse"], [/\bloesch/gi, "lösch"], [/\bstoer/gi, "stör"], [/koennt/gi, "könnt"],
|
|
|
|
|
// erweitert (30.06.): weitere häufige Stämme + sicheres -ität-Suffix. KONSERVATIV gewählt;
|
|
|
|
|
// der TTS-Server (_fix_umlauts) hat zusätzlich ein Netz. neu/aktuell/Steuer/Feuer bleiben unberührt.
|
|
|
|
|
[/itaet/gi, "ität"],
|
|
|
|
|
[/\bmoeglich/gi, "möglich"], [/gespraech/gi, "gespräch"], [/\bmaerz/gi, "märz"],
|
|
|
|
|
[/geschaeft/gi, "geschäft"], [/gefaehr/gi, "gefähr"], [/\bhaeng/gi, "häng"], [/\blaeng/gi, "läng"],
|
|
|
|
|
[/\bmaenner/gi, "männer"], [/\bmaedchen/gi, "mädchen"], [/\bvaeter/gi, "väter"], [/\btraeum/gi, "träum"],
|
|
|
|
|
[/\bsaetz/gi, "sätz"], [/\bplaetz/gi, "plätz"], [/\bkaelte/gi, "kälte"], [/\bzaehl/gi, "zähl"],
|
|
|
|
|
[/\baerg/gi, "ärg"], [/\baerzt/gi, "ärzt"], [/aeusser/gi, "äußer"],
|
|
|
|
|
[/\btuer/gi, "tür"], [/\bkueche/gi, "küche"], [/buech/gi, "büch"], [/\bbuero/gi, "büro"], [/\bbuerg/gi, "bürg"],
|
|
|
|
|
[/\bsued/gi, "süd"], [/schueler/gi, "schüler"], [/uebung/gi, "übung"], [/kuenstl/gi, "künstl"],
|
|
|
|
|
[/\bmuede/gi, "müde"], [/\bmuell/gi, "müll"], [/\bdrueck/gi, "drück"], [/stuetz/gi, "stütz"],
|
|
|
|
|
[/\bmoebel/gi, "möbel"], [/loes/gi, "lös"], [/voellig/gi, "völlig"], [/zwoelf/gi, "zwölf"],
|
|
|
|
|
[/\bkoenig/gi, "könig"], [/\bhoeh/gi, "höh"], [/\bdoerf/gi, "dörf"], [/\bwoert/gi, "wört"],
|
|
|
|
|
]
|
|
|
|
|
export function restoreUmlauts(s: string): string {
|
|
|
|
|
let out = s
|
|
|
|
|
for (const [re, rep] of UMLAUT_FIX) out = out.replace(re, rep as string)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Optionaler Stimmungs-Tag, den Hermes ans Ende haengen kann (<emo:happy> ...). Steuert NUR den
|
|
|
|
|
// Gesichtsausdruck -> wird vor Anzeige UND vor dem Vorlesen entfernt. Fallback = Sentiment-Heuristik.
|
|
|
|
|
const EMO_TAG = /<emo:(happy|sad|angry|surprised|relaxed|neutral)>/i
|
|
|
|
|
function stripEmoTag(s: string): string { return s.replace(/<emo:\w+>/gi, "").trimEnd() }
|
|
|
|
|
|
|
|
|
|
function cleanForTTS(s: string): string {
|
|
|
|
|
return restoreUmlauts(s)
|
|
|
|
|
.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1")
|
|
|
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
|
|
|
.replace(/https?:\/\/\S+/gi, " ").replace(/www\.\S+/gi, " ").replace(/\b\S+@\S+\.\S+\b/g, " ")
|
|
|
|
|
.replace(/[*_#>~|`]+/g, " ").replace(/^\s*[-•·]\s+/gm, " ")
|
|
|
|
|
.replace(/\s*&\s*/g, " und ")
|
|
|
|
|
.replace(/(\d)\s*%/g, "$1 Prozent").replace(/%/g, " Prozent ")
|
|
|
|
|
.replace(/(\d)\s*°\s*C?/g, "$1 Grad").replace(/°/g, " Grad ")
|
|
|
|
|
.replace(/\s*=\s*/g, " gleich ").replace(/\s*\/\s*/g, " ")
|
|
|
|
|
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "")
|
|
|
|
|
.replace(/\s+/g, " ").trim()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FRISCHE Session pro App-Start (NICHT in localStorage persistieren). Sonst wächst der Hermes-Verlauf
|
|
|
|
|
// über alle Starts hinweg unbegrenzt (gesehen: 97k+ Tokens) -> Degeneration/Wiederhol-Schleifen.
|
|
|
|
|
// Dauerhaftes Gedächtnis liegt ohnehin in Mem0 (sessionunabhängig) -> Continuity bleibt erhalten.
|
|
|
|
|
function newSessionId(): string {
|
|
|
|
|
return "lucy-" + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ttsToBuffer(text: string): Promise<ArrayBuffer> {
|
|
|
|
|
const r = await fetch(`${TTS_URL}/tts`, {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }),
|
|
|
|
|
})
|
|
|
|
|
if (!r.ok) throw new Error(`TTS ${r.status}`)
|
|
|
|
|
return r.arrayBuffer()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Streamt die Stimme (PCM16) und spielt sie lueckenlos ab, sobald die ersten Chunks da sind
|
|
|
|
|
// (Time-to-first-audio ~1s statt der ganzen Generierung). Liefert true, wenn etwas gesprochen wurde.
|
|
|
|
|
async function ttsStreamPlay(queue: AudioQueue, text: string): Promise<boolean> {
|
|
|
|
|
const r = await fetch(`${TTS_URL}/tts/stream`, {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }),
|
|
|
|
|
})
|
|
|
|
|
if (!r.ok || !r.body) throw new Error(`TTS ${r.status}`)
|
|
|
|
|
const sr = Number(r.headers.get("X-Sample-Rate") || "24000")
|
|
|
|
|
await queue.playPcmStream(r.body, sr)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bildschirm-Sicht: erkennt im Gesagten die Bitte, auf den Schirm zu schauen
|
|
|
|
|
const VISION_RX = /\b(schau|sieh|siehst|guck|guckst|zeig|bildschirm|screen|monitor|fenster|erkennst?|lies (mir|das)|was (steht|ist) (da|hier|auf)|auf meinem (bildschirm|schirm|screen))\b/i
|
|
|
|
|
function extractWindowName(text: string): string | null {
|
|
|
|
|
// grobe Heuristik: Wort/Phrase nach 'fenster|app|programm|in|auf|bei|im'
|
|
|
|
|
const m = text.match(/(?:fenster|app|programm|in|auf|bei|im)\s+([A-Za-zÄÖÜäöü][\w.+\- ]{2,28})/i)
|
|
|
|
|
if (!m) return null
|
|
|
|
|
return m[1].replace(/\b(an|fenster|programm|app|bildschirm|schirm|screen|siehst|du)\b/gi, "").trim() || null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useVoiceAgent() {
|
|
|
|
|
const [status, setStatus] = useState<VoiceStatus>("warming")
|
|
|
|
|
const [ready, setReady] = useState(false)
|
|
|
|
|
const [messages, setMessages] = useState<ChatMsg[]>([])
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
const [inputMode, setInputModeState] = useState<"ptt" | "vad">(
|
|
|
|
|
() => (localStorage.getItem("lucy_input_mode") === "vad" ? "vad" : "ptt"))
|
|
|
|
|
const [vadListening, setVadListening] = useState(false)
|
|
|
|
|
const setInputMode = useCallback((m: "ptt" | "vad") => {
|
|
|
|
|
localStorage.setItem("lucy_input_mode", m); setInputModeState(m)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const audioLevel = useRef({ current: 0 })
|
|
|
|
|
const emotion = useRef<Emotion>("neutral")
|
|
|
|
|
const queueRef = useRef<AudioQueue | null>(null)
|
|
|
|
|
const sessionId = useRef<string>(newSessionId())
|
|
|
|
|
// Live-Status fuer die Eingabe-Sperre (ohne pressStart-Closure neu zu binden)
|
|
|
|
|
const statusRef = useRef<VoiceStatus>("warming")
|
|
|
|
|
useEffect(() => { statusRef.current = status }, [status])
|
|
|
|
|
|
|
|
|
|
const ensureQueue = useCallback(() => {
|
|
|
|
|
if (!queueRef.current) {
|
|
|
|
|
const q = new AudioQueue()
|
|
|
|
|
q.onSpeaking = (sp) => setStatus((s) => (sp ? "speaking" : s === "speaking" ? "idle" : s))
|
|
|
|
|
queueRef.current = q
|
|
|
|
|
audioLevel.current = q.level
|
|
|
|
|
}
|
|
|
|
|
return queueRef.current
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// --- Warm-Gate: warten bis der lokale TTS-Dienst geladen ist, bevor die Bedienung frei wird ---
|
|
|
|
|
// pocket_server serviert /health erst, wenn das Modell fertig geladen ist (FastAPI-lifespan).
|
|
|
|
|
// Der Main-Prozess spawnt den Server beim App-Start; hier nur pollen.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false
|
|
|
|
|
;(async () => {
|
|
|
|
|
for (let i = 0; i < 120 && !cancelled; i++) {
|
|
|
|
|
try {
|
|
|
|
|
const h = await (await fetch(`${TTS_URL}/health`)).json()
|
|
|
|
|
if (h.status === "ok") break
|
|
|
|
|
} catch { /* Dienst startet evtl. noch (Spawn + Modell-Load) */ }
|
|
|
|
|
await new Promise((r) => setTimeout(r, 1000))
|
|
|
|
|
}
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
// ein Aufwaerm-Satz (primt alle lazy Pfade); Audio verwerfen
|
|
|
|
|
try { await ttsToBuffer("Alles bereit, Commander.") } catch { /* */ }
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
setReady(true); setStatus("idle")
|
|
|
|
|
})()
|
|
|
|
|
return () => { cancelled = true }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Ein kompletter Turn: User-Text (+ optional Bildschirm-Bilder, Multi-Monitor) -> Hermes (SSE) -> Stimme.
|
|
|
|
|
const runTurn = useCallback(async (userText: string, images?: string[]) => {
|
|
|
|
|
const queue = ensureQueue()
|
|
|
|
|
setStatus("thinking")
|
|
|
|
|
let assistant = ""
|
|
|
|
|
setMessages((m) => [...m, { role: "assistant", text: "" }])
|
|
|
|
|
|
|
|
|
|
let spokeAny = false, ttsFailed = false
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch(`${BOX_URL}/api/voice/chat`, {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ text: userText, session_id: sessionId.current, system: SYSTEM_PROMPT, images: images || [] }),
|
|
|
|
|
})
|
|
|
|
|
if (!r.ok || !r.body) throw new Error(`Agent ${r.status}`)
|
|
|
|
|
const reader = r.body.getReader()
|
|
|
|
|
const dec = new TextDecoder()
|
|
|
|
|
let sse = ""
|
|
|
|
|
for (;;) {
|
|
|
|
|
const { done, value } = await reader.read()
|
|
|
|
|
if (done) break
|
|
|
|
|
sse += dec.decode(value, { stream: true })
|
|
|
|
|
const events = sse.split("\n\n"); sse = events.pop() || ""
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
const lines = ev.split("\n")
|
|
|
|
|
// Hermes mischt Tool-Fortschritt (event: hermes.tool.progress) in den Stream. Das sind KEINE
|
|
|
|
|
// Chat-Chunks -> nicht als solche parsen (ein Tool-Hickup mit error-Objekt darf die Antwort
|
|
|
|
|
// nicht abbrechen). Optional koennte man hier Tool-Status anzeigen.
|
|
|
|
|
const evType = lines.find((l) => l.startsWith("event:"))?.slice(6).trim()
|
|
|
|
|
if (evType && evType.startsWith("hermes.")) continue // Tool-Fortschritt (Status bleibt 'thinking')
|
|
|
|
|
const line = lines.find((l) => l.startsWith("data:"))
|
|
|
|
|
if (!line) continue
|
|
|
|
|
const data = line.slice(5).trim()
|
|
|
|
|
if (data === "[DONE]") continue
|
|
|
|
|
let json: any
|
|
|
|
|
try { json = JSON.parse(data) } catch { continue }
|
|
|
|
|
if (json.error) {
|
|
|
|
|
const msg = typeof json.error === "string"
|
|
|
|
|
? json.error
|
|
|
|
|
: (json.error?.message || JSON.stringify(json.error))
|
|
|
|
|
throw new Error(msg)
|
|
|
|
|
}
|
|
|
|
|
const delta = json.choices?.[0]?.delta?.content || ""
|
|
|
|
|
if (!delta) continue
|
|
|
|
|
assistant += delta
|
|
|
|
|
emotion.current = sentimentToEmotion(assistant)
|
|
|
|
|
setMessages((m) => { const c = m.slice(); c[c.length - 1] = { role: "assistant", text: stripEmoTag(restoreUmlauts(assistant)) }; return c })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!assistant.trim()) { setStatus("idle"); return }
|
|
|
|
|
// Defensiv: falls Hermes mal seinen internen Umschlag durchreicht (Mid-Turn), NICHT vorlesen.
|
|
|
|
|
if (assistant.includes("OUT-OF-BAND USER MESSAGE")) {
|
|
|
|
|
setStatus("error")
|
|
|
|
|
setError("Nachricht kam mitten im Turn an — bitte warten, bis Lucy fertig ist, dann erneut fragen.")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Stimmungs-Tag (falls vorhanden) -> Avatar-Mimik nach BEDEUTUNG (sonst bleibt Sentiment-Heuristik).
|
|
|
|
|
const emo = assistant.match(EMO_TAG)
|
|
|
|
|
if (emo) emotion.current = emo[1].toLowerCase() as Emotion
|
|
|
|
|
// Ganze Antwort STREAMEN -> erstes Audio nach ~1s, lueckenlos (pocket-tts splittet intern in
|
|
|
|
|
// Saetze mit natuerlicher Prosodie). Kein Chunking noetig (real-time RTF ~0.74).
|
|
|
|
|
const spoken = cleanForTTS(stripEmoTag(assistant))
|
|
|
|
|
if (spoken) {
|
|
|
|
|
try { await ttsStreamPlay(queue, spoken); spokeAny = true }
|
|
|
|
|
catch (e) { ttsFailed = true; console.error("TTS-Fehler:", e) }
|
|
|
|
|
}
|
|
|
|
|
if (!spokeAny) {
|
|
|
|
|
setStatus("error")
|
|
|
|
|
setError(ttsFailed ? "Sprachausgabe fehlgeschlagen — laeuft der lokale TTS-Dienst?" : "Keine Sprachausgabe erzeugt.")
|
|
|
|
|
}
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
setStatus("error"); setError(`Agent-Antwort fehlgeschlagen: ${e.message}`)
|
|
|
|
|
}
|
|
|
|
|
}, [ensureQueue])
|
|
|
|
|
|
|
|
|
|
// Bildschirm-Sicht: Bild(er) erfassen (gezieltes Fenster per Name, sonst ALLE Monitore)
|
|
|
|
|
const captureForVision = useCallback(async (userText: string): Promise<{ images: string[]; label: string }> => {
|
|
|
|
|
if (!window.lucy) return { images: [], label: "" }
|
|
|
|
|
try {
|
|
|
|
|
const name = extractWindowName(userText)
|
|
|
|
|
if (name) {
|
|
|
|
|
const hit = await window.lucy.captureByName(name)
|
|
|
|
|
if (hit?.image) return { images: [hit.image], label: ` 👁 ${hit.name}` }
|
|
|
|
|
}
|
|
|
|
|
const shots = (await window.lucy.captureScreen()).filter(Boolean)
|
|
|
|
|
if (shots.length) return { images: shots, label: shots.length > 1 ? ` 👁 ${shots.length} Bildschirme` : " 👁 Bildschirm" }
|
|
|
|
|
} catch (e) { console.error("Bildschirm-Capture:", e) }
|
|
|
|
|
return { images: [], label: "" }
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handleAudio = useCallback(async (blob: Blob) => {
|
|
|
|
|
setError(null)
|
|
|
|
|
ensureQueue().clear()
|
|
|
|
|
setStatus("transcribing")
|
|
|
|
|
let userText = ""
|
|
|
|
|
try {
|
|
|
|
|
const fd = new FormData()
|
|
|
|
|
fd.append("audio", blob, "rec.webm")
|
|
|
|
|
const r = await fetch(`${BOX_URL}/api/voice/stt`, { method: "POST", body: fd })
|
|
|
|
|
if (!r.ok) throw new Error(`STT ${r.status}`)
|
|
|
|
|
userText = (await r.json()).text?.trim() || ""
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
setStatus("error"); setError(`Spracherkennung fehlgeschlagen: ${e.message}`); return
|
|
|
|
|
}
|
|
|
|
|
if (!userText) { setStatus("idle"); return }
|
|
|
|
|
// Bittet der Nutzer Lucy, auf den Bildschirm zu schauen? -> Bild(er) erfassen + mitschicken.
|
|
|
|
|
const vis = VISION_RX.test(userText) ? await captureForVision(userText) : { images: [] as string[], label: "" }
|
|
|
|
|
setMessages((m) => [...m, { role: "user", text: userText + vis.label }])
|
|
|
|
|
await runTurn(userText, vis.images)
|
|
|
|
|
}, [ensureQueue, runTurn, captureForVision])
|
|
|
|
|
|
|
|
|
|
// Auge-Button: Lucy aktiv auf den Bildschirm schauen lassen (ohne Sprachbefehl)
|
|
|
|
|
const lookAtScreen = useCallback(async () => {
|
|
|
|
|
if (!ready || statusRef.current === "thinking" || statusRef.current === "transcribing") return
|
|
|
|
|
setError(null); ensureQueue().clear()
|
|
|
|
|
let images: string[] = []
|
|
|
|
|
try { images = ((await window.lucy?.captureScreen()) || []).filter(Boolean) } catch { /* */ }
|
|
|
|
|
const prompt = "Schau auf meinen Bildschirm und sag mir kurz, was du darauf siehst."
|
|
|
|
|
const label = images.length > 1 ? ` 👁 ${images.length} Bildschirme` : " 👁 Bildschirm"
|
|
|
|
|
setMessages((m) => [...m, { role: "user", text: prompt + label }])
|
|
|
|
|
await runTurn(prompt, images)
|
|
|
|
|
}, [ensureQueue, runTurn, ready])
|
|
|
|
|
|
|
|
|
|
const { recording, start, stop } = usePushToTalk(handleAudio)
|
|
|
|
|
|
|
|
|
|
// Freisprech-VAD: nur im vad-Modus, und nur wenn Lucy IDLE ist (paused sonst) -> keine Rückkopplung,
|
|
|
|
|
// keine Mid-Turn-Nachricht. ensureQueue() vor handleAudio, damit Barge-in/Audio bereit ist.
|
|
|
|
|
useVAD({
|
|
|
|
|
enabled: ready && inputMode === "vad",
|
|
|
|
|
paused: status !== "idle",
|
|
|
|
|
onUtterance: (blob) => { ensureQueue(); void handleAudio(blob) },
|
|
|
|
|
onListening: setVadListening,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const pressStart = useCallback(() => {
|
|
|
|
|
if (!ready) return
|
|
|
|
|
// Waehrend das Gehirn arbeitet (STT/Hermes-Turn laeuft) KEINEN neuen Turn starten: eine zweite
|
|
|
|
|
// Nachricht mitten im Turn liefert Hermes als "OUT-OF-BAND" aus -> Modell echot den Umschlag.
|
|
|
|
|
// Barge-in beim SPRECHEN bleibt erlaubt (Hermes-Turn ist dann fertig) -> queue.clear() in handleAudio.
|
|
|
|
|
if (statusRef.current === "transcribing" || statusRef.current === "thinking") return
|
|
|
|
|
ensureQueue()
|
|
|
|
|
setStatus("listening")
|
|
|
|
|
void start()
|
|
|
|
|
}, [ready, ensureQueue, start])
|
|
|
|
|
|
|
|
|
|
const pressEnd = useCallback(() => { stop() }, [stop])
|
|
|
|
|
|
|
|
|
|
const reset = useCallback(() => {
|
|
|
|
|
queueRef.current?.clear()
|
|
|
|
|
setMessages([]); setError(null); setStatus("idle")
|
|
|
|
|
sessionId.current = newSessionId() // frischer Gesprächsfaden (Mem0-Gedächtnis bleibt)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!recording && status === "listening") setStatus("transcribing")
|
|
|
|
|
}, [recording, status])
|
|
|
|
|
|
|
|
|
|
return { status, ready, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset,
|
|
|
|
|
inputMode, setInputMode, vadListening, lookAtScreen }
|
|
|
|
|
}
|