feat: podcast demo mode with ALWRITY_ENABLED_FEATURES support

- Add ALWRITY_ENABLED_FEATURES env var for feature gating
- Podcast-only mode: skip LLM bootstrap, scheduler, persona services
- Enhance video generation prompt with scene context, analysis, narration
- Add voice cloning support via custom_voice_id in WaveSpeed
- Add text-to-speech for research results (browser speechSynthesis)
- Fix render queue to sync images from script phase
- Add WaveSpeed LLM pricing (gpt-oss-120b)
- Fix podcast bible generation error handling
- Refactor RouterManager for feature-based router loading
This commit is contained in:
ajaysi
2026-04-03 06:59:59 +05:30
parent c52b1eabc9
commit 63bb937796
58 changed files with 3568 additions and 1597 deletions

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { IconButton, Tooltip, CircularProgress, Box, Menu, MenuItem, ListItemIcon, ListItemText, FormControl, Select, Slider, Typography } from '@mui/material';
import { VolumeUp as VolumeUpIcon, Stop as StopIcon, PlayArrow as PlayArrowIcon, Settings as SettingsIcon } from '@mui/icons-material';
import { useTextToSpeech, SpeechSynthesisOptions } from '../../hooks/useTextToSpeech';
interface TextToSpeechButtonProps {
text: string;
textToSpeak?: string; // Optional different text to speak (e.g., shorter version)
options?: SpeechSynthesisOptions;
size?: 'small' | 'medium' | 'large';
showSettings?: boolean;
disabled?: boolean;
}
export const TextToSpeechButton: React.FC<TextToSpeechButtonProps> = ({
text,
textToSpeak,
options,
size = 'medium',
showSettings = false,
disabled = false,
}) => {
const { speak, stop, isSpeaking, isSupported, voices, pause, resume, isPaused } = useTextToSpeech();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [selectedVoice, setSelectedVoice] = React.useState<SpeechSynthesisVoice | null>(null);
const [rate, setRate] = React.useState(1);
const [pitch, setPitch] = React.useState(1);
const [volume, setVolume] = React.useState(1);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
if (showSettings) {
setAnchorEl(event.currentTarget);
}
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSpeak = () => {
const textToUse = textToSpeak || text;
if (!textToUse.trim()) return;
if (isSpeaking) {
stop();
} else {
speak(textToUse, {
voice: selectedVoice || undefined,
rate,
pitch,
volume,
...options,
});
}
};
if (!isSupported) {
return null;
}
const iconSize = size === 'small' ? 18 : size === 'medium' ? 24 : 30;
const buttonSize = size === 'small' ? 'small' : size === 'medium' ? 'medium' : 'large';
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center' }}>
<Tooltip title={isSpeaking ? "Stop" : "Read aloud"}>
<IconButton
onClick={handleSpeak}
size={buttonSize}
disabled={disabled || !text.trim()}
sx={{
color: isSpeaking ? '#ef4444' : '#667eea',
backgroundColor: isSpeaking ? 'rgba(239, 68, 68, 0.1)' : 'rgba(102, 126, 234, 0.1)',
'&:hover': {
backgroundColor: isSpeaking ? 'rgba(239, 68, 68, 0.2)' : 'rgba(102, 126, 234, 0.2)',
},
}}
>
{isSpeaking ? <StopIcon sx={{ fontSize: iconSize }} /> : <VolumeUpIcon sx={{ fontSize: iconSize }} />}
</IconButton>
</Tooltip>
{showSettings && (
<>
<Tooltip title="Voice settings">
<IconButton
onClick={handleClick}
size={buttonSize}
sx={{ ml: 0.5, color: 'rgba(0,0,0,0.5)' }}
>
<SettingsIcon sx={{ fontSize: iconSize * 0.75 }} />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
PaperProps={{ sx: { p: 2, minWidth: 280 } }}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Voice Settings
</Typography>
{/* Voice Selection */}
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Voice</Typography>
<Select
value={selectedVoice?.name || ''}
onChange={(e) => {
const voice = voices.find(v => v.name === e.target.value);
setSelectedVoice(voice || null);
}}
displayEmpty
>
<MenuItem value="">
<em>Default</em>
</MenuItem>
{voices.map((voice) => (
<MenuItem key={voice.name} value={voice.name}>
{voice.name.split(' ')[0]}
</MenuItem>
))}
</Select>
</FormControl>
{/* Speed */}
<Typography variant="caption">Speed: {rate}x</Typography>
<Slider
value={rate}
min={0.5}
max={2}
step={0.1}
onChange={(_, value) => setRate(value as number)}
size="small"
sx={{ mb: 2 }}
/>
{/* Pitch */}
<Typography variant="caption">Pitch: {pitch}</Typography>
<Slider
value={pitch}
min={0.5}
max={2}
step={0.1}
onChange={(_, value) => setPitch(value as number)}
size="small"
sx={{ mb: 2 }}
/>
{/* Volume */}
<Typography variant="caption">Volume: {Math.round(volume * 100)}%</Typography>
<Slider
value={volume}
min={0}
max={1}
step={0.1}
onChange={(_, value) => setVolume(value as number)}
size="small"
/>
</Menu>
</>
)}
</Box>
);
};
export default TextToSpeechButton;

