import { useCallback, useEffect, useRef, useState } from "react"; import { previewVoice } from "../api/client"; import { useErrorModal } from "../contexts/ErrorModalContext"; import { VOICE_EMOTIONS, VOICE_STABILITY_MIN, VOICE_STABILITY_MAX, VOICE_SPEED_MIN, VOICE_SPEED_MAX, VOICE_STYLE_MIN, VOICE_STYLE_MAX, VOICE_TUNING_STEP, serializeVoiceTuning, type VoiceTuning, } from "./voiceTuning"; interface Props { value: VoiceTuning; onChange: (next: VoiceTuning) => void; voiceGender: string; voiceAccent: string; customVoiceId: string; } /** Exact step-3 single-panel markup, extracted into a controlled, self-contained component. * Used by BlogUrlForm (step 3) and ProjectVoiceSettingsCard (change-voice modal). */ export default function AdvancedVoiceOptions({ value, onChange, voiceGender, voiceAccent, customVoiceId }: Props) { const { showError } = useErrorModal(); const [previewState, setPreviewState] = useState<"idle" | "loading" | "playing">("idle"); const previewAudioRef = useRef(null); const previewUrlRef = useRef(null); const stopVoicePreview = useCallback(() => { previewAudioRef.current?.pause(); previewAudioRef.current = null; if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = null; } setPreviewState("idle"); }, []); const handleVoicePreview = useCallback(async () => { if (previewState === "playing") { stopVoicePreview(); return; } if (previewState === "loading") return; setPreviewState("loading"); try { const url = await previewVoice({ voice_gender: voiceGender, voice_accent: voiceAccent, custom_voice_id: customVoiceId || undefined, voice_emotion: serializeVoiceTuning(value.stability, value.speed, value.emotion, value.style, true), }); if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = url; const audio = new Audio(url); audio.addEventListener("ended", stopVoicePreview); previewAudioRef.current = audio; await audio.play(); setPreviewState("playing"); } catch (err: any) { setPreviewState("idle"); const status = err?.response?.status; if (status === 429) showError("Please wait a moment before previewing again."); else if (status === 403) showError("Voice preview requires a Pro or Standard plan."); else showError("Couldn't generate a voice preview. Please try again."); } }, [previewState, stopVoicePreview, showError, voiceGender, voiceAccent, customVoiceId, value]); // Cleanup audio on unmount. useEffect(() => () => stopVoicePreview(), [stopVoicePreview]); const { enabled, stability, speed, emotion, style } = value; return (

Enable

Emotion (optional)
{VOICE_EMOTIONS.map((em) => { const selected = emotion === em.value; return ( ); })}

Pick one to steer delivery, or leave unselected.

Expressiveness {stability.toFixed(2)}
onChange({ ...value, stability: parseFloat(e.target.value) })} className="w-full accent-purple-600 cursor-pointer" />
Steady Expressive
Character {style.toFixed(2)}
onChange({ ...value, style: parseFloat(e.target.value) })} className="w-full accent-purple-600 cursor-pointer" />
Natural Dramatic
{style >= 0.4 && stability >= 0.7 && (

High Character + high Expressiveness can sound distorted — try lowering one.

)}
Speed {speed.toFixed(2)}x
onChange({ ...value, speed: parseFloat(e.target.value) })} className="w-full accent-purple-600 cursor-pointer" />

Narration pace at synthesis (0.7–1.2x).

); }