Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88c2a95c1 | |||
| ed55612cfa | |||
| 47ade040e6 | |||
| 852e723cc8 | |||
| f90e5f4519 | |||
| c3d1cc87e2 | |||
| 88c2659a9c | |||
| 6f7498a956 | |||
| a1cea1a1ea | |||
| 60765469aa |
@@ -41,3 +41,4 @@ client/lucy-f5/*.onnx
|
|||||||
client/lucy-f5/*.safetensors
|
client/lucy-f5/*.safetensors
|
||||||
client/lucy-f5/*.wav
|
client/lucy-f5/*.wav
|
||||||
client/lucy-f5/*.zip
|
client/lucy-f5/*.zip
|
||||||
|
client/lucy-f5/venv/
|
||||||
|
|||||||
@@ -137,6 +137,25 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
|
|||||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voice/turn")
|
||||||
|
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||||
|
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||||
|
data = await audio.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
|
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||||
|
with Timer("turn"):
|
||||||
|
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||||
|
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||||
|
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/voice/reference")
|
@router.post("/voice/reference")
|
||||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
||||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
||||||
|
|||||||
Generated
+66
-753
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/three": "^0.169.0",
|
"@types/three": "^0.169.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^33.2.1",
|
"electron": "^43.0.0",
|
||||||
"electron-vite": "^3.0.0",
|
"electron-vite": "^3.0.0",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3"
|
||||||
|
|||||||
@@ -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`) }
|
||||||
@@ -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 (<emo:happy> ...). Steuert NUR den
|
||||||
|
// Gesichtsausdruck -> wird vor Anzeige UND vor dem Vorlesen entfernt. Fallback = Sentiment-Heuristik.
|
||||||
|
export const EMO_TAG = /<emo:(happy|sad|angry|surprised|relaxed|neutral)>/i
|
||||||
|
export function stripEmoTag(s: string): string { return s.replace(/<emo:\w+>/gi, "").trimEnd() }
|
||||||
|
|
||||||
|
// Hermes hängt bei selbst erzeugten Medien (z.B. eigener Screenshot via pc-control) einen Roh-Token
|
||||||
|
// „MEDIA:<datei>" 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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { MicVAD } from "@ricky0123/vad-web"
|
import { MicVAD } from "@ricky0123/vad-web"
|
||||||
|
import { BOX_URL } from "../../config"
|
||||||
|
|
||||||
// Freisprech-VAD: lauscht dauerhaft am Mikro und liefert komplette Äußerungen als Blob.
|
// Freisprech-VAD: lauscht dauerhaft am Mikro und liefert komplette Äußerungen als Blob.
|
||||||
// Seit Review P1-7 (2026-07-02) Silero VAD v5 (neuronal, via vad-web/onnxruntime-wasm) statt
|
// Seit Review P1-7 (2026-07-02) Silero VAD v5 (neuronal, via vad-web/onnxruntime-wasm) statt
|
||||||
@@ -19,6 +20,11 @@ const REDEMPTION_MS = 450 // so lange Stille -> Äußerung zu Ende (war 900
|
|||||||
const PRE_SPEECH_PAD_MS = 320 // Vorlauf mitschneiden (erster Wortanfang nicht abschneiden)
|
const PRE_SPEECH_PAD_MS = 320 // Vorlauf mitschneiden (erster Wortanfang nicht abschneiden)
|
||||||
const MIN_SPEECH_MS = 160 // kürzer = Klick/Räuspern -> verwerfen
|
const MIN_SPEECH_MS = 160 // kürzer = Klick/Räuspern -> verwerfen
|
||||||
const COOLDOWN_MS = 450 // nach Lucys Antwort kurz taub (Echo/Lautsprecher abklingen lassen)
|
const COOLDOWN_MS = 450 // nach Lucys Antwort kurz taub (Echo/Lautsprecher abklingen lassen)
|
||||||
|
// Semantische Turn-Detection (Smart Turn v3 auf der Box): meldet sie 'incomplete' (User denkt
|
||||||
|
// mitten im Satz nach), warten wir bis zu HOLD_MS auf die Fortsetzung und hängen sie an,
|
||||||
|
// statt mitten im Gedanken zu antworten. Hart begrenzt, damit Lucy nie ewig schweigt.
|
||||||
|
const HOLD_MS = 1800
|
||||||
|
const MAX_UTTERANCE_S = 30 // Sicherheitsdeckel fürs Zusammenhängen
|
||||||
|
|
||||||
// Float32-Samples (16 kHz mono) -> WAV-Blob (PCM16). Ersetzt den MediaRecorder-webm-Umweg:
|
// Float32-Samples (16 kHz mono) -> WAV-Blob (PCM16). Ersetzt den MediaRecorder-webm-Umweg:
|
||||||
// die Box muss kein Opus mehr dekodieren, Parakeet/Whisper bekommen direkt sauberes WAV.
|
// die Box muss kein Opus mehr dekodieren, Parakeet/Whisper bekommen direkt sauberes WAV.
|
||||||
@@ -38,6 +44,25 @@ function toWavBlob(samples: Float32Array, sampleRate = 16000): Blob {
|
|||||||
return new Blob([buf], { type: "audio/wav" })
|
return new Blob([buf], { type: "audio/wav" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fragt die Box, ob die Äußerung semantisch fertig ist. Fehler => true (nie blockieren).
|
||||||
|
async function isTurnComplete(audio: Float32Array): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("audio", toWavBlob(audio.subarray(Math.max(0, audio.length - 8 * 16000))), "rec.wav")
|
||||||
|
const r = await fetch(`${BOX_URL}/api/voice/turn`, { method: "POST", body: fd })
|
||||||
|
if (!r.ok) return true
|
||||||
|
return (await r.json()).complete !== false
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatAudio(a: Float32Array, b: Float32Array): Float32Array {
|
||||||
|
const out = new Float32Array(a.length + b.length)
|
||||||
|
out.set(a); out.set(b, a.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) {
|
export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) {
|
||||||
const pausedRef = useRef(paused); pausedRef.current = paused
|
const pausedRef = useRef(paused); pausedRef.current = paused
|
||||||
const onUtt = useRef(onUtterance); onUtt.current = onUtterance
|
const onUtt = useRef(onUtterance); onUtt.current = onUtterance
|
||||||
@@ -51,6 +76,15 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
|
|||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let vad: Awaited<ReturnType<typeof MicVAD.new>> | null = null
|
let vad: Awaited<ReturnType<typeof MicVAD.new>> | null = null
|
||||||
|
// Semantik-Hold: bei 'incomplete' gepufferte Äußerung, auf die die Fortsetzung wartet.
|
||||||
|
let pending: Float32Array | null = null
|
||||||
|
let holdTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const emit = (audio: Float32Array) => {
|
||||||
|
pending = null
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
||||||
|
onUtt.current(toWavBlob(audio))
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -69,15 +103,28 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
|
|||||||
}),
|
}),
|
||||||
onSpeechStart: () => {
|
onSpeechStart: () => {
|
||||||
if (pausedRef.current || performance.now() < resumeAt.current) return
|
if (pausedRef.current || performance.now() < resumeAt.current) return
|
||||||
|
// User spricht weiter, während eine 'incomplete'-Äußerung gehalten wird ->
|
||||||
|
// Timer stoppen; die Fortsetzung wird in onSpeechEnd angehängt.
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
||||||
onLst.current?.(true)
|
onLst.current?.(true)
|
||||||
},
|
},
|
||||||
onSpeechEnd: (audio: Float32Array) => {
|
onSpeechEnd: async (audio: Float32Array) => {
|
||||||
onLst.current?.(false)
|
onLst.current?.(false)
|
||||||
// Während Lucy denkt/spricht (oder direkt danach) erkannte Sprache = ihr eigenes
|
// Während Lucy denkt/spricht (oder direkt danach) erkannte Sprache = ihr eigenes
|
||||||
// Echo bzw. Nachhall -> verwerfen statt transkribieren.
|
// Echo bzw. Nachhall -> verwerfen statt transkribieren.
|
||||||
if (cancelled || pausedRef.current || performance.now() < resumeAt.current) return
|
if (cancelled || pausedRef.current || performance.now() < resumeAt.current) { pending = null; return }
|
||||||
if (audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz
|
if (!pending && audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz
|
||||||
onUtt.current(toWavBlob(audio))
|
let combined = pending ? concatAudio(pending, audio) : audio
|
||||||
|
if (combined.length > MAX_UTTERANCE_S * 16000) {
|
||||||
|
combined = combined.subarray(combined.length - MAX_UTTERANCE_S * 16000)
|
||||||
|
}
|
||||||
|
const complete = await isTurnComplete(combined)
|
||||||
|
if (cancelled) return
|
||||||
|
if (complete) { emit(combined); return }
|
||||||
|
// Mitten im Gedanken pausiert: kurz auf die Fortsetzung warten, dann notfalls doch senden.
|
||||||
|
pending = combined
|
||||||
|
if (holdTimer) clearTimeout(holdTimer)
|
||||||
|
holdTimer = setTimeout(() => { if (!cancelled && pending) emit(pending) }, HOLD_MS)
|
||||||
},
|
},
|
||||||
onVADMisfire: () => onLst.current?.(false),
|
onVADMisfire: () => onLst.current?.(false),
|
||||||
})
|
})
|
||||||
@@ -90,6 +137,7 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
if (holdTimer) clearTimeout(holdTimer)
|
||||||
try { vad?.destroy() } catch { /* */ }
|
try { vad?.destroy() } catch { /* */ }
|
||||||
onLst.current?.(false)
|
onLst.current?.(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,103 +5,19 @@ import { AudioQueue } from "./audio"
|
|||||||
import { sentimentToEmotion, type Emotion } from "./sentiment"
|
import { sentimentToEmotion, type Emotion } from "./sentiment"
|
||||||
import { BOX_URL, SYSTEM_PROMPT } from "../../config"
|
import { BOX_URL, SYSTEM_PROMPT } from "../../config"
|
||||||
import { stt, tts, SpeechScheduler } from "../../voice-core"
|
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
|
// 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.
|
// gebuendelt und satzweise frueh gesprochen. Warm-Gate beim Start: Bedienung erst frei, wenn der
|
||||||
// Warm-Gate beim Start: Bedienung erst frei, wenn der TTS-Dienst antwortet (Modell geladen).
|
// 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 type VoiceStatus = "warming" | "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error"
|
||||||
export interface ChatMsg { role: "user" | "assistant"; text: string }
|
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 (<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() }
|
|
||||||
|
|
||||||
// Hermes hängt bei selbst erzeugten Medien (z.B. eigener Screenshot via pc-control) einen Roh-Token
|
|
||||||
// „MEDIA:<datei>" 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() {
|
export function useVoiceAgent() {
|
||||||
const [status, setStatus] = useState<VoiceStatus>("warming")
|
const [status, setStatus] = useState<VoiceStatus>("warming")
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,33 +4,53 @@ globalTTL: 0
|
|||||||
|
|
||||||
models:
|
models:
|
||||||
Qwen3.6-35B-A3B:
|
Qwen3.6-35B-A3B:
|
||||||
|
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
|
||||||
|
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
|
||||||
|
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
|
||||||
cmd: |
|
cmd: |
|
||||||
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
|
|
||||||
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
|
|
||||||
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
|
|
||||||
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
|
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- hermes
|
- hermes
|
||||||
- fast
|
- fast
|
||||||
|
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
|
||||||
|
capabilities:
|
||||||
|
in: [text]
|
||||||
|
out: [text]
|
||||||
|
tools: true
|
||||||
|
context: 65536
|
||||||
Qwen3.5-122B-A10B:
|
Qwen3.5-122B-A10B:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
||||||
ttl: 600
|
ttl: 600
|
||||||
aliases:
|
aliases:
|
||||||
- heavy
|
- heavy
|
||||||
|
capabilities:
|
||||||
|
in: [text]
|
||||||
|
out: [text]
|
||||||
|
tools: true
|
||||||
|
context: 32768
|
||||||
Qwen3-Coder-Next:
|
Qwen3-Coder-Next:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
||||||
ttl: 600
|
ttl: 600
|
||||||
aliases:
|
aliases:
|
||||||
- coder
|
- coder
|
||||||
|
capabilities:
|
||||||
|
in: [text]
|
||||||
|
out: [text]
|
||||||
|
tools: true
|
||||||
|
context: 131072
|
||||||
Qwen3-VL-8B-Instruct:
|
Qwen3-VL-8B-Instruct:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
|
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
|
||||||
ttl: 300
|
ttl: 300
|
||||||
aliases:
|
aliases:
|
||||||
- vision
|
- vision
|
||||||
|
capabilities:
|
||||||
|
in: [text, image]
|
||||||
|
out: [text]
|
||||||
|
context: 32768
|
||||||
Qwen3-Embedding-0.6B:
|
Qwen3-Embedding-0.6B:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
|
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
|
||||||
@@ -43,12 +63,22 @@ models:
|
|||||||
ttl: 300
|
ttl: 300
|
||||||
aliases:
|
aliases:
|
||||||
- coder-lite
|
- coder-lite
|
||||||
|
capabilities:
|
||||||
|
in: [text]
|
||||||
|
out: [text]
|
||||||
|
tools: true
|
||||||
|
context: 131072
|
||||||
GLM-4.6V-Flash:
|
GLM-4.6V-Flash:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
|
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
|
||||||
ttl: 300
|
ttl: 300
|
||||||
aliases:
|
aliases:
|
||||||
- scout
|
- scout
|
||||||
|
capabilities:
|
||||||
|
in: [text, image]
|
||||||
|
out: [text]
|
||||||
|
tools: true
|
||||||
|
context: 131072
|
||||||
groups:
|
groups:
|
||||||
brains:
|
brains:
|
||||||
swap: false
|
swap: false
|
||||||
|
|||||||
@@ -249,6 +249,21 @@ Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap.
|
|||||||
- **C5 war Fehlalarm** (s. Findings-Tabelle).
|
- **C5 war Fehlalarm** (s. Findings-Tabelle).
|
||||||
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme).
|
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme).
|
||||||
|
|
||||||
|
## 7. Nachtrag 2: P2/P3-Vollausbau (02.07., zweite Session-Hälfte)
|
||||||
|
|
||||||
|
| Punkt | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| **P2-11 Turn-Detection** | ✅ Smart Turn v3.2 (8 MB ONNX) im Voice-Sidecar (`/turn`, ~110 ms warm) + Semantik-Hold im Client (bei „unfertig" bis 1,8 s auf Fortsetzung warten). **Zwei Upstream-Fallen live diagnostiziert:** Audio muss LINKS gepadded werden (sonst konstant „complete"), und der Output ist P(unfertig) — entgegen der Doku. Verifiziert: fertig=0,74→true, mitten im Wort=0,04→false |
|
||||||
|
| **P2-12 F5 Phase C/D** | ✅ **Verdikt: Pocket bleibt.** F5-ONNX auf 9070 XT/DirectML: RTF 0,59@NFE32 / 0,30@NFE16 — aber nicht streamfähig (TTFA = ganze Satzdauer), NFE16-Qualitätsrisiko, GPU-Dauerbelegung. F5 taugt als Offline-Renderer, nicht für den Dialog-Loop |
|
||||||
|
| **P2-13a Refactor** | ✅ useVoiceAgent 397→305 Z; textPipeline/visionIntent/perf als pure Module |
|
||||||
|
| **P2-13b Electron** | ✅ 33→43 (Chromium 150/Node 24), Boot-Smoke-Test grün (allow-scripts-Falle: install.js manuell) |
|
||||||
|
| **P2-14 Cockpit/SystemDrawer** | ⏸ **bewusst vertagt:** reiner Qualitäts-Refactor von live deployter UI; braucht eine eigene Session mit Browser-Verifikation (MC_API_TARGET-Workflow) statt eines Blind-Splits am Session-Ende |
|
||||||
|
| **P3-15 Mem0 v3** | ✅ verifiziert aktiv (BM25/Entity/Hybrid in 2.0.8); mc2-memory nutzt jetzt `sync_turn(messages)` — lernt Tool-NAMEN mit (Ergebnisse bewusst nicht: Poisoning-Vektor). Deployt, Postcheck grün |
|
||||||
|
| **P3-16 llama-swap** | ✅ `capabilities` (in/out/tools/context) je Modell in deploy-Config; Swap-Matrix aktuell unnötig (brains-Gruppe reicht) |
|
||||||
|
| **P3-17 Kandidaten** | 🟢/🔄 **Qwen3-VL-30B-A3B heruntergeladen + gebencht: tg 90–92 t/s** (MoE 3B aktiv, 19 GB, Vulkan) — klarer Upgrade-Kandidat für die Bildschirm-Sicht (`MC_VISION_MODEL`), Qualitäts-Check mit echten Screenshots + brains-Budget-Entscheid (19 vs. 6 GB warm) beim User. gpt-oss-120B lädt noch (nohup); danach: `bash /tmp/candidate_bench.sh` auf der Box vergleicht ihn gegen heavy (Skript liegt bereit) |
|
||||||
|
| **P3-18 Lemonade** | ✅ **Verdikt: nicht als Unterbau.** lemonade-sdk 9.1.4 (Python) installiert + Server lief (OpenAI-API, gute Registry inkl. gpt-oss/GLM) — aber: Python-Edition **offiziell deprecated** (C++-Installer ist der Weg), Server blockiert komplett während Model-Downloads (Health tot >10 min bei 400-MB-Modell), Ryzen-AI-Hybrid auf 9700X unsupported. **Empfehlung für den lokalen GPU-Klon: llama.cpp-Vulkan + llama-swap — derselbe Stack wie die Box** (ein Betriebsmodell, ein Know-how, bewährte Configs). Lemonade-C++ nur, falls Whisper+TTS+LLM aus einer Hand gewünscht |
|
||||||
|
| **MC3-Vollbau** | ⛔ außerhalb des Review-Scopes — eigenes Projekt (Prototyp steht unter #mc3) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Erhoben am 02.07.2026 durch Claude Code (Live-SSH auf Box, lokale PC-Prüfung, 3 Codebase-Agents, 3 Web-Recherche-Runden). Messwerte: /api/voice/metrics (n=13 STT, n=18 chat_ttfb), TTFT-Direktmessung llama-swap :8080.*
|
*Erhoben am 02.07.2026 durch Claude Code (Live-SSH auf Box, lokale PC-Prüfung, 3 Codebase-Agents, 3 Web-Recherche-Runden). Messwerte: /api/voice/metrics (n=13 STT, n=18 chat_ttfb), TTFT-Direktmessung llama-swap :8080.*
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+61
-61
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-KW1c6Ww8.js"></script>
|
<script type="module" crossorigin src="/assets/index-CrYzXlx6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BljJ8MOS.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
|
||||||
|
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
|
||||||
|
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
|
||||||
|
interface State { error: Error | null }
|
||||||
|
|
||||||
|
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||||
|
state: State = { error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.error) return this.props.children
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
|
||||||
|
<div className="max-w-lg space-y-4 text-center">
|
||||||
|
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
|
||||||
|
<div className="text-sm text-neutral-400 break-all">
|
||||||
|
{this.state.error.message}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from "react"
|
|||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
|
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||||
@@ -17,8 +18,10 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AppErrorBoundary>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
</QueryClientProvider>
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -150,6 +150,18 @@ class MC2MemoryProvider(MemoryProvider):
|
|||||||
return
|
return
|
||||||
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
|
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
|
||||||
a = (assistant_content or "").strip()
|
a = (assistant_content or "").strip()
|
||||||
|
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls).
|
||||||
|
# Bewusst NUR die Tool-NAMEN mitlernen ("hat X per Tool Y geprüft") — Tool-ERGEBNISSE
|
||||||
|
# sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
|
||||||
|
if messages:
|
||||||
|
tools = []
|
||||||
|
for m in messages:
|
||||||
|
for tc in (m.get("tool_calls") or []):
|
||||||
|
name = ((tc.get("function") or {}).get("name") or "").strip()
|
||||||
|
if name and name not in tools:
|
||||||
|
tools.append(name)
|
||||||
|
if tools:
|
||||||
|
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
|
||||||
if a:
|
if a:
|
||||||
msgs.append({"role": "assistant", "content": a[:4000]})
|
msgs.append({"role": "assistant", "content": a[:4000]})
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -195,6 +195,76 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str, engine: str = "")
|
|||||||
return transcribe_whisper(audio_bytes, suffix, language)
|
return transcribe_whisper(audio_bytes, suffix, language)
|
||||||
|
|
||||||
|
|
||||||
|
# =================================================================================
|
||||||
|
# Turn-Detection — Smart Turn v3 (pipecat, BSD-2): semantisches Äußerungs-Ende.
|
||||||
|
# Whisper-Tiny-Encoder + Klassifikator (8 MB int8, ~12 ms CPU). Der Lucy-Client fragt
|
||||||
|
# nach dem akustischen VAD-Ende hier nach: "War das ein fertiger Satz?" — bei
|
||||||
|
# 'incomplete' wartet er kurz weiter, statt mitten im Gedanken loszuantworten.
|
||||||
|
# Inferenz-Pfad 1:1 aus pipecat-ai/smart-turn inference.py (Feature-Shape muss passen).
|
||||||
|
# =================================================================================
|
||||||
|
TURN_REPO = os.environ.get("VOICE_TURN_REPO", "pipecat-ai/smart-turn-v3")
|
||||||
|
TURN_FILE = os.environ.get("VOICE_TURN_FILE", "smart-turn-v3.2-cpu.onnx")
|
||||||
|
_turn = None # (session, feature_extractor)
|
||||||
|
_turn_failed = False
|
||||||
|
|
||||||
|
|
||||||
|
def turn_model():
|
||||||
|
global _turn, _turn_failed
|
||||||
|
if _turn is None and not _turn_failed:
|
||||||
|
try:
|
||||||
|
import onnxruntime as ort
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
from transformers import WhisperFeatureExtractor
|
||||||
|
log.info("Lade Smart Turn '%s/%s' …", TURN_REPO, TURN_FILE)
|
||||||
|
path = hf_hub_download(TURN_REPO, TURN_FILE)
|
||||||
|
so = ort.SessionOptions()
|
||||||
|
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||||
|
sess = ort.InferenceSession(path, sess_options=so, providers=["CPUExecutionProvider"])
|
||||||
|
_turn = (sess, WhisperFeatureExtractor(chunk_length=8))
|
||||||
|
except Exception:
|
||||||
|
_turn_failed = True
|
||||||
|
log.exception("Smart Turn nicht verfügbar — /turn meldet complete=true (Fallback).")
|
||||||
|
return _turn
|
||||||
|
|
||||||
|
|
||||||
|
def check_turn(audio_bytes: bytes, suffix: str) -> dict:
|
||||||
|
model = turn_model()
|
||||||
|
if model is None:
|
||||||
|
return {"complete": True, "probability": 1.0, "engine": "none"}
|
||||||
|
sess, fe = model
|
||||||
|
from faster_whisper.audio import decode_audio
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
|
||||||
|
tf.write(audio_bytes)
|
||||||
|
src = tf.name
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
arr = decode_audio(src, sampling_rate=16000)
|
||||||
|
# Smart Turn erwartet das Audio AM ENDE des 8-s-Fensters (Zeros vorn) — die Turn-Ende-
|
||||||
|
# Hinweise liegen in den letzten Frames. Der FeatureExtractor padded rechts (Zeros hinten),
|
||||||
|
# damit sah das Modell immer nur Padding und meldete konstant 'complete' (live diagnostiziert).
|
||||||
|
# Darum manuell links auffüllen und exakt 8 s übergeben.
|
||||||
|
n = 8 * 16000
|
||||||
|
arr = arr[-n:]
|
||||||
|
if len(arr) < n:
|
||||||
|
arr = np.concatenate([np.zeros(n - len(arr), dtype=np.float32), arr.astype(np.float32)])
|
||||||
|
inputs = fe(arr, sampling_rate=16000, return_tensors="np", padding="do_not_pad",
|
||||||
|
truncation=True, do_normalize=True)
|
||||||
|
out = sess.run(None, {"input_features": inputs.input_features})
|
||||||
|
# Output-Tensor heißt 'logits' — je nach Export roher Logit ODER schon Sigmoid.
|
||||||
|
# Robust: Werte außerhalb [0,1] durch Sigmoid schicken, sonst direkt nutzen.
|
||||||
|
raw = float(out[0][0].item() if hasattr(out[0][0], "item") else out[0][0])
|
||||||
|
p = raw if 0.0 <= raw <= 1.0 else 1.0 / (1.0 + float(np.exp(-raw)))
|
||||||
|
# EMPIRISCH VERIFIZIERT (2026-07-02, Box): v3.2-cpu liefert P(UNFERTIG) —
|
||||||
|
# fertiger Satz -> 0.26, mitten im Wort abgeschnitten -> 0.96, Stille -> 0.99.
|
||||||
|
# (Die Upstream-Doku beschreibt es andersherum; Messung schlägt Doku.)
|
||||||
|
return {"complete": p <= 0.5, "probability": round(1.0 - p, 3), "engine": "smart-turn-v3"}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(src)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# =================================================================================
|
# =================================================================================
|
||||||
# TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben)
|
# TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben)
|
||||||
# =================================================================================
|
# =================================================================================
|
||||||
@@ -467,6 +537,16 @@ async def stt(audio: UploadFile = File(...), language: str = Form(default=""),
|
|||||||
return {"text": text}
|
return {"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/turn")
|
||||||
|
async def turn(audio: UploadFile = File(...)) -> dict:
|
||||||
|
"""Semantische Turn-Detection: War die Äußerung ein fertiger Gedanke? (Smart Turn v3)"""
|
||||||
|
data = await audio.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
|
suffix = Path(audio.filename or "rec.wav").suffix or ".wav"
|
||||||
|
return check_turn(data, suffix)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/tts")
|
@app.post("/tts")
|
||||||
def tts(body: TTSIn) -> Response:
|
def tts(body: TTSIn) -> Response:
|
||||||
text = (body.text or "").strip()
|
text = (body.text or "").strip()
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ edge-tts
|
|||||||
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller
|
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller
|
||||||
# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
|
# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
|
||||||
onnx-asr[cpu,hub]
|
onnx-asr[cpu,hub]
|
||||||
|
# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features)
|
||||||
|
transformers
|
||||||
|
|||||||
Reference in New Issue
Block a user