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

@@ -1,27 +1,102 @@
import { useState } from 'react'
import './App.css'
import TextInputSection from './components/TextInputSection'
import VoiceAssignmentSection from './components/VoiceAssignmentSection'
import type { Role } from './api/tts'
import { detectRoles } from './api/tts'
function App() {
const [text, setText] = useState('')
const [phase, setPhase] = useState<'input' | 'assignment' | 'generated'>('input')
const [isLoading] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [roles, setRoles] = useState<Role[]>([])
const [voiceAssignments, setVoiceAssignments] = useState<Map<string, string>>(new Map())
const [language, setLanguage] = useState('a') // Default: American English
const [speed, setSpeed] = useState(1.0) // Default: 1.0x
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleTextChange = (newText: string) => {
setText(newText)
}
const handleNext = () => {
// TODO: Implement role detection and phase transition
console.log('Moving to voice assignment phase with text:', text)
setPhase('assignment')
const handleNext = async () => {
setIsLoading(true)
setError(null)
try {
// Detect roles in the text
const response = await detectRoles(text)
setRoles(response.roles)
// Initialize voice assignments map (empty initially)
setVoiceAssignments(new Map())
// Move to assignment phase
setPhase('assignment')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to detect roles')
console.error('Role detection error:', err)
} finally {
setIsLoading(false)
}
}
const handleVoiceChange = (roleId: string, voiceId: string) => {
setVoiceAssignments(prev => {
const newMap = new Map(prev)
newMap.set(roleId, voiceId)
return newMap
})
}
const handleLanguageChange = (lang: string) => {
setLanguage(lang)
}
const handleSpeedChange = (newSpeed: number) => {
setSpeed(newSpeed)
}
const handleGenerate = async () => {
setIsGenerating(true)
setError(null)
try {
// TODO: Implement audio generation
console.log('Generating audiobook with:', {
text,
language,
voiceAssignments: Array.from(voiceAssignments.entries()),
speed
})
// Placeholder - will be implemented in later tasks
setTimeout(() => {
setIsGenerating(false)
setPhase('generated')
}, 1000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate audiobook')
console.error('Generation error:', err)
setIsGenerating(false)
}
}
// Check if all roles have assigned voices
const allRolesAssigned = roles.length > 0 && roles.every(role => voiceAssignments.has(role.id))
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto py-8">
<h1 className="text-4xl font-bold text-center mb-8">Text to Audiobook</h1>
{error && (
<div className="max-w-4xl mx-auto mb-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded">
{error}
</div>
)}
{phase === 'input' && (
<TextInputSection
text={text}
@@ -33,9 +108,18 @@ function App() {
)}
{phase === 'assignment' && (
<div className="text-center">
<p>Voice assignment phase (to be implemented)</p>
</div>
<VoiceAssignmentSection
roles={roles}
voiceAssignments={voiceAssignments}
onVoiceChange={handleVoiceChange}
language={language}
onLanguageChange={handleLanguageChange}
speed={speed}
onSpeedChange={handleSpeedChange}
onGenerate={handleGenerate}
isGenerating={isGenerating}
allRolesAssigned={allRolesAssigned}
/>
)}
{phase === 'generated' && (

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>
);
}