fix(voice-clone): persist clone info in localStorage, auto-merge into project knobs, fix clone ID detection in CreateModal

- Move voice clone cache from module-level memory to localStorage
  so it survives page refresh and works across browser tabs
- VoiceAvatarPlaceholder now syncs clone result to localStorage
  immediately after creation (both design and clone paths)
- usePodcastProjectState auto-merges voice clone cache into project
  knobs when loading a project (fills gap for projects created
  before voice clone or when voice clone was created after)
- CreateModal now detects voice clone IDs by prefix (vc_*) not
  just by VOICE_CLONE_ID constant, fixing the mismatch where
  VoiceSelector passes the actual clone ID but CreateModal
  expected the placeholder ID
- AudioRegenerateModal is intentionally per-scene override and
  does not write back to knobs (by design)
- trends.py handler added for podcast topic trend analysis
This commit is contained in:
ajaysi
2026-04-24 20:36:35 +05:30
parent d518365c87
commit fc47445181
6 changed files with 191 additions and 16 deletions

View File

@@ -3,6 +3,7 @@ import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgre
import { keyframes } from '@mui/system';
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver, Settings } from '@mui/icons-material';
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
import { setCachedVoiceCloneInfo } from '../../../../services/podcastApi';
import { getAuthTokenGetter, getApiUrl } from '../../../../api/client';
import { OperationButton } from '../../../shared/OperationButton';
@@ -292,6 +293,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
} catch (e) {
console.warn('Failed to save voice selection to storage', e);
}
// Also persist to cross-phase cache for Write phase
setCachedVoiceCloneInfo({
customVoiceId: customVoiceId || undefined,
voiceSampleUrl: resultAudioUrl || undefined,
engine: engine || 'qwen3',
isVoiceClone: true,
});
if (onVoiceSet) onVoiceSet();
} else {
setError(resp.error || 'Failed to set brand voice');
@@ -510,6 +518,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
if (resp.success) {
setSuccess(resp.message || 'Voice generated successfully');
setResultAudioUrl(resp.preview_audio_url || null);
// Persist to cross-phase cache so Write phase can use it immediately
setCachedVoiceCloneInfo({
customVoiceId: resp.custom_voice_id || undefined,
voiceSampleUrl: resp.preview_audio_url || undefined,
engine: resp.engine || 'qwen3',
isVoiceClone: true,
});
} else {
setError(resp.error || 'Voice generation failed');
}
@@ -557,6 +572,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
if (resp.success) {
setSuccess('Voice generated successfully. Use this for generating your Brand Voice.');
setResultAudioUrl(resp.preview_audio_url || null);
// Persist to cross-phase cache so Write phase can use it immediately
setCachedVoiceCloneInfo({
customVoiceId: resp.custom_voice_id || customVoiceId || undefined,
voiceSampleUrl: resp.preview_audio_url || undefined,
engine: resp.engine || engine || 'qwen3',
isVoiceClone: true,
});
} else {
setError(resp.error || 'Voice clone failed');
}

View File

@@ -319,13 +319,19 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
// Include selected voice in knobs
// If voice clone is selected, include voice clone metadata
const isVoiceClone = selectedVoiceId === VOICE_CLONE_ID || knobs.custom_voice_id === selectedVoiceId;
// VoiceSelector may pass VOICE_CLONE_ID, the actual clone ID (vc_*), or a system voice ID
const selectedLooksLikeClone = selectedVoiceId?.startsWith("vc_") || selectedVoiceId === "MY_VOICE_CLONE";
const isVoiceClone = selectedVoiceId === VOICE_CLONE_ID || selectedLooksLikeClone || knobs.custom_voice_id === selectedVoiceId;
let voiceSampleUrl: string | undefined;
let voiceCloneEngine: string | undefined;
let customVoiceId: string | undefined;
if (isVoiceClone) {
// If VoiceSelector already gave us the real clone ID, use it as fallback
if (selectedLooksLikeClone && selectedVoiceId !== VOICE_CLONE_ID) {
customVoiceId = selectedVoiceId;
}
try {
const voiceCloneInfo = await getLatestVoiceClone();
if (voiceCloneInfo?.success && voiceCloneInfo.custom_voice_id) {

View File

@@ -11,7 +11,7 @@ import {
PodcastBible,
} from '../components/PodcastMaker/types';
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
import { podcastApi } from '../services/podcastApi';
import { podcastApi, getCachedVoiceCloneInfo } from '../services/podcastApi';
export interface PodcastProjectState {
// Project metadata
@@ -79,6 +79,30 @@ const DEFAULT_KNOBS: Knobs = {
bitrate: "standard",
};
/**
* Merge voice clone cache into knobs if the project knobs don't already have it.
* This ensures projects created before voice clone, or after a new clone is made,
* automatically pick up the latest voice clone info.
*/
function mergeVoiceCloneCacheIntoKnobs(knobs: Knobs): Knobs {
// If knobs already has a custom voice ID, trust it (user explicitly set it)
if (knobs.custom_voice_id) {
return knobs;
}
const cached = getCachedVoiceCloneInfo();
if (!cached || !cached.isVoiceClone) {
return knobs;
}
return {
...knobs,
voice_id: knobs.voice_id || "Wise_Woman",
custom_voice_id: cached.customVoiceId,
is_voice_clone: true,
voice_sample_url: cached.voiceSampleUrl,
voice_clone_engine: cached.engine || "qwen3",
};
}
const DEFAULT_STATE: PodcastProjectState = {
project: null,
analysis: null,
@@ -446,7 +470,7 @@ export const usePodcastProjectState = () => {
scriptData: dbProject.script_data,
bible: dbProject.bible,
renderJobs: dbProject.render_jobs || [],
knobs: { ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) },
knobs: mergeVoiceCloneCacheIntoKnobs({ ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) }),
researchProvider: dbProject.research_provider || 'exa',
budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false,

View File

@@ -38,31 +38,63 @@ const DEFAULT_KNOBS: Knobs = {
bitrate: "standard",
};
// In-memory cache for voice clone info to avoid re-fetching per scene
let _voiceCloneCache: {
const VOICE_CLONE_STORAGE_KEY = "alwrity_voice_clone_info";
const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
function _readVoiceCloneCache() {
try {
const raw = localStorage.getItem(VOICE_CLONE_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === "number" && Date.now() - parsed.timestamp < VOICE_CLONE_CACHE_TTL) {
return parsed;
}
} catch {
/* ignore corrupt localStorage */
}
return null;
}
function _writeVoiceCloneCache(info: {
customVoiceId?: string;
voiceSampleUrl?: string;
engine?: string;
isVoiceClone?: boolean;
timestamp: number;
} | null = null;
const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
export function getCachedVoiceCloneInfo() {
if (_voiceCloneCache && Date.now() - _voiceCloneCache.timestamp < VOICE_CLONE_CACHE_TTL) {
return _voiceCloneCache;
}) {
try {
localStorage.setItem(VOICE_CLONE_STORAGE_KEY, JSON.stringify({ ...info, timestamp: Date.now() }));
} catch {
/* ignore localStorage errors (e.g. quota exceeded) */
}
_voiceCloneCache = null;
return null;
}
function _clearVoiceCloneCache() {
try {
localStorage.removeItem(VOICE_CLONE_STORAGE_KEY);
} catch {
/* ignore */
}
}
/**
* Get cached voice clone info from localStorage (survives page refresh).
* Returns null if expired (>30 min) or not set.
*/
export function getCachedVoiceCloneInfo() {
return _readVoiceCloneCache();
}
/**
* Persist voice clone info to localStorage so it survives page refresh
* and is available across tabs.
*/
export function setCachedVoiceCloneInfo(info: {
customVoiceId?: string;
voiceSampleUrl?: string;
engine?: string;
isVoiceClone?: boolean;
}) {
_voiceCloneCache = { ...info, timestamp: Date.now() };
_writeVoiceCloneCache(info);
}
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -365,6 +397,32 @@ export const podcastApi = {
return response.data;
},
async getTrendingTopics(params: {
keywords: string[];
timeframe?: string;
geo?: string;
}): Promise<{
success: boolean;
data?: {
interest_over_time: any[];
interest_by_region: any[];
related_topics: { top: any[]; rising: any[] };
related_queries: { top: any[]; rising: any[] };
timeframe: string;
geo: string;
keywords: string[];
cached: boolean;
};
error?: string;
}> {
const response = await aiApiClient.post("/api/podcast/trends", {
keywords: params.keywords,
timeframe: params.timeframe || "today 12-m",
geo: params.geo || "US",
});
return response.data;
},
async runResearch(params: {
projectId: string;
topic: string;