Implement voice assignment component (Task 10)

- Created VoiceAssignmentSection component with role-to-voice mapping
- Added language selector with American English, British English, French, Japanese
- Added speed slider (0.5x - 1.5x range)
- Implemented Generate Audiobook button with proper disabled state
- Integrated component into App.tsx with state management
- Added role detection API call on text submission
- Validates all roles have assigned voices before enabling generation

Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.3
This commit is contained in:
2025-12-09 07:48:19 +00:00
parent 85a71fac2c
commit e92d0349bf
3 changed files with 259 additions and 10 deletions

View File

@@ -0,0 +1,165 @@
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;
isGenerating: boolean;
allRolesAssigned: boolean;
}
interface VoiceOption {
id: string;
label: string;
gender: 'male' | 'female';
accent: string;
}
interface LanguageOption {
code: string;
label: string;
}
// Kokoro voice options as defined in the design document
const KOKORO_VOICES: VoiceOption[] = [
{ id: 'af_bella', label: 'Bella (Female, American)', gender: 'female', accent: 'American' },
{ id: 'af_sarah', label: 'Sarah (Female, American)', gender: 'female', accent: 'American' },
{ id: 'am_adam', label: 'Adam (Male, American)', gender: 'male', accent: 'American' },
{ id: 'am_michael', label: 'Michael (Male, American)', gender: 'male', accent: 'American' },
{ id: 'bf_emma', label: 'Emma (Female, British)', gender: 'female', accent: 'British' },
{ id: 'bf_isabella', label: 'Isabella (Female, British)', gender: 'female', accent: 'British' },
{ id: 'bm_george', label: 'George (Male, British)', gender: 'male', accent: 'British' },
{ id: 'bm_lewis', label: 'Lewis (Male, British)', gender: 'male', accent: 'British' },
];
// Language options as defined in the design document
const LANGUAGES: LanguageOption[] = [
{ code: 'a', label: 'American English' },
{ code: 'b', label: 'British English' },
{ code: 'f', label: 'French' },
{ code: 'j', label: 'Japanese' },
];
export default function VoiceAssignmentSection({
roles,
voiceAssignments,
onVoiceChange,
language,
onLanguageChange,
speed,
onSpeedChange,
onGenerate,
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="w-full max-w-4xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Assign voices to roles</h2>
{/* Role-to-Voice Mapping Section */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4">Voice Assignments</h3>
<div className="space-y-4">
{roles.map((role) => (
<div key={role.id} className="flex items-center gap-4">
<label className="w-40 font-medium text-gray-700">
{role.label}:
</label>
<select
value={voiceAssignments.get(role.id) || ''}
onChange={(e) => handleVoiceSelect(role.id, e.target.value)}
className="flex-1 p-2 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none"
>
<option value="">Select a voice...</option>
{KOKORO_VOICES.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.label}
</option>
))}
</select>
</div>
))}
</div>
</div>
{/* Language Selector */}
<div className="mb-6">
<h3 className="text-xl font-semibold mb-4">Language</h3>
<select
value={language}
onChange={handleLanguageSelect}
className="w-full p-2 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
</div>
{/* Speed Slider */}
<div className="mb-8">
<h3 className="text-xl font-semibold mb-4">Playback Speed</h3>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-12">0.5x</span>
<input
type="range"
min="0.5"
max="1.5"
step="0.1"
value={speed}
onChange={handleSpeedChange}
className="flex-1"
/>
<span className="text-sm text-gray-600 w-12">1.5x</span>
<span className="font-semibold text-gray-800 w-16 text-center">
{speed.toFixed(1)}x
</span>
</div>
</div>
{/* Generate Button */}
<div className="flex justify-center">
<button
onClick={onGenerate}
disabled={isGenerateDisabled}
className={`px-8 py-3 rounded-lg font-semibold text-white transition-colors ${
isGenerateDisabled
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 cursor-pointer'
}`}
>
{isGenerating ? 'Generating...' : 'Generate Audiobook'}
</button>
</div>
{/* Validation Message */}
{!allRolesAssigned && !isGenerating && (
<div className="mt-4 p-3 bg-yellow-100 border border-yellow-400 text-yellow-800 rounded text-center">
Please assign voices to all roles before generating the audiobook.
</div>
)}
</div>
);
}