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:
64
backend/api/podcast/handlers/trends.py
Normal file
64
backend/api/podcast/handlers/trends.py
Normal 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)}"
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user