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:
@@ -152,7 +152,7 @@
|
||||
- Mock fetch calls and verify request format
|
||||
- _Requirements: 9.6_
|
||||
|
||||
- [ ] 9. Implement text input component
|
||||
- [x] 9. Implement text input component
|
||||
- Create TextInputSection component
|
||||
- Add large multiline textarea
|
||||
- Add "Next: Assign voices" button
|
||||
@@ -172,7 +172,7 @@
|
||||
- Test validation message appears for empty text
|
||||
- _Requirements: 1.1, 1.3, 1.4_
|
||||
|
||||
- [ ] 10. Implement voice assignment component
|
||||
- [x] 10. Implement voice assignment component
|
||||
- Create VoiceAssignmentSection component
|
||||
- Display "Assign voices to roles" title
|
||||
- Render role list with labels and dropdowns
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
165
frontend/src/components/VoiceAssignmentSection.tsx
Normal file
165
frontend/src/components/VoiceAssignmentSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user