View File

@@ -0,0 +1,321 @@
import React, { useState, useEffect, useMemo } from "react";
import {
Box,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
Stack,
Button,
Chip,
CircularProgress,
Tooltip,
alpha,
IconButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import {
Mic,
PlayArrow,
Pause,
CloudUpload,
HelpOutline,
AutoAwesome,
CheckCircle,
} from "@mui/icons-material";
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
export type VoiceOption = {
id: string;
name: string;
personality?: string;
isCustom?: boolean;
previewUrl?: string;
};
interface VoiceSelectorProps {
value: string;
onChange: (voiceId: string) => void;
disabled?: boolean;
showVoiceClone?: boolean;
compact?: boolean;
}
const PREDEFINED_VOICES: VoiceOption[] = [
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content" },
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions" },
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration" },
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics" },
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics" },
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials" },
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements" },
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations" },
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming" },
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches" },
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling" },
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials" },
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content" },
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content" },
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation" },
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content" },
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations" },
];
const VOICE_CLONE_ID = "MY_VOICE_CLONE";
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
value,
onChange,
disabled = false,
showVoiceClone = true,
compact = false,
}) => {
const [voiceClone, setVoiceClone] = useState<VoiceCloneResponse | null>(null);
const [loadingVoiceClone, setLoadingVoiceClone] = useState(false);
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
const voiceOptions = useMemo(() => {
const options: VoiceOption[] = [...PREDEFINED_VOICES];
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
options.unshift({
id: VOICE_CLONE_ID,
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
personality: "Your own voice - cloned from audio sample",
isCustom: true,
previewUrl: voiceClone.preview_audio_url,
});
}
return options;
}, [showVoiceClone, voiceClone]);
const selectedVoice = useMemo(() => {
if (value === VOICE_CLONE_ID && voiceClone?.success) {
return voiceOptions.find(v => v.id === VOICE_CLONE_ID);
}
return voiceOptions.find(v => v.id === value) || voiceOptions[0];
}, [value, voiceOptions, voiceClone]);
useEffect(() => {
if (!showVoiceClone) return;
const fetchVoiceClone = async () => {
try {
setLoadingVoiceClone(true);
const result = await getLatestVoiceClone();
setVoiceClone(result);
} catch (error) {
console.error("Failed to fetch voice clone:", error);
} finally {
setLoadingVoiceClone(false);
}
};
fetchVoiceClone();
}, [showVoiceClone]);
const handlePreview = (voice: VoiceOption) => {
if (!voice.previewUrl) return;
if (playingPreview === voice.id) {
const audio = document.getElementById(`voice-preview-${voice.id}`) as HTMLAudioElement;
if (audio) {
audio.pause();
audio.currentTime = 0;
}
setPlayingPreview(null);
} else {
setPlayingPreview(voice.id);
const audio = new Audio(voice.previewUrl);
audio.id = `voice-preview-${voice.id}`;
audio.onerror = () => {
console.error("Failed to load voice preview audio");
setPlayingPreview(null);
};
audio.onended = () => setPlayingPreview(null);
audio.play().catch((err) => {
console.error("Failed to play voice preview:", err);
setPlayingPreview(null);
});
}
};
const handleChange = (newValue: string) => {
if (newValue === VOICE_CLONE_ID && voiceClone?.success) {
onChange(voiceClone.custom_voice_id || VOICE_CLONE_ID);
} else {
onChange(newValue);
}
};
const isVoiceCloneSelected = value === VOICE_CLONE_ID ||
(voiceClone?.success && voiceClone.custom_voice_id && value === voiceClone.custom_voice_id);
if (compact) {
return (
<FormControl fullWidth size="small">
<InputLabel>Voice</InputLabel>
<Select
value={isVoiceCloneSelected ? VOICE_CLONE_ID : value}
onChange={(e) => handleChange(e.target.value)}
label="Voice"
disabled={disabled}
startAdornment={
<ListItemIcon sx={{ minWidth: 32 }}>
<Mic fontSize="small" sx={{ color: isVoiceCloneSelected ? "#667eea" : "inherit" }} />
</ListItemIcon>
}
>
{voiceOptions.map((voice) => (
<MenuItem key={voice.id} value={voice.id}>
<ListItemText
primary={voice.name}
secondary={voice.isCustom ? "Custom voice clone" : voice.personality?.split(' - ')[0]}
/>
</MenuItem>
))}
</Select>
</FormControl>
);
}
return (
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Voice
</Typography>
<Tooltip title="Choose a system voice or your custom cloned voice" arrow>
<IconButton size="small" sx={{ color: "rgba(0,0,0,0.5)" }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
{showVoiceClone && loadingVoiceClone && (
<CircularProgress size={16} sx={{ ml: 1 }} />
)}
</Stack>
<FormControl fullWidth>
<Select
value={isVoiceCloneSelected ? VOICE_CLONE_ID : value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
renderValue={(selected) => {
const voice = voiceOptions.find(v =>
v.id === selected ||
(selected === VOICE_CLONE_ID && v.isCustom)
);
return (
<Stack direction="row" spacing={1} alignItems="center">
<Mic fontSize="small" sx={{ color: voice?.isCustom ? "#667eea" : "inherit" }} />
<Typography>{voice?.name}</Typography>
{voice?.isCustom && (
<Chip
label="Cloned"
size="small"
sx={{
bgcolor: alpha("#667eea", 0.1),
color: "#667eea",
height: 20,
fontSize: "0.7rem",
}}
/>
)}
</Stack>
);
}}
MenuProps={{
PaperProps: {
sx: {
maxHeight: 400,
},
},
}}
>
{showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id && (
<MenuItem value={VOICE_CLONE_ID} sx={{ borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<ListItemIcon>
<AutoAwesome sx={{ color: "#667eea" }} />
</ListItemIcon>
<ListItemText
primary={
<Stack direction="row" spacing={1} alignItems="center">
<Typography fontWeight={600} sx={{ color: "#667eea" }}>
My Voice Clone
</Typography>
<Chip
icon={<CheckCircle sx={{ fontSize: "14px !important" }} />}
label="Active"
size="small"
sx={{
bgcolor: alpha("#10b981", 0.1),
color: "#10b981",
height: 20,
fontSize: "0.65rem",
'& .MuiChip-icon': { color: "#10b981" }
}}
/>
</Stack>
}
secondary={
voiceClone.preview_audio_url && (
<Button
size="small"
startIcon={playingPreview === VOICE_CLONE_ID ? <Pause /> : <PlayArrow />}
onClick={(e) => {
e.stopPropagation();
handlePreview({
id: VOICE_CLONE_ID,
name: voiceClone.voice_name || "My Voice Clone",
previewUrl: voiceClone.preview_audio_url
});
}}
sx={{ mt: 0.5, textTransform: 'none' }}
>
{playingPreview === VOICE_CLONE_ID ? "Stop" : "Preview"}
</Button>
)
}
/>
</MenuItem>
)}
<MenuItem disabled sx={{ opacity: 0.6 }}>
<Typography variant="caption">System Voices</Typography>
</MenuItem>
{voiceOptions.filter(v => !v.isCustom).map((voice) => (
<MenuItem key={voice.id} value={voice.id}>
<ListItemText
primary={voice.name}
secondary={voice.personality?.split(' - ')[0]}
/>
</MenuItem>
))}
</Select>
</FormControl>
{selectedVoice?.personality && (
<Typography variant="caption" sx={{ color: "text.secondary", mt: 0.5, display: 'block' }}>
{selectedVoice.personality}
</Typography>
)}
{showVoiceClone && !voiceClone?.success && (
<Box sx={{ mt: 2, p: 2, bgcolor: alpha("#f8fafc", 0.5), borderRadius: 2, border: '1px dashed rgba(0,0,0,0.1)' }}>
<Stack direction="row" spacing={1} alignItems="center">
<CloudUpload sx={{ color: "#64748b" }} />
<Typography variant="body2" sx={{ color: "#64748b" }}>
Don't see your voice? Go to Onboarding Voice Cloning to create your custom voice clone.
</Typography>
</Stack>
</Box>
)}
</Box>
);
};
export default VoiceSelector;