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

@@ -61,6 +61,8 @@ export interface PodcastProjectState {
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
voice_id: "Wise_Woman",
custom_voice_id: undefined,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
@@ -338,7 +340,12 @@ export const usePodcastProjectState = () => {
budget_cap: payload.budgetCap,
avatar_url: finalAvatarUrl,
});
} catch (error) {
} catch (error: any) {
const errorStr = error?.message || "";
if (errorStr.includes("DUPLICATE_IDEA")) {
// Re-throw duplicate idea error for UI handling
throw error;
}
console.error('Error creating project in database:', error);
// Continue anyway - localStorage fallback
}

View File

@@ -0,0 +1,190 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
export interface SpeechSynthesisOptions {
voice?: SpeechSynthesisVoice;
rate?: number; // 0.1 to 10
pitch?: number; // 0 to 2
volume?: number; // 0 to 1
}
export interface UseTextToSpeechReturn {
speak: (text: string, options?: SpeechSynthesisOptions) => void;
stop: () => void;
pause: () => void;
resume: () => void;
isSupported: boolean;
isSpeaking: boolean;
isPaused: boolean;
voices: SpeechSynthesisVoice[];
currentText: string | null;
}
// Singleton to manage global speech synthesis state
let globalIsSpeaking = false;
let globalIsPaused = false;
let globalCurrentText: string | null = null;
let globalOnStateChange: ((state: { isSpeaking: boolean; isPaused: boolean; currentText: string | null }) => void) | null = null;
const notifyStateChange = () => {
if (globalOnStateChange) {
globalOnStateChange({
isSpeaking: globalIsSpeaking,
isPaused: globalIsPaused,
currentText: globalCurrentText,
});
}
};
export const useTextToSpeech = (): UseTextToSpeechReturn => {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const synthRef = useRef<SpeechSynthesis | null>(null);
const isSupported = typeof window !== 'undefined' && 'speechSynthesis' in window;
// Initialize singleton listener
useEffect(() => {
globalOnStateChange = (state) => {
// Force re-render by using state setter (handled through component-local state)
};
return () => {
globalOnStateChange = null;
};
}, []);
// Load available voices
useEffect(() => {
if (!isSupported) return;
synthRef.current = window.speechSynthesis;
const loadVoices = () => {
const availableVoices = synthRef.current?.getVoices() || [];
setVoices(availableVoices);
};
loadVoices();
// Voices may load asynchronously
synthRef.current.onvoiceschanged = loadVoices;
// Cleanup on unmount - stop any ongoing speech
return () => {
if (synthRef.current) {
synthRef.current.cancel();
synthRef.current.onvoiceschanged = null;
}
};
}, [isSupported]);
const stop = useCallback(() => {
if (synthRef.current) {
synthRef.current.cancel();
}
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
notifyStateChange();
}, []);
const pause = useCallback(() => {
if (synthRef.current && globalIsSpeaking && !globalIsPaused) {
synthRef.current.pause();
globalIsPaused = true;
notifyStateChange();
}
}, []);
const resume = useCallback(() => {
if (synthRef.current && globalIsPaused) {
synthRef.current.resume();
globalIsPaused = false;
notifyStateChange();
}
}, []);
const speak = useCallback((text: string, options?: SpeechSynthesisOptions) => {
if (!isSupported || !synthRef.current || !text?.trim()) return;
// Stop any current speech first
synthRef.current.cancel();
const utterance = new SpeechSynthesisUtterance(text);
// Apply options
if (options?.voice) {
utterance.voice = options.voice;
}
if (options?.rate !== undefined) {
utterance.rate = Math.max(0.1, Math.min(10, options.rate));
}
if (options?.pitch !== undefined) {
utterance.pitch = Math.max(0, Math.min(2, options.pitch));
}
if (options?.volume !== undefined) {
utterance.volume = Math.max(0, Math.min(1, options.volume));
}
// Set up event handlers
utterance.onstart = () => {
globalIsSpeaking = true;
globalIsPaused = false;
globalCurrentText = text;
notifyStateChange();
};
utterance.onend = () => {
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
notifyStateChange();
};
utterance.onerror = (event) => {
console.error('Speech synthesis error:', event.error);
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
notifyStateChange();
};
utterance.onpause = () => {
globalIsPaused = true;
notifyStateChange();
};
utterance.onresume = () => {
globalIsPaused = false;
notifyStateChange();
};
synthRef.current.speak(utterance);
}, [isSupported]);
// Use local state for reactivity but sync with global state
const [localIsSpeaking, setLocalIsSpeaking] = useState(false);
const [localIsPaused, setLocalIsPaused] = useState(false);
const [localCurrentText, setLocalCurrentText] = useState<string | null>(null);
// Sync with global state periodically
useEffect(() => {
const interval = setInterval(() => {
setLocalIsSpeaking(globalIsSpeaking);
setLocalIsPaused(globalIsPaused);
setLocalCurrentText(globalCurrentText);
}, 100);
return () => clearInterval(interval);
}, []);
return {
speak,
stop,
pause,
resume,
isSupported,
isSpeaking: localIsSpeaking,
isPaused: localIsPaused,
voices,
currentText: localCurrentText,
};
};
export default useTextToSpeech;