- Add generous padding and spacing throughout (p-10, p-12) - Constrain content width with max-w-4xl and max-w-5xl - Increase font sizes for better hierarchy (text-3xl, text-lg) - Add gradient backgrounds to cards and elements - Implement smooth transitions and hover states - Create professional progress stepper with labels - Add shadow effects with color-matched glows - Increase button padding and font sizes - Add gradient borders on hover - Implement proper visual breathing room - Create polished, premium aesthetic - Add smooth cubic-bezier transitions - Improve form input styling with focus rings - Create professional card hover effects
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import type { Role } from '../api/tts';
|
||
|
||
interface VoiceAssignmentSectionProps {
|
||
roles: Role[];
|
||
voiceAssignments: Map<string, string>;
|
||
onVoiceChange: (roleId: string, voiceId: string) => void;
|
||
language: string;
|
||
onLanguageChange: (lang: string) => void;
|
||
speed: number;
|
||
onSpeedChange: (speed: number) => void;
|
||
onGenerate: () => void;
|
||
onSkipReview?: () => void;
|
||
isGenerating: boolean;
|
||
allRolesAssigned: boolean;
|
||
}
|
||
|
||
interface VoiceOption {
|
||
id: string;
|
||
label: string;
|
||
gender: 'male' | 'female';
|
||
accent: string;
|
||
}
|
||
|
||
interface LanguageOption {
|
||
code: string;
|
||
label: string;
|
||
}
|
||
|
||
// All available Kokoro voice options (54 voices across 9 languages)
|
||
const KOKORO_VOICES: VoiceOption[] = [
|
||
// American English - Female (11 voices)
|
||
{ id: 'af_alloy', label: 'Alloy (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_aoede', label: 'Aoede (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_bella', label: 'Bella (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_heart', label: 'Heart (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_jessica', label: 'Jessica (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_kore', label: 'Kore (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_nicole', label: 'Nicole (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_nova', label: 'Nova (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_river', label: 'River (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_sarah', label: 'Sarah (Female, American)', gender: 'female', accent: 'American' },
|
||
{ id: 'af_sky', label: 'Sky (Female, American)', gender: 'female', accent: 'American' },
|
||
|
||
// American English - Male (10 voices)
|
||
{ id: 'am_adam', label: 'Adam (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_echo', label: 'Echo (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_eric', label: 'Eric (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_fenrir', label: 'Fenrir (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_liam', label: 'Liam (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_michael', label: 'Michael (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_onyx', label: 'Onyx (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_puck', label: 'Puck (Male, American)', gender: 'male', accent: 'American' },
|
||
{ id: 'am_santa', label: 'Santa (Male, American)', gender: 'male', accent: 'American' },
|
||
|
||
// British English - Female (4 voices)
|
||
{ id: 'bf_alice', label: 'Alice (Female, British)', gender: 'female', accent: 'British' },
|
||
{ id: 'bf_emma', label: 'Emma (Female, British)', gender: 'female', accent: 'British' },
|
||
{ id: 'bf_isabella', label: 'Isabella (Female, British)', gender: 'female', accent: 'British' },
|
||
{ id: 'bf_lily', label: 'Lily (Female, British)', gender: 'female', accent: 'British' },
|
||
|
||
// British English - Male (4 voices)
|
||
{ id: 'bm_daniel', label: 'Daniel (Male, British)', gender: 'male', accent: 'British' },
|
||
{ id: 'bm_fable', label: 'Fable (Male, British)', gender: 'male', accent: 'British' },
|
||
{ id: 'bm_george', label: 'George (Male, British)', gender: 'male', accent: 'British' },
|
||
{ id: 'bm_lewis', label: 'Lewis (Male, British)', gender: 'male', accent: 'British' },
|
||
|
||
// Spanish - Female (1 voice)
|
||
{ id: 'ef_dora', label: 'Dora (Female, Spanish)', gender: 'female', accent: 'Spanish' },
|
||
|
||
// Spanish - Male (2 voices)
|
||
{ id: 'em_alex', label: 'Alex (Male, Spanish)', gender: 'male', accent: 'Spanish' },
|
||
{ id: 'em_santa', label: 'Santa (Male, Spanish)', gender: 'male', accent: 'Spanish' },
|
||
|
||
// French - Female (1 voice)
|
||
{ id: 'ff_siwis', label: 'Siwis (Female, French)', gender: 'female', accent: 'French' },
|
||
|
||
// Hindi - Female (2 voices)
|
||
{ id: 'hf_alpha', label: 'Alpha (Female, Hindi)', gender: 'female', accent: 'Hindi' },
|
||
{ id: 'hf_beta', label: 'Beta (Female, Hindi)', gender: 'female', accent: 'Hindi' },
|
||
|
||
// Hindi - Male (2 voices)
|
||
{ id: 'hm_omega', label: 'Omega (Male, Hindi)', gender: 'male', accent: 'Hindi' },
|
||
{ id: 'hm_psi', label: 'Psi (Male, Hindi)', gender: 'male', accent: 'Hindi' },
|
||
|
||
// Italian - Female (1 voice)
|
||
{ id: 'if_sara', label: 'Sara (Female, Italian)', gender: 'female', accent: 'Italian' },
|
||
|
||
// Italian - Male (1 voice)
|
||
{ id: 'im_nicola', label: 'Nicola (Male, Italian)', gender: 'male', accent: 'Italian' },
|
||
|
||
// Japanese - Female (4 voices)
|
||
{ id: 'jf_alpha', label: 'Alpha (Female, Japanese)', gender: 'female', accent: 'Japanese' },
|
||
{ id: 'jf_gongitsune', label: 'Gongitsune (Female, Japanese)', gender: 'female', accent: 'Japanese' },
|
||
{ id: 'jf_nezumi', label: 'Nezumi (Female, Japanese)', gender: 'female', accent: 'Japanese' },
|
||
{ id: 'jf_tebukuro', label: 'Tebukuro (Female, Japanese)', gender: 'female', accent: 'Japanese' },
|
||
|
||
// Japanese - Male (1 voice)
|
||
{ id: 'jm_kumo', label: 'Kumo (Male, Japanese)', gender: 'male', accent: 'Japanese' },
|
||
|
||
// Portuguese - Female (1 voice)
|
||
{ id: 'pf_dora', label: 'Dora (Female, Portuguese)', gender: 'female', accent: 'Portuguese' },
|
||
|
||
// Portuguese - Male (2 voices)
|
||
{ id: 'pm_alex', label: 'Alex (Male, Portuguese)', gender: 'male', accent: 'Portuguese' },
|
||
{ id: 'pm_santa', label: 'Santa (Male, Portuguese)', gender: 'male', accent: 'Portuguese' },
|
||
|
||
// Chinese/Mandarin - Female (4 voices)
|
||
{ id: 'zf_xiaobei', label: 'Xiaobei (Female, Chinese)', gender: 'female', accent: 'Chinese' },
|
||
{ id: 'zf_xiaoni', label: 'Xiaoni (Female, Chinese)', gender: 'female', accent: 'Chinese' },
|
||
{ id: 'zf_xiaoxiao', label: 'Xiaoxiao (Female, Chinese)', gender: 'female', accent: 'Chinese' },
|
||
{ id: 'zf_xiaoyi', label: 'Xiaoyi (Female, Chinese)', gender: 'female', accent: 'Chinese' },
|
||
|
||
// Chinese/Mandarin - Male (4 voices)
|
||
{ id: 'zm_yunjian', label: 'Yunjian (Male, Chinese)', gender: 'male', accent: 'Chinese' },
|
||
{ id: 'zm_yunxi', label: 'Yunxi (Male, Chinese)', gender: 'male', accent: 'Chinese' },
|
||
{ id: 'zm_yunxia', label: 'Yunxia (Male, Chinese)', gender: 'male', accent: 'Chinese' },
|
||
{ id: 'zm_yunyang', label: 'Yunyang (Male, Chinese)', gender: 'male', accent: 'Chinese' },
|
||
];
|
||
|
||
// Language options - all supported by Kokoro TTS
|
||
const LANGUAGES: LanguageOption[] = [
|
||
{ code: 'a', label: 'American English' },
|
||
{ code: 'b', label: 'British English' },
|
||
{ code: 'e', label: 'Spanish (Español)' },
|
||
{ code: 'f', label: 'French (Français)' },
|
||
{ code: 'h', label: 'Hindi (हिन्दी)' },
|
||
{ code: 'i', label: 'Italian (Italiano)' },
|
||
{ code: 'j', label: 'Japanese (日本語)' },
|
||
{ code: 'p', label: 'Portuguese (Português)' },
|
||
{ code: 'z', label: 'Chinese (中文)' },
|
||
];
|
||
|
||
export default function VoiceAssignmentSection({
|
||
roles,
|
||
voiceAssignments,
|
||
onVoiceChange,
|
||
language,
|
||
onLanguageChange,
|
||
speed,
|
||
onSpeedChange,
|
||
onGenerate,
|
||
onSkipReview,
|
||
isGenerating,
|
||
allRolesAssigned,
|
||
}: VoiceAssignmentSectionProps) {
|
||
const handleVoiceSelect = (roleId: string, voiceId: string) => {
|
||
onVoiceChange(roleId, voiceId);
|
||
};
|
||
|
||
const handleLanguageSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
onLanguageChange(e.target.value);
|
||
};
|
||
|
||
const handleSpeedChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newSpeed = parseFloat(e.target.value);
|
||
onSpeedChange(newSpeed);
|
||
};
|
||
|
||
const isGenerateDisabled = !allRolesAssigned || isGenerating;
|
||
|
||
return (
|
||
<div className="max-w-5xl mx-auto">
|
||
<div className="card-modern p-10 sm:p-12 space-y-10">
|
||
<div>
|
||
<h2 className="text-3xl font-bold text-gray-900 mb-3">Assign Voices</h2>
|
||
<p className="text-lg text-gray-600">Choose a voice for each character in your story.</p>
|
||
</div>
|
||
|
||
{/* Voice Assignments */}
|
||
<div className="space-y-5">
|
||
{roles.map((role, index) => (
|
||
<div key={role.id} className="flex flex-col sm:flex-row sm:items-center gap-4 p-6 bg-gradient-to-br from-gray-50 to-white rounded-2xl border-2 border-gray-200 hover:border-indigo-300 transition-all duration-200">
|
||
<div className="flex items-center gap-4 sm:w-56">
|
||
<div className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-500/30">
|
||
{index + 1}
|
||
</div>
|
||
<label className="font-bold text-gray-900 text-lg">
|
||
{role.label}
|
||
</label>
|
||
</div>
|
||
<select
|
||
value={voiceAssignments.get(role.id) || ''}
|
||
onChange={(e) => handleVoiceSelect(role.id, e.target.value)}
|
||
className="flex-1 p-4 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100 focus:outline-none bg-white text-base font-medium transition-all duration-200"
|
||
>
|
||
<option value="">Select a voice...</option>
|
||
{KOKORO_VOICES.map((voice) => (
|
||
<option key={voice.id} value={voice.id}>
|
||
{voice.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Settings Grid */}
|
||
<div className="grid sm:grid-cols-2 gap-8 pt-4">
|
||
{/* Language */}
|
||
<div>
|
||
<label className="block text-base font-bold text-gray-900 mb-3">
|
||
Language
|
||
</label>
|
||
<select
|
||
value={language}
|
||
onChange={handleLanguageSelect}
|
||
className="w-full p-4 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100 focus:outline-none bg-white text-base font-medium transition-all duration-200"
|
||
>
|
||
{LANGUAGES.map((lang) => (
|
||
<option key={lang.code} value={lang.code}>
|
||
{lang.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Speed */}
|
||
<div>
|
||
<label className="block text-base font-bold text-gray-900 mb-3">
|
||
Playback Speed: <span className="text-indigo-600">{speed.toFixed(1)}x</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0.5"
|
||
max="1.5"
|
||
step="0.1"
|
||
value={speed}
|
||
onChange={handleSpeedChange}
|
||
className="w-full h-3"
|
||
/>
|
||
<div className="flex justify-between text-sm text-gray-500 mt-2 font-medium">
|
||
<span>0.5x</span>
|
||
<span>1.5x</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Validation Message */}
|
||
{!allRolesAssigned && !isGenerating && (
|
||
<div className="p-5 bg-yellow-50 border-2 border-yellow-200 text-yellow-900 rounded-2xl">
|
||
<p className="font-semibold">⚠️ Please assign voices to all roles before continuing.</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex flex-col sm:flex-row gap-4 justify-end pt-4">
|
||
{onSkipReview && (
|
||
<button
|
||
onClick={onSkipReview}
|
||
disabled={isGenerateDisabled}
|
||
className="px-8 py-4 border-2 border-gray-300 text-gray-700 font-semibold rounded-xl hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||
>
|
||
Skip Review & Generate
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onGenerate}
|
||
disabled={isGenerateDisabled}
|
||
className="btn-primary"
|
||
>
|
||
{isGenerating ? (
|
||
<span className="flex items-center gap-3">
|
||
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
||
Generating...
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-3">
|
||
Continue
|
||
<span className="text-lg">→</span>
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|