Feat: Chatterbox-Klon-Referenz hochladen (eigene Stimme via ElevenLabs → lokal unbegrenzt klonen)

Sidecar: /reference (POST Upload → 24kHz-Mono-WAV via PyAV/faster-whisper, kein System-ffmpeg;
GET/DELETE). Chatterbox nutzt aktive Referenz (active.txt, überlebt Restart). Backend: /api/voice/
reference-Proxy. Picker (Chatterbox): „Referenz-Stimme hochladen (mp3/wav)" → setzt Stimme auf
»deine Referenz«. Ermöglicht den User-Plan: Stimme besorgen → EL erzeugen → Chatterbox klont.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 00:55:40 +02:00
parent 75727c6b36
commit eeb397f066
6 changed files with 304 additions and 159 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DRD104Im.js"></script>
<script type="module" crossorigin src="/assets/index-Dtig7Izl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
</head>
<body>
+49 -3
View File
@@ -27,9 +27,35 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "piper")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const [refActive, setRefActive] = useState(false)
const [refUploading, setRefUploading] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const refFileRef = useRef<HTMLInputElement>(null)
const previewAudio = useRef<HTMLAudioElement | null>(null)
// Aktive Chatterbox-Klon-Referenz anzeigen.
useEffect(() => {
fetch("/api/voice/reference").then((r) => r.json()).then((d) => setRefActive(!!d.active)).catch(() => {})
}, [])
const uploadReference = async (file: File) => {
setRefUploading(true)
try {
const fd = new FormData()
fd.append("audio", file)
const r = await fetch("/api/voice/reference", { method: "POST", body: fd })
if (!r.ok) throw new Error(`${r.status}`)
setRefActive(true)
const v = await fetch("/api/voice/voices").then((r) => r.json())
setVoices(v.voices || [])
saveVoice("chatterbox", "clone") // direkt auf die geklonte Stimme schalten
} catch (e) {
console.error("Referenz-Upload fehlgeschlagen:", e)
} finally {
setRefUploading(false)
}
}
// „Probe hören": synthetisiert einen festen Satz mit der aktuellen Engine+Stimme und spielt ihn ab.
const playPreview = async () => {
if (previewing) return
@@ -218,9 +244,29 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
</p>
)}
{engine === "chatterbox" && (
<p className="text-[11px] text-muted-foreground">
Chatterbox läuft auf CPU erste Antwort kann ein paar Sekunden dauern. Lokal + Voice-Cloning (dt. mit Akzent).
</p>
<div className="space-y-1.5">
<input
ref={refFileRef}
type="file"
accept="audio/*,.mp3,.wav,.m4a,.ogg"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadReference(f) }}
/>
<button
onClick={() => refFileRef.current?.click()}
disabled={refUploading}
className="flex w-full items-center justify-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-2 text-xs hover:bg-accent transition-colors disabled:opacity-60"
>
{refUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{refActive ? "Referenz-Stimme ersetzen" : "Referenz-Stimme hochladen (mp3/wav)"}
</button>
<p className="text-[11px] text-muted-foreground">
{refActive
? "✓ Referenz aktiv → wähle oben die Stimme »Chatterbox (deine Referenz)«. "
: "Lad deine (z.B. via ElevenLabs erzeugte) Stimme hoch — Chatterbox klont sie. "}
CPU: paar Sek./Satz. Der Akzent der Referenz überträgt sich.
</p>
</div>
)}
{engine === "elevenlabs" && (
<p className="text-[11px] text-muted-foreground">