Lucy v2: voice-core-Adapter, VAD, Overlay-Feinschliff + Projekt-Dateien
- voice-core/: STT/TTS hinter Engine-Interfaces (pocket/box austauschbar), SpeechScheduler ausgelagert - useVAD (Freisprechen, adaptive RMS-Schwelle), usePushToTalk, sentiment - Avatar3D/config/styles-Feinschliff, AuraGlow - electron.vite.config, tsconfig, Starter-BATs, .gitignore (out/, avatar.vrm) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
# Avatar-VRM ist gross + Lizenz/Redistribution -> nicht committen (lokal/Box vorhalten)
|
||||||
|
src/renderer/public/avatar.vrm
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
curl -s -m 5 http://127.0.0.1:8130/health > lucy_check.log 2>&1
|
||||||
|
echo.>> lucy_check.log
|
||||||
|
echo --- electron/vite/pocket laeuft? --->> lucy_check.log
|
||||||
|
powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pocket_server:app|electron-vite|lucy-desktop' } | ForEach-Object { $_.Name + ' ' + $_.ProcessId }" >> lucy_check.log 2>&1
|
||||||
|
type lucy_check.log
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-desktop"
|
||||||
|
echo Beende haengende Versuche (ohne mich selbst)...
|
||||||
|
powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'electron.exe' -or $_.CommandLine -match 'electron-vite|pocket_server:app|start-lucy' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"
|
||||||
|
timeout /t 2 >nul
|
||||||
|
echo Starte 'npm run dev' mit Log-Capture...
|
||||||
|
start /b "" cmd /c "npm run dev > dev.log 2>&1"
|
||||||
|
timeout /t 22 >nul
|
||||||
|
echo === dev.log ===
|
||||||
|
type dev.log
|
||||||
|
echo.
|
||||||
|
echo (laeuft im Hintergrund weiter, falls erfolgreich)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
REM Hygiene: Recon-Log mit Token entfernen
|
||||||
|
del /q "F:\Coding Stuff\mission-control-2\box_recon.log" 2>nul
|
||||||
|
echo Beende laufende Lucy-Instanz (pocket_server :8130 + Electron/Vite)...
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pocket_server:app|lucy-desktop|electron-vite' } | ForEach-Object { Write-Output ('kill ' + $_.ProcessId + ' ' + $_.Name); Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||||
|
timeout /t 2 >nul
|
||||||
|
echo.
|
||||||
|
echo Starte Lucy neu (frischer Voice-Server + Client mit erweitertem Umlaut-Fix)...
|
||||||
|
start "" "F:\Coding Stuff\mission-control-2\client\lucy-desktop\start-lucy.cmd"
|
||||||
|
timeout /t 3 >nul
|
||||||
|
echo Fertig.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ws=New-Object -ComObject WScript.Shell; $p=Join-Path ([Environment]::GetFolderPath('Desktop')) 'Lucy.lnk'; $l=$ws.CreateShortcut($p); $l.TargetPath='F:\Coding Stuff\mission-control-2\client\lucy-desktop\start-lucy.cmd'; $l.WorkingDirectory='F:\Coding Stuff\mission-control-2\client\lucy-desktop'; $l.IconLocation='F:\Coding Stuff\mission-control-2\client\lucy-desktop\lucy.ico'; $l.Description='Lucy - lokaler Sprach-Companion starten'; $l.Save(); Write-Output ('OK: ' + $p)" > lucy_shortcut.log 2>&1
|
||||||
|
type lucy_shortcut.log
|
||||||
|
echo.
|
||||||
|
echo Fertig - "Lucy" liegt jetzt auf dem Desktop.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "electron-vite"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
build: { rollupOptions: { input: { index: resolve(__dirname, "src/main/index.ts") } } },
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
build: { rollupOptions: { input: { index: resolve(__dirname, "src/preload/index.ts") } } },
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: resolve(__dirname, "src/renderer"),
|
||||||
|
build: { rollupOptions: { input: { index: resolve(__dirname, "src/renderer/index.html") } } },
|
||||||
|
plugins: [react()],
|
||||||
|
},
|
||||||
|
})
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:8130 http://192.168.178.151:9001 ws://localhost:*; media-src 'self' blob: data: http://127.0.0.1:8130; img-src 'self' data: blob:; script-src 'self' 'unsafe-inline'" />
|
||||||
|
<title>Lucy</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useRef, type MutableRefObject } from "react"
|
||||||
|
|
||||||
|
// Status-als-Licht: reaktive Aura hinter Lucy. Farbe = Status (hört zu / denkt / spricht), Intensität
|
||||||
|
// pulst mit dem Sprech-Pegel. Liest audioLevel per rAF (kein Re-Render pro Frame).
|
||||||
|
type LevelRef = MutableRefObject<{ current: number }>
|
||||||
|
|
||||||
|
const COLORS: Record<string, [number, number, number]> = {
|
||||||
|
warming: [120, 130, 150], idle: [125, 211, 252], listening: [56, 189, 248],
|
||||||
|
transcribing: [167, 139, 250], thinking: [251, 191, 36], speaking: [52, 211, 153],
|
||||||
|
error: [248, 113, 113],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuraGlow({ status, audioLevel }: { status: string; audioLevel: LevelRef }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const statusRef = useRef(status); statusRef.current = status
|
||||||
|
const phase = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0, last = performance.now()
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const dt = Math.min(0.05, (now - last) / 1000); last = now
|
||||||
|
phase.current += dt
|
||||||
|
const el = ref.current
|
||||||
|
if (el) {
|
||||||
|
const s = statusRef.current
|
||||||
|
const [r, g, b] = COLORS[s] || COLORS.idle
|
||||||
|
const lvl = audioLevel.current?.current ?? 0
|
||||||
|
// Grund-Puls je Status + Sprech-Reaktivität
|
||||||
|
const base = s === "thinking" || s === "transcribing" ? 0.45 + Math.sin(phase.current * 2.2) * 0.18
|
||||||
|
: s === "listening" ? 0.55 + Math.sin(phase.current * 3) * 0.12
|
||||||
|
: s === "speaking" ? 0.4 + Math.min(0.6, lvl * 1.8)
|
||||||
|
: s === "error" ? 0.6
|
||||||
|
: 0.32 + Math.sin(phase.current * 1.1) * 0.06 // idle: ruhiges Atmen
|
||||||
|
const intensity = Math.max(0.15, Math.min(1, base))
|
||||||
|
const scale = 1 + intensity * 0.18 + (s === "speaking" ? lvl * 0.25 : 0)
|
||||||
|
el.style.background = `radial-gradient(circle at 50% 42%, rgba(${r},${g},${b},${0.5 * intensity}) 0%, rgba(${r},${g},${b},${0.18 * intensity}) 32%, transparent 62%)`
|
||||||
|
el.style.transform = `scale(${scale})`
|
||||||
|
el.style.opacity = String(0.5 + intensity * 0.5)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [audioLevel])
|
||||||
|
|
||||||
|
return <div ref={ref} className="aura" />
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Canvas, useFrame } from "@react-three/fiber"
|
|||||||
import { OrbitControls } from "@react-three/drei"
|
import { OrbitControls } from "@react-three/drei"
|
||||||
import { useEffect, useRef, useState, type MutableRefObject } from "react"
|
import { useEffect, useRef, useState, type MutableRefObject } from "react"
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
|
||||||
import { Object3D, Vector3, AnimationMixer, LoopPingPong, LoopOnce, type AnimationAction } from "three"
|
import { Object3D, Vector3, AnimationMixer, LoopPingPong, LoopOnce, AdditiveBlending, DoubleSide, type AnimationAction } from "three"
|
||||||
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
|
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
|
||||||
import { VRMAnimationLoaderPlugin, createVRMAnimationClip, type VRMAnimation } from "@pixiv/three-vrm-animation"
|
import { VRMAnimationLoaderPlugin, createVRMAnimationClip, type VRMAnimation } from "@pixiv/three-vrm-animation"
|
||||||
import type { Emotion } from "../lib/voice/sentiment"
|
import type { Emotion } from "../lib/voice/sentiment"
|
||||||
@@ -281,25 +281,72 @@ function VrmModel({ url, audioLevel, emotion, status, cursor, pat, dance, onErro
|
|||||||
return vrm ? <primitive object={vrm.scene} /> : null
|
return vrm ? <primitive object={vrm.scene} /> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hologramm-Beiwerk: Projektor-Sockel (Glow-Ringe am Boden) + schwebender Kristall daneben.
|
||||||
|
// Reine Additiv-Meshes -> berühren die VRM-Materialien NICHT (voll reversibel per Toggle).
|
||||||
|
function HoloRig() {
|
||||||
|
const ringA = useRef<any>(null), ringB = useRef<any>(null), crystal = useRef<any>(null)
|
||||||
|
useFrame((s, d) => {
|
||||||
|
const t = s.clock.elapsedTime
|
||||||
|
if (ringA.current) ringA.current.rotation.z += d * 0.3
|
||||||
|
if (ringB.current) ringB.current.rotation.z -= d * 0.55
|
||||||
|
if (crystal.current) {
|
||||||
|
crystal.current.rotation.y += d * 1.1
|
||||||
|
crystal.current.rotation.x = Math.sin(t * 0.8) * 0.3
|
||||||
|
crystal.current.position.y = 1.18 + Math.sin(t * 1.6) * 0.04
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const cyan = "#4fd8ff"
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Projektor-Sockel: weicher Glow-Disc + zwei rotierende Ringe am Boden (Fuesse bei y=0) */}
|
||||||
|
<group position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<mesh><ringGeometry args={[0, 0.36, 48]} /><meshBasicMaterial color={cyan} transparent opacity={0.07} blending={AdditiveBlending} side={DoubleSide} depthWrite={false} /></mesh>
|
||||||
|
<mesh ref={ringA}><ringGeometry args={[0.3, 0.36, 64]} /><meshBasicMaterial color={cyan} transparent opacity={0.85} blending={AdditiveBlending} side={DoubleSide} depthWrite={false} /></mesh>
|
||||||
|
<mesh ref={ringB}><ringGeometry args={[0.19, 0.215, 48]} /><meshBasicMaterial color={cyan} transparent opacity={0.6} blending={AdditiveBlending} side={DoubleSide} depthWrite={false} /></mesh>
|
||||||
|
</group>
|
||||||
|
{/* schwebender Kristall neben ihr (Schulterhoehe) */}
|
||||||
|
<mesh ref={crystal} position={[0.44, 1.18, 0]} scale={0.075}>
|
||||||
|
<octahedronGeometry args={[1, 0]} />
|
||||||
|
<meshBasicMaterial color={cyan} transparent opacity={0.85} blending={AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Avatar3D({ url, audioLevel, emotion, status = "idle", cursor, pat, dance, controls = true }: {
|
export function Avatar3D({ url, audioLevel, emotion, status = "idle", cursor, pat, dance, controls = true }: {
|
||||||
url: string; audioLevel: LevelRef; emotion: EmotionRef; status?: string
|
url: string; audioLevel: LevelRef; emotion: EmotionRef; status?: string
|
||||||
cursor?: CursorRef; pat?: NumRef; dance?: NumRef; controls?: boolean
|
cursor?: CursorRef; pat?: NumRef; dance?: NumRef; controls?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const [holo, setHolo] = useState(() => localStorage.getItem("lucy_holo") !== "0") // Hologramm-Look, Standard: an
|
||||||
const statusRef = useRef(status); statusRef.current = status
|
const statusRef = useRef(status); statusRef.current = status
|
||||||
|
const toggleHolo = () => setHolo((h) => { const n = !h; localStorage.setItem("lucy_holo", n ? "1" : "0"); return n })
|
||||||
return (
|
return (
|
||||||
<div className="avatarCanvas" style={{ position: "relative", height: "100%", width: "100%" }}>
|
<div className={`avatarCanvas${holo ? " holo" : ""}`} style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||||
<Canvas camera={{ position: [0, 1.35, 1.25], fov: 30 }} dpr={[1, 1.5]}
|
<Canvas camera={{ position: [0, 1.35, 1.25], fov: 30 }} dpr={[1, 1.5]}
|
||||||
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }} style={{ background: "transparent" }}>
|
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }} style={{ background: "transparent" }}>
|
||||||
|
{holo ? (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.55} color="#9fe6ff" />
|
||||||
|
<directionalLight position={[0, 2, -2.5]} intensity={2.4} color="#3fd4ff" />{/* Rim von hinten -> Silhouetten-Glow */}
|
||||||
|
<directionalLight position={[1.5, 1.5, 2]} intensity={0.5} color="#cceeff" />
|
||||||
|
<HoloRig />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<ambientLight intensity={0.85} />
|
<ambientLight intensity={0.85} />
|
||||||
<directionalLight position={[1, 2, 2]} intensity={1.1} />
|
<directionalLight position={[1, 2, 2]} intensity={1.1} />
|
||||||
<directionalLight position={[-1, 1, -1]} intensity={0.4} />
|
<directionalLight position={[-1, 1, -1]} intensity={0.4} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} status={statusRef} cursor={cursor} pat={pat} dance={dance} onError={setErr} />
|
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} status={statusRef} cursor={cursor} pat={pat} dance={dance} onError={setErr} />
|
||||||
{controls && (
|
{controls && (
|
||||||
<OrbitControls target={[0, 1.3, 0]} enablePan={false} minDistance={0.7} maxDistance={3}
|
<OrbitControls target={[0, 1.3, 0]} enablePan={false} minDistance={0.7} maxDistance={3}
|
||||||
minPolarAngle={Math.PI / 3} maxPolarAngle={Math.PI / 1.8} />
|
minPolarAngle={Math.PI / 3} maxPolarAngle={Math.PI / 1.8} />
|
||||||
)}
|
)}
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
{holo && (<><div className="holoTint" /><div className="holoScan" /></>)}
|
||||||
|
<button className={`holoToggle${holo ? " on" : ""}`} onClick={toggleHolo} title="Hologramm-Look an/aus">◇</button>
|
||||||
{err && (
|
{err && (
|
||||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 12, margin: "0 auto", width: "fit-content",
|
<div style={{ position: "absolute", left: 0, right: 0, bottom: 12, margin: "0 auto", width: "fit-content",
|
||||||
borderRadius: 8, background: "rgba(239,68,68,0.15)", border: "1px solid rgba(239,68,68,0.3)",
|
borderRadius: 8, background: "rgba(239,68,68,0.15)", border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
|||||||
@@ -5,8 +5,21 @@ export const TTS_URL = "http://127.0.0.1:8130" // lokaler Lucy-TTS (pock
|
|||||||
// Persona/Anrede: Lucy spricht den Nutzer als "Commander" an. ECHTE Umlaute erzwingen (sonst klingt TTS grausam).
|
// Persona/Anrede: Lucy spricht den Nutzer als "Commander" an. ECHTE Umlaute erzwingen (sonst klingt TTS grausam).
|
||||||
export const SYSTEM_PROMPT =
|
export const SYSTEM_PROMPT =
|
||||||
"Du bist Lucy, eine gesprochene Assistentin, und redest den Nutzer mit „Commander“ an. " +
|
"Du bist Lucy, eine gesprochene Assistentin, und redest den Nutzer mit „Commander“ an. " +
|
||||||
"Antworte natürlich und freundlich, aber SEHR KNAPP: in der Regel 1 bis 3 ganze, gut vorlesbare Sätze, " +
|
"Antworte natürlich, locker und mit etwas Persönlichkeit — du darfst ruhig ein bisschen chatty und frech sein, " +
|
||||||
"nur das Wesentliche. Hol nicht aus, zähle nicht alles auf — lange Erklärungen nur, wenn ausdrücklich gewünscht. " +
|
"aber bleib FOKUSSIERT: in der Regel 2 bis 3 Sätze. Beantworte die Frage direkt (gern mit einer kleinen menschlichen " +
|
||||||
|
"Note) und hol dann nicht unnötig aus. Keine ungefragten Meta-Kommentare, Warnungen oder Wiederholungen. " +
|
||||||
|
"Ausführlich nur, wenn ausdrücklich gewünscht. " +
|
||||||
|
"TOOLS: Du DARFST und SOLLST Tools/Skills nutzen, wenn sie wirklich helfen (Live-Daten wie Wetter, " +
|
||||||
|
"Gedächtnis-/Gehirn-Zugriff, echte Analyse). Aber NICHT für Triviales, das du eh weißt (Uhrzeit, " +
|
||||||
|
"Smalltalk, Allgemeinwissen) — da antworte direkt. Ketten NIEMALS mehrere Tools wild aneinander oder " +
|
||||||
|
"probier herum (kein SSH + Screenshot + Shell für eine simple Info). Wenn ein Tool länger dauert, sag " +
|
||||||
|
"kurz Bescheid, bevor du es nutzt (z. B. „Moment, das schau ich kurz nach, Commander.“), dann liefere das Ergebnis. " +
|
||||||
|
"WICHTIG für die Sprachausgabe: Beginne mit einem KURZEN ersten Satz (nur wenige Wörter, z. B. " +
|
||||||
|
"„Na klar, Commander!“). Fasse dich kurz und komm auf den Punkt — JEDES Satzzeichen (Komma wie Punkt) " +
|
||||||
|
"wird als hörbare Pause gesprochen, je weniger Wörter und Satzzeichen, desto flüssiger. Vermeide vor " +
|
||||||
|
"allem lange Komma-Ketten und Schachtelsätze; zwei, drei knappe Aussagen reichen. Also lieber " +
|
||||||
|
"„Das Backup lief durch. Keine Fehler. Alles stabil.“ statt „Das Backup ist durchgelaufen, es gab " +
|
||||||
|
"keine Fehler, und alles läuft stabil.“. Kurze Sätze starten sofort hörbar und klingen sauberer. " +
|
||||||
"Halte den gesprochenen Teil in reinem Fließtext (keine Aufzählungszeichen, keine Emojis). " +
|
"Halte den gesprochenen Teil in reinem Fließtext (keine Aufzählungszeichen, keine Emojis). " +
|
||||||
"NUR wenn der Commander ausdrücklich nach Code, Befehlen oder einem Link fragt, gib diese im Text aus — " +
|
"NUR wenn der Commander ausdrücklich nach Code, Befehlen oder einem Link fragt, gib diese im Text aus — " +
|
||||||
"Code in Markdown-Codeblöcken (```), Links als vollständige URL. Diese werden angezeigt, aber NICHT vorgelesen; " +
|
"Code in Markdown-Codeblöcken (```), Links als vollständige URL. Diese werden angezeigt, aber NICHT vorgelesen; " +
|
||||||
@@ -14,6 +27,8 @@ export const SYSTEM_PROMPT =
|
|||||||
"Verwende IMMER echte deutsche Umlaute (ä, ö, ü, ß) und NIEMALS ae, oe, ue oder ss als Ersatz. " +
|
"Verwende IMMER echte deutsche Umlaute (ä, ö, ü, ß) und NIEMALS ae, oe, ue oder ss als Ersatz. " +
|
||||||
"Schreibe Zahlen, Modellbezeichnungen und Abkürzungen EXAKT und normal (z. B. „RX 9070 XT“, „4 GB“, " +
|
"Schreibe Zahlen, Modellbezeichnungen und Abkürzungen EXAKT und normal (z. B. „RX 9070 XT“, „4 GB“, " +
|
||||||
"„25 Grad“) — schreibe Ziffern NIEMALS als Wörter aus und interpretiere sie nicht als Komma-/Dezimalzahlen. " +
|
"„25 Grad“) — schreibe Ziffern NIEMALS als Wörter aus und interpretiere sie nicht als Komma-/Dezimalzahlen. " +
|
||||||
|
"Nenne beim Sprechen KEINE Zeitzonen-Codes (UTC, CET, GMT o.ä.) und keine technischen Zeit-Zusätze — " +
|
||||||
|
"sag einfach die lokale Uhrzeit natürlich (z. B. „Es ist 19 Uhr 21, Commander.“). " +
|
||||||
"Du kannst optional GANZ AM ENDE deiner Antwort einen Stimmungs-Tag anhängen: <emo:happy>, <emo:sad>, " +
|
"Du kannst optional GANZ AM ENDE deiner Antwort einen Stimmungs-Tag anhängen: <emo:happy>, <emo:sad>, " +
|
||||||
"<emo:angry>, <emo:surprised>, <emo:relaxed> oder <emo:neutral>. Er wird nicht angezeigt oder vorgelesen " +
|
"<emo:angry>, <emo:surprised>, <emo:relaxed> oder <emo:neutral>. Er wird nicht angezeigt oder vorgelesen " +
|
||||||
"und steuert nur deinen Gesichtsausdruck — wähle ihn passend zum Inhalt deiner Antwort."
|
"und steuert nur deinen Gesichtsausdruck — wähle ihn passend zum Inhalt deiner Antwort."
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Leichtgewichtige Stimmungs-Heuristik → treibt Lucys Mimik. Regelbasiert (kein Modell).
|
||||||
|
export type Emotion = "neutral" | "happy" | "angry" | "sad" | "surprised" | "relaxed"
|
||||||
|
|
||||||
|
const RULES: [Emotion, RegExp][] = [
|
||||||
|
["happy", /(super|toll|klasse|freu|cool|prima|perfekt|danke|großartig|wunderbar|gerne|haha)/i],
|
||||||
|
["surprised", /(wow|wirklich\?|krass|unglaublich|echt\?|tatsächlich|\?!|!\?|oha)/i],
|
||||||
|
["angry", /(fehler|kaputt|mist|verdammt|nervt|schlecht|problem|ärgerlich|leider nicht|geht nicht)/i],
|
||||||
|
["sad", /(leider|schade|traurig|tut mir leid|entschuldigung|sorry|bedauere)/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function sentimentToEmotion(text: string): Emotion {
|
||||||
|
for (const [emo, rx] of RULES) if (rx.test(text)) return emo
|
||||||
|
return "neutral"
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
// Push-to-talk-Aufnahme via MediaRecorder. start beim Druecken, stop beim Loslassen -> Audio-Blob an onAudio.
|
||||||
|
export function usePushToTalk(onAudio: (blob: Blob) => void) {
|
||||||
|
const [recording, setRecording] = useState(false)
|
||||||
|
const recRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const chunksRef = useRef<Blob[]>([])
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (recRef.current) return
|
||||||
|
let stream: MediaStream
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Mikrofon-Zugriff verweigert:", e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamRef.current = stream
|
||||||
|
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
|
||||||
|
const rec = new MediaRecorder(stream, { mimeType: mime })
|
||||||
|
chunksRef.current = []
|
||||||
|
rec.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data) }
|
||||||
|
rec.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mime })
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop())
|
||||||
|
streamRef.current = null
|
||||||
|
recRef.current = null
|
||||||
|
setRecording(false)
|
||||||
|
if (blob.size > 1200) onAudio(blob)
|
||||||
|
}
|
||||||
|
rec.start()
|
||||||
|
recRef.current = rec
|
||||||
|
setRecording(true)
|
||||||
|
}, [onAudio])
|
||||||
|
|
||||||
|
const stop = useCallback(() => { recRef.current?.stop() }, [])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
recRef.current?.stop()
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { recording, start, stop }
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
// Freisprech-VAD (Voice Activity Detection): lauscht dauerhaft am Mikro, erkennt Sprech-Beginn/-Ende
|
||||||
|
// per Energie und liefert komplette Äußerungen als Blob. RÜCKKOPPLUNGS-SCHUTZ: `paused` (Lucy denkt/spricht)
|
||||||
|
// stoppt die Erkennung -> Lucys eigene Stimme triggert das Mikro nicht; + echoCancellation + Abkling-Sperre.
|
||||||
|
|
||||||
|
interface VADOptions {
|
||||||
|
enabled: boolean // VAD-Modus an?
|
||||||
|
paused: boolean // Lucy beschäftigt (thinking/speaking/listening) -> nicht aufnehmen
|
||||||
|
onUtterance: (blob: Blob) => void
|
||||||
|
onListening?: (active: boolean) => void // UI: gerade Sprache am Aufnehmen?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erkennung per ZEIT-DOMÄNEN-RMS mit ADAPTIVEM Rausch-Pegel (kalibriert sich aufs Mikro) — robuster
|
||||||
|
// als feste Frequenz-Schwellen. Schwellen werden relativ zum gemessenen Rauschen berechnet.
|
||||||
|
const START_FRAMES = 3 // so viele laute Frames in Folge -> Start (gegen Klick-Fehlstarts)
|
||||||
|
const SILENCE_MS = 900 // so lange Stille -> Äußerung zu Ende
|
||||||
|
const COOLDOWN_MS = 450 // nach Pause-Ende kurz taub (Echo/Lautsprecher abklingen lassen)
|
||||||
|
|
||||||
|
export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) {
|
||||||
|
const pausedRef = useRef(paused); pausedRef.current = paused
|
||||||
|
const onUtt = useRef(onUtterance); onUtt.current = onUtterance
|
||||||
|
const onLst = useRef(onListening); onLst.current = onListening
|
||||||
|
const resumeAt = useRef(0)
|
||||||
|
|
||||||
|
// Abkling-Sperre: sobald Lucy fertig ist (paused true->false), kurz nicht lauschen
|
||||||
|
useEffect(() => { if (!paused) resumeAt.current = performance.now() + COOLDOWN_MS }, [paused])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
let cancelled = false
|
||||||
|
let stream: MediaStream | null = null
|
||||||
|
let ctx: AudioContext | null = null
|
||||||
|
let raf = 0
|
||||||
|
let rec: MediaRecorder | null = null
|
||||||
|
let chunks: Blob[] = []
|
||||||
|
let speaking = false
|
||||||
|
let silenceStart = 0
|
||||||
|
let voiceFrames = 0
|
||||||
|
|
||||||
|
const setListening = (v: boolean) => onLst.current?.(v)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VAD: Mikrofon-Zugriff verweigert", e); return
|
||||||
|
}
|
||||||
|
if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return }
|
||||||
|
|
||||||
|
const Ctor = window.AudioContext || (window as any).webkitAudioContext
|
||||||
|
ctx = new Ctor()
|
||||||
|
try { await ctx.resume() } catch { /* */ } // WICHTIG: sonst bleibt der Context 'suspended' -> nur Nullen
|
||||||
|
const srcNode = ctx.createMediaStreamSource(stream)
|
||||||
|
const analyser = ctx.createAnalyser()
|
||||||
|
analyser.fftSize = 1024
|
||||||
|
srcNode.connect(analyser)
|
||||||
|
const wave = new Uint8Array(analyser.fftSize)
|
||||||
|
let noiseFloor = 0.015 // adaptiver Rausch-Pegel (kalibriert sich auf das Mikro)
|
||||||
|
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
|
||||||
|
|
||||||
|
const startRec = () => {
|
||||||
|
chunks = []
|
||||||
|
rec = new MediaRecorder(stream!, { mimeType: mime })
|
||||||
|
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data) }
|
||||||
|
rec.onstop = () => {
|
||||||
|
const blob = new Blob(chunks, { type: mime })
|
||||||
|
if (!cancelled && blob.size > 2400) onUtt.current(blob) // Mini-Blobs (Klicks) verwerfen
|
||||||
|
}
|
||||||
|
rec.start()
|
||||||
|
setListening(true)
|
||||||
|
}
|
||||||
|
const stopRec = (emit: boolean) => {
|
||||||
|
if (rec && rec.state !== "inactive") {
|
||||||
|
if (!emit) rec.onstop = null
|
||||||
|
rec.stop()
|
||||||
|
}
|
||||||
|
rec = null
|
||||||
|
setListening(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
analyser.getByteTimeDomainData(wave)
|
||||||
|
let sq = 0
|
||||||
|
for (let i = 0; i < wave.length; i++) { const v = (wave[i] - 128) / 128; sq += v * v }
|
||||||
|
const level = Math.sqrt(sq / wave.length) // RMS-Pegel 0..1
|
||||||
|
const now = performance.now()
|
||||||
|
// Rausch-Pegel langsam an leise Phasen angleichen (nur wenn nicht geredet wird)
|
||||||
|
if (!speaking) noiseFloor += (Math.min(level, noiseFloor * 1.6 + 0.004) - noiseFloor) * 0.05
|
||||||
|
const startTh = Math.max(0.02, noiseFloor * 2.8) // Sprache: deutlich über dem Rauschen
|
||||||
|
const stopTh = Math.max(0.012, noiseFloor * 1.7) // Stille: nahe am Rauschen
|
||||||
|
|
||||||
|
if (pausedRef.current || now < resumeAt.current) {
|
||||||
|
if (speaking) { speaking = false; voiceFrames = 0; silenceStart = 0; stopRec(false) } // verwerfen
|
||||||
|
} else if (!speaking) {
|
||||||
|
if (level > startTh) { voiceFrames++; if (voiceFrames >= START_FRAMES) { speaking = true; silenceStart = 0; startRec() } }
|
||||||
|
else voiceFrames = 0
|
||||||
|
} else {
|
||||||
|
if (level < stopTh) {
|
||||||
|
if (!silenceStart) silenceStart = now
|
||||||
|
else if (now - silenceStart > SILENCE_MS) { speaking = false; silenceStart = 0; stopRec(true) }
|
||||||
|
} else silenceStart = 0
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
try { if (rec && rec.state !== "inactive") { rec.onstop = null; rec.stop() } } catch { /* */ }
|
||||||
|
stream?.getTracks().forEach((t) => t.stop())
|
||||||
|
try { ctx?.close() } catch { /* */ }
|
||||||
|
onLst.current?.(false)
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { usePushToTalk } from "./usePushToTalk"
|
|||||||
import { useVAD } from "./useVAD"
|
import { useVAD } from "./useVAD"
|
||||||
import { AudioQueue } from "./audio"
|
import { AudioQueue } from "./audio"
|
||||||
import { sentimentToEmotion, type Emotion } from "./sentiment"
|
import { sentimentToEmotion, type Emotion } from "./sentiment"
|
||||||
import { BOX_URL, TTS_URL, SYSTEM_PROMPT } from "../../config"
|
import { BOX_URL, SYSTEM_PROMPT } from "../../config"
|
||||||
|
import { stt, tts, SpeechScheduler } from "../../voice-core"
|
||||||
|
|
||||||
// Voll-Duplex-Schleife: PTT -> Box /stt -> Box /chat (SSE Hermes) -> CHUNKS -> lokal /tts -> AudioQueue.
|
// 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
|
// Hirn+STT = Box (wie WebUI), Stimme = lokal (pocket-tts, CPU). Antwort wird in groessere Bloecke
|
||||||
@@ -13,6 +14,13 @@ import { BOX_URL, TTS_URL, SYSTEM_PROMPT } from "../../config"
|
|||||||
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.
|
// 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
|
// 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).
|
// Umlaut ist (echte "ue"-Wörter wie aktuell/neue/Quelle bleiben unangetastet — die sind hier NICHT gelistet).
|
||||||
@@ -51,10 +59,23 @@ export function restoreUmlauts(s: string): string {
|
|||||||
const EMO_TAG = /<emo:(happy|sad|angry|surprised|relaxed|neutral)>/i
|
const EMO_TAG = /<emo:(happy|sad|angry|surprised|relaxed|neutral)>/i
|
||||||
function stripEmoTag(s: string): string { return s.replace(/<emo:\w+>/gi, "").trimEnd() }
|
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 {
|
function cleanForTTS(s: string): string {
|
||||||
return restoreUmlauts(s)
|
return restoreUmlauts(s)
|
||||||
.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1")
|
.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1")
|
||||||
.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(/https?:\/\/\S+/gi, " ").replace(/www\.\S+/gi, " ").replace(/\b\S+@\S+\.\S+\b/g, " ")
|
||||||
.replace(/[*_#>~|`]+/g, " ").replace(/^\s*[-•·]\s+/gm, " ")
|
.replace(/[*_#>~|`]+/g, " ").replace(/^\s*[-•·]\s+/gm, " ")
|
||||||
.replace(/\s*&\s*/g, " und ")
|
.replace(/\s*&\s*/g, " und ")
|
||||||
@@ -72,26 +93,6 @@ function newSessionId(): string {
|
|||||||
return "lucy-" + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
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
|
// 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
|
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 {
|
function extractWindowName(text: string): string | null {
|
||||||
@@ -116,6 +117,10 @@ export function useVoiceAgent() {
|
|||||||
const audioLevel = useRef({ current: 0 })
|
const audioLevel = useRef({ current: 0 })
|
||||||
const emotion = useRef<Emotion>("neutral")
|
const emotion = useRef<Emotion>("neutral")
|
||||||
const queueRef = useRef<AudioQueue | null>(null)
|
const queueRef = useRef<AudioQueue | null>(null)
|
||||||
|
const schedulerRef = useRef<SpeechScheduler | null>(null)
|
||||||
|
const turnAbortRef = useRef<AbortController | null>(null) // laufenden Turn abbrechen (Barge-in)
|
||||||
|
const thinkingRef = useRef(false) // Hermes werkelt noch (Tools) -> Status nach Filler zurück auf 'thinking'
|
||||||
|
const turnT0 = useRef(0) // Perf: Startzeit des aktuellen Turns (Mikro losgelassen)
|
||||||
const sessionId = useRef<string>(newSessionId())
|
const sessionId = useRef<string>(newSessionId())
|
||||||
// Live-Status fuer die Eingabe-Sperre (ohne pressStart-Closure neu zu binden)
|
// Live-Status fuer die Eingabe-Sperre (ohne pressStart-Closure neu zu binden)
|
||||||
const statusRef = useRef<VoiceStatus>("warming")
|
const statusRef = useRef<VoiceStatus>("warming")
|
||||||
@@ -124,9 +129,15 @@ export function useVoiceAgent() {
|
|||||||
const ensureQueue = useCallback(() => {
|
const ensureQueue = useCallback(() => {
|
||||||
if (!queueRef.current) {
|
if (!queueRef.current) {
|
||||||
const q = new AudioQueue()
|
const q = new AudioQueue()
|
||||||
q.onSpeaking = (sp) => setStatus((s) => (sp ? "speaking" : s === "speaking" ? "idle" : s))
|
|
||||||
queueRef.current = q
|
queueRef.current = q
|
||||||
audioLevel.current = q.level
|
audioLevel.current = q.level
|
||||||
|
// Satz-Pipelining: der Scheduler spricht einzelne Sätze, sobald sie aus dem Hirn-Stream
|
||||||
|
// fertig sind. Er (nicht die Queue) treibt den „speaking"-Status, damit es zwischen Sätzen
|
||||||
|
// nicht flackert (die Queue pausiert die Pegelmessung je Satz).
|
||||||
|
const sch = new SpeechScheduler(tts, q)
|
||||||
|
// Nach dem Filler-Satz zurück auf 'thinking', solange Hermes noch werkelt (sonst flackert's auf idle).
|
||||||
|
sch.onBusy = (busy) => setStatus((s) => (busy ? "speaking" : thinkingRef.current ? "thinking" : s === "speaking" ? "idle" : s))
|
||||||
|
schedulerRef.current = sch
|
||||||
}
|
}
|
||||||
return queueRef.current
|
return queueRef.current
|
||||||
}, [])
|
}, [])
|
||||||
@@ -137,16 +148,10 @@ export function useVoiceAgent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
for (let i = 0; i < 120 && !cancelled; i++) {
|
await tts.waitReady(120_000)
|
||||||
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
|
if (cancelled) return
|
||||||
// ein Aufwaerm-Satz (primt alle lazy Pfade); Audio verwerfen
|
// ein Aufwaerm-Satz (primt alle lazy Pfade); Audio verwerfen
|
||||||
try { await ttsToBuffer("Alles bereit, Commander.") } catch { /* */ }
|
try { await tts.synthesize("Alles bereit, Commander.") } catch { /* */ }
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setReady(true); setStatus("idle")
|
setReady(true); setStatus("idle")
|
||||||
})()
|
})()
|
||||||
@@ -156,21 +161,60 @@ export function useVoiceAgent() {
|
|||||||
// Ein kompletter Turn: User-Text (+ optional Bildschirm-Bilder, Multi-Monitor) -> Hermes (SSE) -> Stimme.
|
// Ein kompletter Turn: User-Text (+ optional Bildschirm-Bilder, Multi-Monitor) -> Hermes (SSE) -> Stimme.
|
||||||
const runTurn = useCallback(async (userText: string, images?: string[]) => {
|
const runTurn = useCallback(async (userText: string, images?: string[]) => {
|
||||||
const queue = ensureQueue()
|
const queue = ensureQueue()
|
||||||
|
const scheduler = schedulerRef.current!
|
||||||
|
// Vorherigen Turn (falls noch am Streamen) hart abbrechen -> kein „Weiterreden" nach Barge-in.
|
||||||
|
turnAbortRef.current?.abort()
|
||||||
|
const ac = new AbortController()
|
||||||
|
turnAbortRef.current = ac
|
||||||
|
scheduler.clear(); queue.clear() // frischer Turn: evtl. Reste aus vorherigem Sprechen verwerfen
|
||||||
setStatus("thinking")
|
setStatus("thinking")
|
||||||
|
thinkingRef.current = true // Hermes werkelt (evtl. Tools) -> Status nach Filler zurück auf 'thinking'
|
||||||
let assistant = ""
|
let assistant = ""
|
||||||
|
let firstToken = true
|
||||||
setMessages((m) => [...m, { role: "assistant", text: "" }])
|
setMessages((m) => [...m, { role: "assistant", text: "" }])
|
||||||
|
|
||||||
let spokeAny = false, ttsFailed = false
|
// GANZE Antwort in EINEM Stream an Pocket (nach Hermes-Ende). Pocket kürzt intern den ersten
|
||||||
|
// Chunk (FAST_FIRST) -> schnelles erstes Audio, konsistente Prosodie, wenige Collapse-Regens.
|
||||||
|
// (Client-seitiges Satz-Chunking kämpfte gegen genau diese Optimierung -> verworfen.)
|
||||||
|
let dispatchedAny = false
|
||||||
|
let aborted = false
|
||||||
|
// --- Inkrementelles Sprechen bei TOOL-Turns ------------------------------------------------
|
||||||
|
// Ohne Tools bleibt alles Ein-Stück (Flush erst am Ende) -> gleiche Prosodie/FAST_FIRST wie bisher.
|
||||||
|
// Sobald Hermes ein Tool anstößt (hermes.*-Event), sprechen wir die bis dahin FERTIGEN Sätze schon
|
||||||
|
// -> Lucy redet, WÄHREND das Tool läuft, statt am Ende alles am Stück (sonst 30s+ Totstille bei Tool-Ketten).
|
||||||
|
let spokenLen = 0
|
||||||
|
const SENT_END = /[.!?…](?=[\s"“”„)\]]|$)/g
|
||||||
|
const flushSpeakable = (force: boolean) => {
|
||||||
|
const raw = assistant.slice(spokenLen)
|
||||||
|
if (!raw.trim()) return
|
||||||
|
if (!force && ((raw.match(/```/g)?.length || 0) % 2) === 1) return // offener Code-Zaun -> warten
|
||||||
|
let upto = raw.length
|
||||||
|
if (!force) {
|
||||||
|
let last = -1, m: RegExpExecArray | null
|
||||||
|
SENT_END.lastIndex = 0
|
||||||
|
while ((m = SENT_END.exec(raw))) last = m.index + 1
|
||||||
|
if (last < 0) return // noch kein ganzer Satz fertig -> warten
|
||||||
|
upto = last
|
||||||
|
}
|
||||||
|
spokenLen += upto
|
||||||
|
const seg = cleanForTTS(stripEmoTag(raw.slice(0, upto)))
|
||||||
|
if (seg) { scheduler.push(seg); dispatchedAny = true }
|
||||||
|
}
|
||||||
|
// Sofort-Kontext: lokale Zeit/Datum mitgeben -> Lucy braucht dafür KEIN Tool (spart den 13s-Tool-Tanz).
|
||||||
|
const timeCtx = `\n\n[Sofort-Kontext, DIREKT nutzbar OHNE Tool: Lokale Zeit/Datum beim Commander ist ` +
|
||||||
|
`${new Date().toLocaleString("de-DE", { weekday: "long", day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit" })} Uhr.]`
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${BOX_URL}/api/voice/chat`, {
|
const r = await fetch(`${BOX_URL}/api/voice/chat`, {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ text: userText, session_id: sessionId.current, system: SYSTEM_PROMPT, images: images || [] }),
|
body: JSON.stringify({ text: userText, session_id: sessionId.current, system: SYSTEM_PROMPT + timeCtx, images: images || [] }),
|
||||||
|
signal: ac.signal,
|
||||||
})
|
})
|
||||||
if (!r.ok || !r.body) throw new Error(`Agent ${r.status}`)
|
if (!r.ok || !r.body) throw new Error(`Agent ${r.status}`)
|
||||||
const reader = r.body.getReader()
|
const reader = r.body.getReader()
|
||||||
const dec = new TextDecoder()
|
const dec = new TextDecoder()
|
||||||
let sse = ""
|
let sse = ""
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
if (ac.signal.aborted) break
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
sse += dec.decode(value, { stream: true })
|
sse += dec.decode(value, { stream: true })
|
||||||
@@ -181,7 +225,16 @@ export function useVoiceAgent() {
|
|||||||
// Chat-Chunks -> nicht als solche parsen (ein Tool-Hickup mit error-Objekt darf die Antwort
|
// Chat-Chunks -> nicht als solche parsen (ein Tool-Hickup mit error-Objekt darf die Antwort
|
||||||
// nicht abbrechen). Optional koennte man hier Tool-Status anzeigen.
|
// nicht abbrechen). Optional koennte man hier Tool-Status anzeigen.
|
||||||
const evType = lines.find((l) => l.startsWith("event:"))?.slice(6).trim()
|
const evType = lines.find((l) => l.startsWith("event:"))?.slice(6).trim()
|
||||||
if (evType && evType.startsWith("hermes.")) continue // Tool-Fortschritt (Status bleibt 'thinking')
|
if (evType && evType.startsWith("hermes.")) {
|
||||||
|
// Tool-Fortschritt (Status bleibt 'thinking'). Für die „denkt-lange"-Diagnose zeigen wir
|
||||||
|
// die Agent-Aktivität mit Zeitstempel -> so sieht man, ob Tools/Reasoning die Zeit fressen.
|
||||||
|
if (PERF) {
|
||||||
|
const dt = lines.find((l) => l.startsWith("data:"))?.slice(5).trim().slice(0, 140) || ""
|
||||||
|
plogSend(`Hermes ${evType} +${Math.round(performance.now() - turnT0.current)}ms ${dt}`)
|
||||||
|
}
|
||||||
|
flushSpeakable(false) // Tool läuft an -> die bis hier fertigen Sätze schon sprechen (redet WÄHREND das Tool arbeitet)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const line = lines.find((l) => l.startsWith("data:"))
|
const line = lines.find((l) => l.startsWith("data:"))
|
||||||
if (!line) continue
|
if (!line) continue
|
||||||
const data = line.slice(5).trim()
|
const data = line.slice(5).trim()
|
||||||
@@ -196,33 +249,39 @@ export function useVoiceAgent() {
|
|||||||
}
|
}
|
||||||
const delta = json.choices?.[0]?.delta?.content || ""
|
const delta = json.choices?.[0]?.delta?.content || ""
|
||||||
if (!delta) continue
|
if (!delta) continue
|
||||||
|
if (firstToken) { firstToken = false; plog("Chat-TTFB (1. Hermes-Token)", performance.now() - turnT0.current) }
|
||||||
assistant += delta
|
assistant += delta
|
||||||
|
// Defensiv: falls Hermes seinen internen Umschlag durchreicht (Mid-Turn) -> NICHTS vorlesen.
|
||||||
|
if (assistant.includes("OUT-OF-BAND USER MESSAGE")) { aborted = true; scheduler.clear(); queue.clear(); break }
|
||||||
emotion.current = sentimentToEmotion(assistant)
|
emotion.current = sentimentToEmotion(assistant)
|
||||||
setMessages((m) => { const c = m.slice(); c[c.length - 1] = { role: "assistant", text: stripEmoTag(restoreUmlauts(assistant)) }; return c })
|
setMessages((m) => { const c = m.slice(); c[c.length - 1] = { role: "assistant", text: stripEmoTag(stripMedia(restoreUmlauts(assistant))) }; return c })
|
||||||
}
|
}
|
||||||
|
if (aborted) break
|
||||||
}
|
}
|
||||||
if (!assistant.trim()) { setStatus("idle"); return }
|
if (ac.signal.aborted) return // Turn wurde unterbrochen (Barge-in) -> still beenden
|
||||||
// Defensiv: falls Hermes mal seinen internen Umschlag durchreicht (Mid-Turn), NICHT vorlesen.
|
if (aborted) {
|
||||||
if (assistant.includes("OUT-OF-BAND USER MESSAGE")) {
|
|
||||||
setStatus("error")
|
setStatus("error")
|
||||||
setError("Nachricht kam mitten im Turn an — bitte warten, bis Lucy fertig ist, dann erneut fragen.")
|
setError("Nachricht kam mitten im Turn an — bitte warten, bis Lucy fertig ist, dann erneut fragen.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!assistant.trim()) { setStatus("idle"); return }
|
||||||
// Stimmungs-Tag (falls vorhanden) -> Avatar-Mimik nach BEDEUTUNG (sonst bleibt Sentiment-Heuristik).
|
// Stimmungs-Tag (falls vorhanden) -> Avatar-Mimik nach BEDEUTUNG (sonst bleibt Sentiment-Heuristik).
|
||||||
const emo = assistant.match(EMO_TAG)
|
const emo = assistant.match(EMO_TAG)
|
||||||
if (emo) emotion.current = emo[1].toLowerCase() as Emotion
|
if (emo) emotion.current = emo[1].toLowerCase() as Emotion
|
||||||
// Ganze Antwort STREAMEN -> erstes Audio nach ~1s, lueckenlos (pocket-tts splittet intern in
|
// Rest sprechen: bei Tool-Turns wurde schon segmentweise geflusht; hier kommt das letzte Segment
|
||||||
// Saetze mit natuerlicher Prosodie). Kein Chunking noetig (real-time RTF ~0.74).
|
// (bzw. bei Turns OHNE Tools die GANZE Antwort in einem Rutsch -> FAST_FIRST, gleiche Prosodie wie bisher).
|
||||||
const spoken = cleanForTTS(stripEmoTag(assistant))
|
thinkingRef.current = false // Hermes fertig -> nach der Antwort darf der Status auf idle (kein Rückfall auf 'thinking')
|
||||||
if (spoken) {
|
plog("Text an TTS (Rest)", performance.now() - turnT0.current)
|
||||||
try { await ttsStreamPlay(queue, spoken); spokeAny = true }
|
flushSpeakable(true)
|
||||||
catch (e) { ttsFailed = true; console.error("TTS-Fehler:", e) }
|
await scheduler.idle() // warten, bis Lucy fertig gesprochen hat
|
||||||
}
|
if (dispatchedAny && !scheduler.playedAny) {
|
||||||
if (!spokeAny) {
|
|
||||||
setStatus("error")
|
setStatus("error")
|
||||||
setError(ttsFailed ? "Sprachausgabe fehlgeschlagen — laeuft der lokale TTS-Dienst?" : "Keine Sprachausgabe erzeugt.")
|
setError("Sprachausgabe fehlgeschlagen — laeuft der lokale TTS-Dienst?")
|
||||||
|
} else if (!dispatchedAny) {
|
||||||
|
setStatus("idle")
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AbortError" || ac.signal.aborted) return // absichtlich abgebrochen (Barge-in)
|
||||||
setStatus("error"); setError(`Agent-Antwort fehlgeschlagen: ${e.message}`)
|
setStatus("error"); setError(`Agent-Antwort fehlgeschlagen: ${e.message}`)
|
||||||
}
|
}
|
||||||
}, [ensureQueue])
|
}, [ensureQueue])
|
||||||
@@ -244,15 +303,16 @@ export function useVoiceAgent() {
|
|||||||
|
|
||||||
const handleAudio = useCallback(async (blob: Blob) => {
|
const handleAudio = useCallback(async (blob: Blob) => {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
turnT0.current = performance.now() // Perf: Startpunkt = Mikro losgelassen
|
||||||
|
turnAbortRef.current?.abort() // Barge-in: laufenden Hirn-Turn stoppen (kein Nachschieben)
|
||||||
|
thinkingRef.current = false
|
||||||
ensureQueue().clear()
|
ensureQueue().clear()
|
||||||
|
schedulerRef.current?.clear() // Barge-in: wartende Sätze mit verwerfen
|
||||||
setStatus("transcribing")
|
setStatus("transcribing")
|
||||||
let userText = ""
|
let userText = ""
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
userText = await stt.transcribe(blob)
|
||||||
fd.append("audio", blob, "rec.webm")
|
plog("STT (Mikro->Text)", performance.now() - turnT0.current)
|
||||||
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) {
|
} catch (e: any) {
|
||||||
setStatus("error"); setError(`Spracherkennung fehlgeschlagen: ${e.message}`); return
|
setStatus("error"); setError(`Spracherkennung fehlgeschlagen: ${e.message}`); return
|
||||||
}
|
}
|
||||||
@@ -266,7 +326,7 @@ export function useVoiceAgent() {
|
|||||||
// Auge-Button: Lucy aktiv auf den Bildschirm schauen lassen (ohne Sprachbefehl)
|
// Auge-Button: Lucy aktiv auf den Bildschirm schauen lassen (ohne Sprachbefehl)
|
||||||
const lookAtScreen = useCallback(async () => {
|
const lookAtScreen = useCallback(async () => {
|
||||||
if (!ready || statusRef.current === "thinking" || statusRef.current === "transcribing") return
|
if (!ready || statusRef.current === "thinking" || statusRef.current === "transcribing") return
|
||||||
setError(null); ensureQueue().clear()
|
setError(null); turnT0.current = performance.now(); ensureQueue().clear(); schedulerRef.current?.clear()
|
||||||
let images: string[] = []
|
let images: string[] = []
|
||||||
try { images = ((await window.lucy?.captureScreen()) || []).filter(Boolean) } catch { /* */ }
|
try { images = ((await window.lucy?.captureScreen()) || []).filter(Boolean) } catch { /* */ }
|
||||||
const prompt = "Schau auf meinen Bildschirm und sag mir kurz, was du darauf siehst."
|
const prompt = "Schau auf meinen Bildschirm und sag mir kurz, was du darauf siehst."
|
||||||
@@ -300,7 +360,10 @@ export function useVoiceAgent() {
|
|||||||
const pressEnd = useCallback(() => { stop() }, [stop])
|
const pressEnd = useCallback(() => { stop() }, [stop])
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
turnAbortRef.current?.abort()
|
||||||
|
thinkingRef.current = false
|
||||||
queueRef.current?.clear()
|
queueRef.current?.clear()
|
||||||
|
schedulerRef.current?.clear()
|
||||||
setMessages([]); setError(null); setStatus("idle")
|
setMessages([]); setError(null); setStatus("idle")
|
||||||
sessionId.current = newSessionId() // frischer Gesprächsfaden (Mem0-Gedächtnis bleibt)
|
sessionId.current = newSessionId() // frischer Gesprächsfaden (Mem0-Gedächtnis bleibt)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react"
|
||||||
|
import ReactDOM from "react-dom/client"
|
||||||
|
import App from "./App"
|
||||||
|
import "./styles.css"
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -254,3 +254,29 @@ body {
|
|||||||
}
|
}
|
||||||
.msg.assistant .bubble:hover .msgCopy { opacity: 1; }
|
.msg.assistant .bubble:hover .msgCopy { opacity: 1; }
|
||||||
.msgCopy:hover { background: rgba(255,255,255,0.14); color: #e5e7eb; }
|
.msgCopy:hover { background: rgba(255,255,255,0.14); color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* ---- Hologramm-Look (Holo-Lucy): Silhouetten-Glow + Scanlines + Cyan-Tint ---- */
|
||||||
|
/* drop-shadow folgt der Alpha-Silhouette des transparenten WebGL-Canvas -> billiger „Bloom"-Halo */
|
||||||
|
.avatarCanvas.holo canvas {
|
||||||
|
filter: drop-shadow(0 0 5px rgba(90,222,255,0.85)) drop-shadow(0 0 15px rgba(56,190,255,0.5));
|
||||||
|
}
|
||||||
|
.holoTint {
|
||||||
|
position: absolute; inset: 0; z-index: 2; pointer-events: none; mix-blend-mode: screen;
|
||||||
|
background: radial-gradient(ellipse 60% 55% at 50% 42%, rgba(60,200,255,0.12), rgba(30,120,255,0.05) 62%, transparent 82%);
|
||||||
|
}
|
||||||
|
.holoScan {
|
||||||
|
position: absolute; inset: 0; z-index: 3; pointer-events: none; mix-blend-mode: screen;
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(120,230,255,0.07) 0px, rgba(120,230,255,0.07) 1px, transparent 2px, transparent 4px);
|
||||||
|
animation: holoFlicker 4s infinite;
|
||||||
|
}
|
||||||
|
@keyframes holoFlicker {
|
||||||
|
0%, 100% { opacity: 0.5; } 48% { opacity: 0.46; } 50% { opacity: 0.72; } 52% { opacity: 0.45; }
|
||||||
|
70% { opacity: 0.55; } 71% { opacity: 0.4; } 72% { opacity: 0.55; } 92% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
.holoToggle {
|
||||||
|
position: absolute; top: 6px; right: 6px; z-index: 6; -webkit-app-region: no-drag;
|
||||||
|
width: 26px; height: 24px; border-radius: 7px; cursor: pointer; font-size: 13px;
|
||||||
|
border: 1px solid rgba(125,211,252,0.35); background: rgba(12,12,20,0.5); color: #7dd3fc;
|
||||||
|
}
|
||||||
|
.holoToggle:hover { background: rgba(125,211,252,0.2); }
|
||||||
|
.holoToggle.on { background: rgba(125,211,252,0.28); color: #bae6fd; }
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Box-STT-Adapter — Spracherkennung läuft auf der Box (MC2 /api/voice/stt -> faster-whisper).
|
||||||
|
import { BOX_URL } from "../../config"
|
||||||
|
import type { SttEngine } from "../types"
|
||||||
|
|
||||||
|
export class BoxSttEngine implements SttEngine {
|
||||||
|
readonly id = "box-whisper"
|
||||||
|
constructor(private readonly baseUrl: string = BOX_URL) {}
|
||||||
|
|
||||||
|
async transcribe(audio: Blob): Promise<string> {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("audio", audio, "rec.webm")
|
||||||
|
const r = await fetch(`${this.baseUrl}/api/voice/stt`, { method: "POST", body: fd })
|
||||||
|
if (!r.ok) throw new Error(`STT ${r.status}`)
|
||||||
|
return (await r.json()).text?.trim() || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Pocket-TTS-Adapter — kapselt den lokalen pocket_server (CPU, vom Main-Prozess gespawnt).
|
||||||
|
// Endpunkte: POST /tts {text}->WAV, POST /tts/stream {text}->PCM16 (+ X-Sample-Rate), GET /health.
|
||||||
|
import { TTS_URL } from "../../config"
|
||||||
|
import type { TtsEngine, TtsStreamHandle } from "../types"
|
||||||
|
|
||||||
|
export class PocketTtsEngine implements TtsEngine {
|
||||||
|
readonly id = "pocket"
|
||||||
|
constructor(private readonly baseUrl: string = TTS_URL) {}
|
||||||
|
|
||||||
|
async synthesize(text: string): Promise<ArrayBuffer> {
|
||||||
|
const r = await fetch(`${this.baseUrl}/tts`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`TTS ${r.status}`)
|
||||||
|
return r.arrayBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesizeStream(text: string): Promise<TtsStreamHandle> {
|
||||||
|
const r = await fetch(`${this.baseUrl}/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 sampleRate = Number(r.headers.get("X-Sample-Rate") || "24000")
|
||||||
|
return { stream: r.body, sampleRate }
|
||||||
|
}
|
||||||
|
|
||||||
|
// pocket_server serviert /health erst, wenn das Modell fertig geladen ist (FastAPI-lifespan).
|
||||||
|
async waitReady(timeoutMs = 120_000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const h = await (await fetch(`${this.baseUrl}/health`)).json()
|
||||||
|
if (h.status === "ok") return true
|
||||||
|
} catch { /* Dienst startet evtl. noch (Spawn + Modell-Load) */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 1000))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// voice-core — öffentliche API + aktive Engine-Auswahl.
|
||||||
|
// Aktuell fest: Pocket-TTS (lokal, Kyutai — macht auch Lucys Voice-Cloning) + Box-Whisper-STT.
|
||||||
|
// TTS-Engines beschränkt auf pocket (live) + f5 (GPU-Option). ElevenLabs/Edge/Chatterbox/Piper
|
||||||
|
// waren nur im alten Web-Pfad und sind hier bewusst NICHT dabei. Der Voice-Agent importiert NUR von hier.
|
||||||
|
import { PocketTtsEngine } from "./engines/pocketTts"
|
||||||
|
import { BoxSttEngine } from "./engines/boxStt"
|
||||||
|
import type { SttEngine, TtsEngine } from "./types"
|
||||||
|
|
||||||
|
export const tts: TtsEngine = new PocketTtsEngine()
|
||||||
|
export const stt: SttEngine = new BoxSttEngine()
|
||||||
|
|
||||||
|
export { SpeechScheduler } from "./speech"
|
||||||
|
export type { AudioSink } from "./speech"
|
||||||
|
export type { SttEngine, TtsEngine, TtsStreamHandle } from "./types"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// SpeechScheduler — Satz-Pipelining fürs Voll-Duplex-Gefühl (Etappe 2, Schritt 1).
|
||||||
|
// Nimmt einzelne SÄTZE entgegen, während das Hirn noch streamt, und spricht sie lückenlos
|
||||||
|
// nacheinander: erstes Audio nach dem ERSTEN Satz statt nach der ganzen Antwort.
|
||||||
|
// Barge-in-fest über eine Generation (clear() verwirft laufende + wartende Sätze).
|
||||||
|
import type { TtsEngine, TtsStreamHandle } from "./types"
|
||||||
|
|
||||||
|
// Perf-Diagnose: misst hörbare Stille + Synthese-Zeit je Satz. Abschalten via localStorage lucy_perf=0.
|
||||||
|
const PERF = typeof localStorage !== "undefined" && localStorage.getItem("lucy_perf") !== "0"
|
||||||
|
|
||||||
|
/** Minimaler Audio-Ausgang (AudioQueue erfüllt diese Form) — hält voice-core von der UI entkoppelt. */
|
||||||
|
export interface AudioSink {
|
||||||
|
playPcmStream(stream: ReadableStream<Uint8Array>, sampleRate: number): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpeechScheduler {
|
||||||
|
private pending: string[] = []
|
||||||
|
private running = false
|
||||||
|
private gen = 0
|
||||||
|
private lastPlayEnd = 0 // Zeitstempel Ende des vorigen Satzes (für die „Stille davor"-Messung)
|
||||||
|
/** true, sobald mindestens ein Satz seit dem letzten clear() gesprochen wurde (für Fehlererkennung). */
|
||||||
|
playedAny = false
|
||||||
|
/** Aktiv-Signal: true beim ersten Satz, false wenn alles gesprochen ist (treibt den „speaking"-Status). */
|
||||||
|
onBusy?: (busy: boolean) => void
|
||||||
|
|
||||||
|
constructor(private readonly tts: TtsEngine, private readonly sink: AudioSink) {}
|
||||||
|
|
||||||
|
/** Einen fertigen Satz einreihen (leere werden ignoriert) und ggf. den Pump starten. */
|
||||||
|
push(text: string): void {
|
||||||
|
const t = text.trim()
|
||||||
|
if (!t) return
|
||||||
|
this.pending.push(t)
|
||||||
|
if (!this.running) void this.pump()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Barge-in / frischer Turn: laufende + wartende Sätze verwerfen. Der Audio-Sink wird separat
|
||||||
|
* gestoppt (queue.clear()). Setzt die Fehler-/Erfolgs-Erkennung zurück. */
|
||||||
|
clear(): void {
|
||||||
|
this.gen++
|
||||||
|
this.pending = []
|
||||||
|
this.playedAny = false
|
||||||
|
this.lastPlayEnd = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wartet, bis alle eingereihten Sätze gesprochen sind. */
|
||||||
|
async idle(): Promise<void> {
|
||||||
|
while (this.running || this.pending.length) await new Promise((r) => setTimeout(r, 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
private synth(text: string): Promise<TtsStreamHandle | null> {
|
||||||
|
return this.tts.synthesizeStream(text).catch((e) => { console.error("TTS-Fehler:", e); return null })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pump(): Promise<void> {
|
||||||
|
if (this.running) return
|
||||||
|
this.running = true
|
||||||
|
this.onBusy?.(true)
|
||||||
|
try {
|
||||||
|
// SERIELL, KEIN Prefetch: der pocket_server ist Single-Instanz. synthesizeStream liefert den
|
||||||
|
// Handle schon bei TTFB (Server generiert den Body noch) -> ein vorab angestoßenes Segment
|
||||||
|
// würde den laufenden Lauf KONKURRIEREN lassen (gemessen bei Tool-Turns: gen 17s statt 5s,
|
||||||
|
// TTFB-Spitze 14.7s). Gaplessness INNERHALB einer Antwort macht ohnehin der Server (Satz-Split
|
||||||
|
// + _compress_gaps); hier werden nur die Segmente/Tool-Häppchen der Reihe nach gesprochen.
|
||||||
|
let idx = 0
|
||||||
|
while (this.pending.length) {
|
||||||
|
const myGen = this.gen
|
||||||
|
const tWait = performance.now()
|
||||||
|
const handle = await this.synth(this.pending.shift()!)
|
||||||
|
const synthWait = performance.now() - tWait
|
||||||
|
if (myGen !== this.gen) { if (handle) handle.stream.cancel().catch(() => {}); continue } // ge-cleared -> Server sofort freigeben
|
||||||
|
if (!handle) continue // Synthese fehlgeschlagen -> nächsten Satz versuchen
|
||||||
|
const gap = this.lastPlayEnd ? tWait - this.lastPlayEnd : 0 // hörbare Stille seit dem letzten Satz
|
||||||
|
const tPlay = performance.now()
|
||||||
|
await this.sink.playPcmStream(handle.stream, handle.sampleRate)
|
||||||
|
this.lastPlayEnd = performance.now()
|
||||||
|
this.playedAny = true
|
||||||
|
if (PERF) console.log(`[lucy-perf] TTS Satz #${idx}: Stille davor=${Math.round(gap)}ms · Synth-Warten=${Math.round(synthWait)}ms · Spieldauer=${Math.round(this.lastPlayEnd - tPlay)}ms`)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.running = false
|
||||||
|
this.onBusy?.(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// voice-core — Engine-Adapter-Interface (Etappe 0 des Greenfield-Umbaus).
|
||||||
|
// Ziel: STT/TTS-Backends hinter EINEM Vertrag, damit pocket | f5 | piper | elevenlabs | edge
|
||||||
|
// austauschbar sind (Config/Picker) und der Voice-Agent nicht mehr direkt an URLs klebt.
|
||||||
|
|
||||||
|
export interface SttEngine {
|
||||||
|
/** Stabiler Bezeichner der Engine (z. B. "box-whisper"). */
|
||||||
|
readonly id: string
|
||||||
|
/** Audio (webm/opus/wav …) -> erkannter Text (leer, wenn nichts verstanden). */
|
||||||
|
transcribe(audio: Blob): Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis einer Streaming-Synthese: fortlaufender PCM16-mono-Stream + Samplerate. */
|
||||||
|
export interface TtsStreamHandle {
|
||||||
|
readonly stream: ReadableStream<Uint8Array>
|
||||||
|
readonly sampleRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsEngine {
|
||||||
|
/** Stabiler Bezeichner der Engine (z. B. "pocket"). */
|
||||||
|
readonly id: string
|
||||||
|
/** Vollständige Synthese in einen dekodierbaren Audio-Buffer (z. B. WAV) — für Warm-up. */
|
||||||
|
synthesize(text: string): Promise<ArrayBuffer>
|
||||||
|
/** Streaming-PCM16-Synthese für niedrige Time-to-first-audio (die Live-Antwort). */
|
||||||
|
synthesizeStream(text: string): Promise<TtsStreamHandle>
|
||||||
|
/** Bereitschafts-/Warm-Gate: true, sobald die Engine antwortet (Modell evtl. lazy geladen). */
|
||||||
|
waitReady(timeoutMs?: number): Promise<boolean>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
title Lucy (Dev)
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo Starte Lucy ... (dieses Fenster offen lassen; schliessen = Lucy beenden)
|
||||||
|
echo.
|
||||||
|
call npm run dev
|
||||||
|
echo.
|
||||||
|
echo Lucy wurde beendet. Taste druecken zum Schliessen.
|
||||||
|
pause >nul
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node", "electron-vite/node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user