'use client'; /** * Provider-neutral client orchestrator for the auto-voice register-once flow. * * Given a provider id + voice design, it resolves a deterministic voice id, * ensures the voice is registered on the backend via `POST /api/generate/voice` * (which dispatches to the provider's adapter), and caches the reference clip * in IndexedDB so a GC'd voice can be re-registered. Callers decide *whether* * their provider supports registration; this module is provider-agnostic. */ import { db } from '@/lib/utils/database'; import { getDeterministicVoiceId, type VoiceDesign } from '@/lib/audio/voice-design'; export interface VoiceRegistrationRequestConfig { ttsApiKey?: string; ttsBaseUrl?: string; ttsModelId?: string; } function base64ToBlob(base64: string, mimeType?: string): Blob { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new Blob([bytes], { type: mimeType || 'audio/wav' }); } async function blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(reader.error || new Error('Failed to read reference audio')); reader.onload = () => { const result = typeof reader.result === 'string' ? reader.result : ''; const commaIndex = result.indexOf(','); resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result); }; reader.readAsDataURL(blob); }); } // Confirmed-registered + in-flight memos, keyed by (voiceId, backend, credential). // The same voiceId may be unregistered — or inaccessible — on a different backend // or under different credentials, so both the base URL and the API key are part // of the key. Otherwise switching the VoxCPM base URL or account mid-session // would skip re-registration and reuse an id from the old backend/credentials. const registeredThisSession = new Set(); const inFlight = new Map>(); function memoKeyFor(voiceId: string, request: VoiceRegistrationRequestConfig): string { // In-memory only (never persisted or logged), so the raw key identity is fine. return `${voiceId}::${request.ttsBaseUrl ?? ''}::${request.ttsApiKey ?? ''}`; } async function getCachedClip( voiceId: string, ): Promise<{ base64: string; mimeType: string } | undefined> { const row = await db.autoVoiceCache.get(voiceId); if (!row) return undefined; return { base64: await blobToBase64(row.referenceAudio), mimeType: row.mimeType }; } /** * Ensure the agent's deterministic auto voice is registered for `providerId`, * returning its voice id (or undefined when unavailable, so callers fall back * to the inline voice-design prompt). Lazy + idempotent: memoized per session, * reference clip cached in IndexedDB. register-on-invalid is handled by the * endpoint's existence check, which re-registers a GC'd voice from the clip. */ export async function ensureRegisteredVoice( providerId: string, params: { voiceDesign?: VoiceDesign; language?: string }, request: VoiceRegistrationRequestConfig, ): Promise { if (!params.voiceDesign) return undefined; const voiceId = await getDeterministicVoiceId(params.voiceDesign, { providerId, model: request.ttsModelId, }); const memoKey = memoKeyFor(voiceId, request); if (registeredThisSession.has(memoKey)) return voiceId; // Coalesce concurrent calls for the same (voiceId, backend) into one request. const existing = inFlight.get(memoKey); if (existing) return existing; const promise = registerOnce(providerId, voiceId, memoKey, params, request).finally(() => inFlight.delete(memoKey), ); inFlight.set(memoKey, promise); return promise; } async function registerOnce( providerId: string, voiceId: string, memoKey: string, params: { voiceDesign?: VoiceDesign; language?: string }, request: VoiceRegistrationRequestConfig, ): Promise { const cached = await getCachedClip(voiceId); const res = await fetch('/api/generate/voice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerId, voiceId, descriptor: params.voiceDesign, language: params.language, referenceAudioBase64: cached?.base64, mimeType: cached?.mimeType, ...request, }), }); if (!res.ok) return undefined; // graceful fallback to the inline prompt path const data = (await res.json().catch(() => ({}))) as { referenceAudioBase64?: string; mimeType?: string; }; if (data.referenceAudioBase64 && !cached) { await db.autoVoiceCache.put({ voiceId, referenceAudio: base64ToBlob(data.referenceAudioBase64, data.mimeType), mimeType: data.mimeType || 'audio/wav', updatedAt: Date.now(), }); } registeredThisSession.add(memoKey); return voiceId; }