diff --git a/client/lucy-desktop/src/renderer/src/lib/voice/perf.ts b/client/lucy-desktop/src/renderer/src/lib/voice/perf.ts new file mode 100644 index 0000000..7e6e3da --- /dev/null +++ b/client/lucy-desktop/src/renderer/src/lib/voice/perf.ts @@ -0,0 +1,8 @@ +// Perf-Diagnose (Client): Zeit pro Stufe ab „Mikro losgelassen". Opt-in via localStorage lucy_perf=1. +// NUR Console (kein POST an den pocket_server — der generiert Audio; POSTs mittendrin +// verursachten hakelige Sprachausgabe). (Aus useVoiceAgent extrahiert, Review P2-13a.) + +export const PERF = typeof localStorage !== "undefined" && localStorage.getItem("lucy_perf") === "1" + +export function plogSend(msg: string) { if (PERF) console.log(`[lucy-perf] ${msg}`) } +export function plog(label: string, ms: number) { plogSend(`${label} = ${Math.round(ms)}ms`) } diff --git a/client/lucy-desktop/src/renderer/src/lib/voice/textPipeline.ts b/client/lucy-desktop/src/renderer/src/lib/voice/textPipeline.ts new file mode 100644 index 0000000..fc31c21 --- /dev/null +++ b/client/lucy-desktop/src/renderer/src/lib/voice/textPipeline.ts @@ -0,0 +1,74 @@ +// Pure Text-Verarbeitung für den Voice-Loop (aus useVoiceAgent extrahiert, Review P2-13a). +// Alles hier ist zustandslos und einzeln testbar — der Hook orchestriert nur noch. + +// 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 ( ...). Steuert NUR den +// Gesichtsausdruck -> wird vor Anzeige UND vor dem Vorlesen entfernt. Fallback = Sentiment-Heuristik. +export const EMO_TAG = //i +export function stripEmoTag(s: string): string { return s.replace(//gi, "").trimEnd() } + +// Hermes hängt bei selbst erzeugten Medien (z.B. eigener Screenshot via pc-control) einen Roh-Token +// „MEDIA:" an. Das ist weder Sprech- noch Anzeigetext -> überall entfernen (bis Medien echt +// gerendert werden). Betrifft auch Lucys eigene Bildschirm-Sicht, die den Screenshot ohnehin schon liefert. +export function stripMedia(s: string): string { + return s.replace(/\bMEDIA:\S+/gi, "").replace(/[ \t]{2,}/g, " ").replace(/[ \t]+\n/g, "\n").trimEnd() +} + +export function cleanForTTS(s: string): string { + return restoreUmlauts(s) + .replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\bMEDIA:\S+/gi, " ") + // Zeitzonen-Codes (UTC/CET/GMT…) + evtl. angehängte Zeit: Pocket mangelt die Akronyme zu + // Kauderwelsch -> aus der Stimme entfernen (Anzeige behält sie). Lucy sagt die lokale Zeit ohnehin. + .replace(/\b(?:UTC|GMT|CET|CEST|MEZ|MESZ|PST|PDT|EST|EDT)\b\s*[+\-]?\d{0,2}(?::\d{2})?/gi, " ") + // Zahlen/Uhrzeiten (18:01 -> „achtzehn Uhr eins", 2026 -> „zweitausend…") normalisiert jetzt + // der lokale pocket_server (text_norm.py, num2words) — kontextsicher (Modellnummern bleiben). + .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. +export function newSessionId(): string { + return "lucy-" + Math.random().toString(36).slice(2) + Date.now().toString(36) +} diff --git a/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts b/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts index f9347db..c6de99a 100644 --- a/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts +++ b/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts @@ -5,103 +5,19 @@ import { AudioQueue } from "./audio" import { sentimentToEmotion, type Emotion } from "./sentiment" import { BOX_URL, SYSTEM_PROMPT } from "../../config" import { stt, tts, SpeechScheduler } from "../../voice-core" +import { EMO_TAG, cleanForTTS, newSessionId, restoreUmlauts, stripEmoTag, stripMedia } from "./textPipeline" +import { VISION_RX, extractWindowName } from "./visionIntent" +import { PERF, plog, plogSend } from "./perf" -// Voll-Duplex-Schleife: PTT -> Box /stt -> Box /chat (SSE Hermes) -> CHUNKS -> lokal /tts -> AudioQueue. +// Voll-Duplex-Schleife: PTT/VAD -> 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). +// gebuendelt und satzweise frueh gesprochen. Warm-Gate beim Start: Bedienung erst frei, wenn der +// TTS-Dienst antwortet. Text-Verarbeitung: textPipeline.ts · Vision-Intent: visionIntent.ts · +// Perf-Logging: perf.ts (Review P2-13a: Hook = nur noch Orchestrierung). export type VoiceStatus = "warming" | "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error" export interface ChatMsg { role: "user" | "assistant"; text: string } -// Perf-Diagnose (Client): Zeit pro Stufe ab „Mikro losgelassen". Aus via localStorage lucy_perf=0. -// Perf-Diagnose: NUR Console (kein POST an den pocket_server mehr — der generiert Audio; POSTs -// mittendrin verursachten hakelige Sprachausgabe). Diagnose ist durch (Thinking war die Ursache). -const PERF = typeof localStorage !== "undefined" && localStorage.getItem("lucy_perf") === "1" // opt-in statt default -function plogSend(msg: string) { if (PERF) console.log(`[lucy-perf] ${msg}`) } -function plog(label: string, ms: number) { plogSend(`${label} = ${Math.round(ms)}ms`) } - -// 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 ( ...). Steuert NUR den -// Gesichtsausdruck -> wird vor Anzeige UND vor dem Vorlesen entfernt. Fallback = Sentiment-Heuristik. -const EMO_TAG = //i -function stripEmoTag(s: string): string { return s.replace(//gi, "").trimEnd() } - -// Hermes hängt bei selbst erzeugten Medien (z.B. eigener Screenshot via pc-control) einen Roh-Token -// „MEDIA:" an. Das ist weder Sprech- noch Anzeigetext -> überall entfernen (bis Medien echt -// gerendert werden). Betrifft auch Lucys eigene Bildschirm-Sicht, die den Screenshot ohnehin schon liefert. -function stripMedia(s: string): string { - return s.replace(/\bMEDIA:\S+/gi, "").replace(/[ \t]{2,}/g, " ").replace(/[ \t]+\n/g, "\n").trimEnd() -} - -function cleanForTTS(s: string): string { - return restoreUmlauts(s) - .replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\bMEDIA:\S+/gi, " ") - // Zeitzonen-Codes (UTC/CET/GMT…) + evtl. angehängte Zeit: Pocket mangelt die Akronyme zu - // Kauderwelsch -> aus der Stimme entfernen (Anzeige behält sie). Lucy sagt die lokale Zeit ohnehin. - .replace(/\b(?:UTC|GMT|CET|CEST|MEZ|MESZ|PST|PDT|EST|EDT)\b\s*[+\-]?\d{0,2}(?::\d{2})?/gi, " ") - // Zahlen/Uhrzeiten (18:01 -> „achtzehn Uhr eins", 2026 -> „zweitausend…") normalisiert jetzt - // der lokale pocket_server (text_norm.py, num2words) — kontextsicher (Modellnummern bleiben). - .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) -} - -// 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("warming") const [ready, setReady] = useState(false) diff --git a/client/lucy-desktop/src/renderer/src/lib/voice/visionIntent.ts b/client/lucy-desktop/src/renderer/src/lib/voice/visionIntent.ts new file mode 100644 index 0000000..04a400e --- /dev/null +++ b/client/lucy-desktop/src/renderer/src/lib/voice/visionIntent.ts @@ -0,0 +1,11 @@ +// Bildschirm-Sicht-Intent: erkennt im Gesagten die Bitte, auf den Schirm zu schauen +// (aus useVoiceAgent extrahiert, Review P2-13a). + +export 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 + +export 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 +}