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

@@ -0,0 +1,64 @@
"""
Podcast Trends Handler
Endpoints for fetching Google Trends data relevant to podcast topics.
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from loguru import logger
from middleware.auth_middleware import get_current_user
router = APIRouter(prefix="/trends", tags=["Podcast Trends"])
class PodcastTrendsRequest(BaseModel):
keywords: List[str] = Field(..., min_length=1, max_length=5, description="1-5 keywords to analyze")
timeframe: str = Field(default="today 12-m", description="Timeframe: 'today 3-m', 'today 12-m', 'today 5-y', 'all'")
geo: str = Field(default="US", description="Country code: 'US', 'GB', 'IN', etc.")
class PodcastTrendsResponse(BaseModel):
success: bool
data: Optional[Dict[str, Any]] = None
error: Optional[str] = None
@router.post("", response_model=PodcastTrendsResponse)
async def get_podcast_trends(
request: PodcastTrendsRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Fetch Google Trends data for podcast topic keywords."""
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
try:
from services.research.trends import GoogleTrendsService
except (ImportError, RuntimeError) as e:
logger.error(f"[Podcast Trends] GoogleTrendsService unavailable: {e}")
raise HTTPException(
status_code=503,
detail="Google Trends service is currently unavailable. Please try again later."
)
try:
service = GoogleTrendsService()
result = await service.analyze_trends(
keywords=request.keywords,
timeframe=request.timeframe,
geo=request.geo,
user_id=user_id,
)
return PodcastTrendsResponse(success=True, data=result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[Podcast Trends] Error fetching trends for {request.keywords}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch trends data: {str(e)}"
)

View File

@@ -12,7 +12,7 @@ from api.story_writer.utils.auth import require_authenticated_user
from api.story_writer.task_manager import task_manager from api.story_writer.task_manager import task_manager
# Import all handler routers # Import all handler routers
from .handlers import projects, analysis, research, script, audio, images, video, avatar, dubbing, broll from .handlers import projects, analysis, research, script, audio, images, video, avatar, dubbing, broll, trends
# Create main router # Create main router
router = APIRouter(prefix="/api/podcast", tags=["Podcast Maker"]) router = APIRouter(prefix="/api/podcast", tags=["Podcast Maker"])
@@ -28,6 +28,7 @@ router.include_router(video.router)
router.include_router(avatar.router) router.include_router(avatar.router)
router.include_router(dubbing.router) router.include_router(dubbing.router)
router.include_router(broll.router) router.include_router(broll.router)
router.include_router(trends.router)
@router.get("/task/{task_id}/status") @router.get("/task/{task_id}/status")

View File

