diff --git a/.kiro/specs/text-to-audiobook/tasks.md b/.kiro/specs/text-to-audiobook/tasks.md index dfd32c7..6f1031a 100644 --- a/.kiro/specs/text-to-audiobook/tasks.md +++ b/.kiro/specs/text-to-audiobook/tasks.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b36760d..52ba563 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]) + const [voiceAssignments, setVoiceAssignments] = useState>(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(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 (

Text to Audiobook

+ {error && ( +
+ {error} +
+ )} + {phase === 'input' && ( -

Voice assignment phase (to be implemented)

-
+ )} {phase === 'generated' && ( diff --git a/frontend/src/components/VoiceAssignmentSection.tsx b/frontend/src/components/VoiceAssignmentSection.tsx new file mode 100644 index 0000000..94ec9d4 --- /dev/null +++ b/frontend/src/components/VoiceAssignmentSection.tsx @@ -0,0 +1,165 @@ +import type { Role } from '../api/tts'; + +interface VoiceAssignmentSectionProps { + roles: Role[]; + voiceAssignments: Map; + 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) => { + onLanguageChange(e.target.value); + }; + + const handleSpeedChange = (e: React.ChangeEvent) => { + const newSpeed = parseFloat(e.target.value); + onSpeedChange(newSpeed); + }; + + const isGenerateDisabled = !allRolesAssigned || isGenerating; + + return ( +
+

Assign voices to roles

+ + {/* Role-to-Voice Mapping Section */} +
+

Voice Assignments

+
+ {roles.map((role) => ( +
+ + +
+ ))} +
+
+ + {/* Language Selector */} +
+

Language

+ +
+ + {/* Speed Slider */} +
+

Playback Speed

+
+ 0.5x + + 1.5x + + {speed.toFixed(1)}x + +
+
+ + {/* Generate Button */} +
+ +
+ + {/* Validation Message */} + {!allRolesAssigned && !isGenerating && ( +
+ Please assign voices to all roles before generating the audiobook. +
+ )} +
+ ); +}