diff --git a/client/src/components/pipeline/CanonCard.jsx b/client/src/components/pipeline/CanonCard.jsx index d1c006205b..316e87e146 100644 --- a/client/src/components/pipeline/CanonCard.jsx +++ b/client/src/components/pipeline/CanonCard.jsx @@ -528,6 +528,7 @@ export default function CanonCard({ onPatchEntry(entry.id, patch)} onExpand={characterExtensions.onExpandCharacter ? () => characterExtensions.onExpandCharacter(entry.id) : null} diff --git a/client/src/components/universe/CharacterDetailEditor.jsx b/client/src/components/universe/CharacterDetailEditor.jsx index b713ce82a4..8e0a2b89a7 100644 --- a/client/src/components/universe/CharacterDetailEditor.jsx +++ b/client/src/components/universe/CharacterDetailEditor.jsx @@ -10,16 +10,28 @@ * only knows the field shape. */ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Plus, Trash2, WandSparkles, Loader2, Palette, Hand, Smile, Package, BookOpen, Eye, Activity, Users, Swords, - Drama, KeyRound, Mic, Images, BadgeCheck, + Drama, KeyRound, Mic, Images, BadgeCheck, Play, } from 'lucide-react'; import { BIBLE_LIMITS as L } from '../../lib/bibleLimits'; import useFieldDraft from '../../hooks/useFieldDraft'; import useRowDraft from '../../hooks/useRowDraft'; import usePendingListRows from '../../hooks/usePendingListRows'; +import useAsyncAction from '../../hooks/useAsyncAction'; +import { + listVoiceEngines, + listVoiceProfiles, + promoteVoicePreset, + renderVoiceProfileBenchmark, + createVoiceDesignCandidate, + createClonedVoiceCandidate, + promoteVoiceProfile, + benchmarkProfileInteractive, + startFineTuningJob, +} from '../../services/apiVoice'; import VoicePicker from '../voice/VoicePicker'; import CollapsibleSection from '../ui/CollapsibleSection'; @@ -748,6 +760,413 @@ function VoiceCanonSection({ entry, onPatch, disabled }) { ); } +function VoiceProfileSection({ universeId, entry, disabled }) { + const [profile, setProfile] = useState(null); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(Boolean(universeId)); + const [loadError, setLoadError] = useState(null); + const [_engineCapability, setEngineCapability] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + + // Voice Design form state + const [designInstructions, setDesignInstructions] = useState(''); + const [designSeed, setDesignSeed] = useState(42); + const [designRate, setDesignRate] = useState(1.0); + + // Consented Cloning form state + const [cloneFile, setCloneFile] = useState(null); + const [cloneFileName, setCloneFileName] = useState(''); + const [cloneTranscript, setCloneTranscript] = useState(''); + const [cloneConsentConfirmed, setCloneConsentConfirmed] = useState(false); + const [cloneLicensePosture, _setCloneLicensePosture] = useState('consented-performance'); + + // Fine-tuning state + const [fineTuneEpochs, setFineTuneEpochs] = useState(5); + const [fineTuneJob, setFineTuneJob] = useState(null); + + const loadGeneration = useRef(0); + + const refreshProfiles = async () => { + if (!universeId || !entry?.id) return; + const result = await listVoiceProfiles({ universeId, characterId: entry.id }, { silent: true }); + const list = Array.isArray(result?.profiles) ? result.profiles : []; + setProfiles(list); + const active = list.find((p) => p.approval?.status === 'approved') || list[0] || null; + if (active) setProfile(active); + }; + + useEffect(() => { + let cancelled = false; + const generation = ++loadGeneration.current; + if (!universeId || !entry?.id) { + setProfile(null); + setProfiles([]); + setLoading(false); + return () => { cancelled = true; }; + } + setLoading(true); + listVoiceProfiles({ universeId, characterId: entry.id }, { silent: true }) + .then((result) => { + if (cancelled || generation !== loadGeneration.current) return; + const list = Array.isArray(result?.profiles) ? result.profiles : []; + setProfiles(list); + const active = list.find((p) => p.approval?.status === 'approved') || list[0] || null; + setProfile(active); + setLoadError(null); + }) + .catch((err) => { + if (!cancelled && generation === loadGeneration.current) { + setLoadError(err?.message || 'Failed to load voice profiles'); + } + }) + .finally(() => { + if (!cancelled && generation === loadGeneration.current) setLoading(false); + }); + return () => { cancelled = true; }; + }, [universeId, entry?.id]); + + useEffect(() => { + if (!universeId) return undefined; + let cancelled = false; + listVoiceEngines({ silent: true }) + .then((result) => { + if (cancelled) return; + const engine = entry?.voiceId?.split(':')[0] || 'qwen3-tts'; + setEngineCapability((result?.engines || []).find((item) => item.id === engine) || null); + }) + .catch(() => { + if (!cancelled) setEngineCapability(null); + }); + return () => { cancelled = true; }; + }, [universeId, entry?.voiceId]); + + const [promotePreset, promotingPreset] = useAsyncAction(async () => { + loadGeneration.current += 1; + const result = await promoteVoicePreset({ + universeId, + characterId: entry.id, + characterName: entry.name || '', + voiceId: entry.voiceId, + }, { silent: true }); + setProfile(result?.profile || null); + await refreshProfiles(); + return result; + }, { errorMessage: 'Could not promote preset' }); + + const [designVoice, designingVoice] = useAsyncAction(async () => { + const result = await createVoiceDesignCandidate({ + universeId, + characterId: entry.id, + characterName: entry.name || '', + instructions: designInstructions, + seed: designSeed, + rate: designRate, + }, { silent: true }); + await refreshProfiles(); + return result; + }, { errorMessage: 'Voice design candidate generation failed' }); + + const [cloneVoice, cloningVoice] = useAsyncAction(async () => { + if (!cloneFile || !cloneConsentConfirmed) return null; + const arrayBuffer = await cloneFile.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const audioBase64 = btoa(binary); + + const result = await createClonedVoiceCandidate({ + universeId, + characterId: entry.id, + characterName: entry.name || '', + filename: cloneFileName || cloneFile.name || 'reference.wav', + audioBase64, + transcript: cloneTranscript, + performerConsentConfirmed: cloneConsentConfirmed, + licensePosture: cloneLicensePosture, + }, { silent: true }); + await refreshProfiles(); + return result; + }, { errorMessage: 'Consented cloning candidate creation failed' }); + + const [renderBenchmark, renderingBenchmark] = useAsyncAction(async () => { + if (!profile?.id) return null; + const result = await renderVoiceProfileBenchmark(profile.id, { silent: true }); + await refreshProfiles(); + return result; + }, { errorMessage: 'Could not render voice benchmark' }); + + const [qualifyInteractive, qualifyingInteractive] = useAsyncAction(async () => { + if (!profile?.id) return null; + const result = await benchmarkProfileInteractive(profile.id, { maxFirstAudioMs: 900 }, { silent: true }); + await refreshProfiles(); + return result; + }, { errorMessage: 'Interactive benchmark qualification failed' }); + + const [promoteSelected, promotingSelected] = useAsyncAction(async (targetProfileId) => { + const result = await promoteVoiceProfile(targetProfileId, {}, { silent: true }); + await refreshProfiles(); + return result; + }, { errorMessage: 'Could not promote candidate profile' }); + + const [startFineTune, startingFineTune] = useAsyncAction(async () => { + if (!profile?.id) return null; + const result = await startFineTuningJob(profile.id, { epochs: fineTuneEpochs }, { silent: true }); + setFineTuneJob(result); + return result; + }, { errorMessage: 'Failed to start fine-tuning' }); + + if (!universeId) return null; + const approved = profile?.approval?.status === 'approved'; + const _benchmarkCount = profile?.benchmark?.lines?.length || 0; + const profileState = approved ? `approved v${profile.version} (${profile.kind})` : profile?.approval?.status || 'not promoted'; + + return ( + +

+ Machine-local voice design, consented cloning, and optional fine-tuning. Candidate profiles never mutate approved character voice until explicitly promoted. +

+ {loadError ?

{loadError}

: null} + + {/* Sub-tab navigation */} +
+ {['overview', 'design', 'clone', 'finetune'].map((tab) => ( + + ))} +
+ + {activeTab === 'overview' && ( +
+ {approved ? ( +
+

+ Active Approved Voice: {profile.voiceId} ({profile.kind}) +

+

+ Model: {profile.modelRevision} · Rate: {profile.delivery?.rate ?? 1} · Studio: {profile.routes?.studio?.enabled ? 'Yes' : 'No'} · Interactive: {profile.routes?.interactive?.enabled ? 'Qualified' : 'Pending qualification'} +

+ {profile.benchmark?.interactiveLatencyMs ? ( +

+ Interactive Latency Benchmark: {profile.benchmark.interactiveLatencyMs}ms (threshold: {profile.routes?.interactive?.maxFirstAudioMs || 900}ms) +

+ ) : null} +
+ ) : ( +

Promote the selected Kokoro or Piper preset to give this character a stable local voice.

+ )} + +
+ + + +
+ + {profile?.benchmark?.lines?.length ? ( +
+

Fixed benchmark renders

+ {profile.benchmark.lines.map((line, index) => ( +
+ {index + 1} + {line.key} + +
+ ))} +
+ ) : null} + + {/* Candidate & Historical Profiles */} + {profiles.length > 1 && ( +
+

Candidate & Previous Profiles

+ {profiles.map((p) => ( +
+
+ {p.voiceId} ({p.kind}, v{p.version}, {p.approval?.status}) +
+ {p.approval?.status !== 'approved' ? ( + + ) : ( + Active + )} +
+ ))} +
+ )} +
+ )} + + {activeTab === 'design' && ( +
+

Design an original character voice via natural language instructions and seed controls (Qwen3-TTS 1.7B Voice Design).

+
+ +
+ + +
+
+ +
+ )} + + {activeTab === 'clone' && ( +
+

Rapid single-speaker cloning with documented consent. Audio remains strictly machine-local.

+
+ +