From fc47445181021006915181a8939a9b9d918b2aa9 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Fri, 24 Apr 2026 20:36:35 +0530 Subject: [PATCH] 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 --- backend/api/podcast/handlers/trends.py | 64 +++++++++++++++ backend/api/podcast/router.py | 3 +- .../components/VoiceAvatarPlaceholder.tsx | 22 +++++ .../components/PodcastMaker/CreateModal.tsx | 8 +- frontend/src/hooks/usePodcastProjectState.ts | 28 ++++++- frontend/src/services/podcastApi.ts | 82 ++++++++++++++++--- 6 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 backend/api/podcast/handlers/trends.py diff --git a/backend/api/podcast/handlers/trends.py b/backend/api/podcast/handlers/trends.py new file mode 100644 index 00000000..038c81a6 --- /dev/null +++ b/backend/api/podcast/handlers/trends.py @@ -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)}" + ) \ No newline at end of file diff --git a/backend/api/podcast/router.py b/backend/api/podcast/router.py index 5e79f9f3..4257599b 100644 --- a/backend/api/podcast/router.py +++ b/backend/api/podcast/router.py @@ -12,7 +12,7 @@ from api.story_writer.utils.auth import require_authenticated_user from api.story_writer.task_manager import task_manager # 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 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(dubbing.router) router.include_router(broll.router) +router.include_router(trends.router) @router.get("/task/{task_id}/status") diff --git a/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx b/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx index 74bed5cb..8b33a7e7 100644 --- a/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx +++ b/frontend/src/components/OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder.tsx @@ -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'); } diff --git a/frontend/src/components/PodcastMaker/CreateModal.tsx b/frontend/src/components/PodcastMaker/CreateModal.tsx index 92dee699..046ae3a4 100644 --- a/frontend/src/components/PodcastMaker/CreateModal.tsx +++ b/frontend/src/components/PodcastMaker/CreateModal.tsx @@ -319,13 +319,19 @@ export const CreateModal: React.FC = ({ 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) { diff --git a/frontend/src/hooks/usePodcastProjectState.ts b/frontend/src/hooks/usePodcastProjectState.ts index 04a42c64..dda84da5 100644 --- a/frontend/src/hooks/usePodcastProjectState.ts +++ b/frontend/src/hooks/usePodcastProjectState.ts @@ -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, diff --git a/frontend/src/services/podcastApi.ts b/frontend/src/services/podcastApi.ts index 2e4afc0a..6a34cb22 100644 --- a/frontend/src/services/podcastApi.ts +++ b/frontend/src/services/podcastApi.ts @@ -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;