@@ -3,6 +3,7 @@ import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgre
import { keyframes } from '@mui/system'; 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 { 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 { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
import { setCachedVoiceCloneInfo } from '../../../../services/podcastApi';
import { getAuthTokenGetter, getApiUrl } from '../../../../api/client'; import { getAuthTokenGetter, getApiUrl } from '../../../../api/client';
import { OperationButton } from '../../../shared/OperationButton'; import { OperationButton } from '../../../shared/OperationButton';
@@ -292,6 +293,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
} catch (e) { } catch (e) {
console.warn('Failed to save voice selection to storage', 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(); if (onVoiceSet) onVoiceSet();
} else { } else {
setError(resp.error || 'Failed to set brand voice'); setError(resp.error || 'Failed to set brand voice');
@@ -510,6 +518,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
if (resp.success) { if (resp.success) {
setSuccess(resp.message || 'Voice generated successfully'); setSuccess(resp.message || 'Voice generated successfully');
setResultAudioUrl(resp.preview_audio_url || null); 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 { } else {
setError(resp.error || 'Voice generation failed'); setError(resp.error || 'Voice generation failed');
} }
@@ -557,6 +572,13 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
if (resp.success) { if (resp.success) {
setSuccess('Voice generated successfully. Use this for generating your Brand Voice.'); setSuccess('Voice generated successfully. Use this for generating your Brand Voice.');
setResultAudioUrl(resp.preview_audio_url || null); 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 { } else {
setError(resp.error || 'Voice clone failed'); 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 // Include selected voice in knobs
// If voice clone is selected, include voice clone metadata // 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 voiceSampleUrl: string | undefined;
let voiceCloneEngine: string | undefined; let voiceCloneEngine: string | undefined;
let customVoiceId: string | undefined; let customVoiceId: string | undefined;
if (isVoiceClone) { if (isVoiceClone) {
// If VoiceSelector already gave us the real clone ID, use it as fallback
if (selectedLooksLikeClone && selectedVoiceId !== VOICE_CLONE_ID) {
customVoiceId = selectedVoiceId;
}
try { try {
const voiceCloneInfo = await getLatestVoiceClone(); const voiceCloneInfo = await getLatestVoiceClone();
if (voiceCloneInfo?.success && voiceCloneInfo.custom_voice_id) { if (voiceCloneInfo?.success && voiceCloneInfo.custom_voice_id) {

View File

@@ -11,7 +11,7 @@ import {
PodcastBible, PodcastBible,
} from '../components/PodcastMaker/types'; } from '../components/PodcastMaker/types';
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi'; import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
import { podcastApi } from '../services/podcastApi'; import { podcastApi, getCachedVoiceCloneInfo } from '../services/podcastApi';
export interface PodcastProjectState { export interface PodcastProjectState {
// Project metadata // Project metadata
@@ -79,6 +79,30 @@ const DEFAULT_KNOBS: Knobs = {
bitrate: "standard", 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 = { const DEFAULT_STATE: PodcastProjectState = {
project: null, project: null,
analysis: null, analysis: null,
@@ -446,7 +470,7 @@ export const usePodcastProjectState = () => {
scriptData: dbProject.script_data, scriptData: dbProject.script_data,
bible: dbProject.bible, bible: dbProject.bible,
renderJobs: dbProject.render_jobs || [], renderJobs: dbProject.render_jobs || [],
knobs: { ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) }, knobs: mergeVoiceCloneCacheIntoKnobs({ ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) }),
researchProvider: dbProject.research_provider || 'exa', researchProvider: dbProject.research_provider || 'exa',
budgetCap: dbProject.budget_cap || 50, budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false, showScriptEditor: dbProject.show_script_editor || false,

View File

@@ -38,31 +38,63 @@ const DEFAULT_KNOBS: Knobs = {
bitrate: "standard", bitrate: "standard",
}; };
// In-memory cache for voice clone info to avoid re-fetching per scene const VOICE_CLONE_STORAGE_KEY = "alwrity_voice_clone_info";
let _voiceCloneCache: { 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; customVoiceId?: string;
voiceSampleUrl?: string; voiceSampleUrl?: string;
engine?: string; engine?: string;
isVoiceClone?: boolean; isVoiceClone?: boolean;
timestamp: number; }) {
} | null = null; try {
const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes localStorage.setItem(VOICE_CLONE_STORAGE_KEY, JSON.stringify({ ...info, timestamp: Date.now() }));
} catch {
export function getCachedVoiceCloneInfo() { /* ignore localStorage errors (e.g. quota exceeded) */
if (_voiceCloneCache && Date.now() - _voiceCloneCache.timestamp < VOICE_CLONE_CACHE_TTL) {
return _voiceCloneCache;
} }
_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: { export function setCachedVoiceCloneInfo(info: {
customVoiceId?: string; customVoiceId?: string;
voiceSampleUrl?: string; voiceSampleUrl?: string;
engine?: string; engine?: string;
isVoiceClone?: boolean; isVoiceClone?: boolean;
}) { }) {
_voiceCloneCache = { ...info, timestamp: Date.now() }; _writeVoiceCloneCache(info);
} }
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -365,6 +397,32 @@ export const podcastApi = {
return response.data; 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: { async runResearch(params: {
projectId: string; projectId: string;
topic: string; topic: string;