From 69eb0b3507d52ff9ada2f8f2b7415c32463b6fd1 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 9 Dec 2025 14:17:58 +0000 Subject: [PATCH] Add voice preview and age category modifiers Features: - Voice preview button to test voices before generation - Age category selection (young, teen, adult, old) - Age categories adjust voice speed automatically: - Young: 1.15x (faster, energetic) - Teen: 1.08x (slightly faster) - Adult: 1.0x (normal) - Old: 0.85x (slower, measured) - Preview caching to avoid regenerating same voice - Backend /api/tts/preview endpoint for voice samples - Frontend previewVoice API function This allows users to hear how each voice sounds and adjust character ages to make them sound younger or older. --- backend/main.py | 91 +++++++++ frontend/src/App.tsx | 13 ++ frontend/src/api/tts.ts | 32 +++ .../src/components/VoiceAssignmentSection.tsx | 184 +++++++++++++++--- 4 files changed, 298 insertions(+), 22 deletions(-) diff --git a/backend/main.py b/backend/main.py index 157bfdd..fcdabd4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,6 +62,13 @@ class TTSRequest(BaseModel): roles: List[RoleVoiceMapping] = Field(..., description="Role to voice mappings") speed: Optional[float] = Field(1.0, description="Playback speed (0.5-1.5)") +class VoicePreviewRequest(BaseModel): + """Request model for voice preview endpoint.""" + voice: str = Field(..., description="Kokoro voice ID to preview") + lang: str = Field(..., description="Language code (a, b, f, j)") + speed: Optional[float] = Field(1.0, description="Playback speed (0.5-1.5)") + text: Optional[str] = Field(None, description="Custom text to preview (uses default if not provided)") + class ErrorResponse(BaseModel): """Standard error response model.""" error: str = Field(..., description="Error type") @@ -234,6 +241,90 @@ async def generate_script(request: ScriptRequest): } ) +@app.post("/api/tts/preview") +async def preview_voice(request: VoicePreviewRequest): + """ + Generates a short audio preview of a voice. + + Args: + request: VoicePreviewRequest containing voice ID, language, speed, and optional text + + Returns: + StreamingResponse with audio/wav content + + Raises: + 400: If validation fails + 500: If audio generation fails + """ + logger.info(f"Voice preview requested: voice={request.voice}, lang={request.lang}, speed={request.speed}") + + # Validate language code + supported_langs = ['a', 'b', 'e', 'f', 'h', 'i', 'j', 'p', 'z'] + if request.lang not in supported_langs: + logger.error(f"Invalid language code: {request.lang}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": f"Invalid language code: {request.lang}", + "details": {"field": "lang", "supported": supported_langs} + } + ) + + # Validate speed range + if request.speed < 0.5 or request.speed > 1.5: + logger.error(f"Invalid speed value: {request.speed}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "error": "ValidationError", + "message": "Speed must be between 0.5 and 1.5", + "details": {"field": "speed", "value": request.speed, "range": [0.5, 1.5]} + } + ) + + # Use provided text or default preview text + preview_text = request.text or "Hello! This is a preview of this voice. How does it sound?" + + try: + # Get pipeline for language + pipeline = tts_service.get_pipeline(request.lang) + + # Generate preview audio + audio = tts_service.synthesize_segment( + pipeline=pipeline, + text=preview_text, + voice=request.voice, + speed=request.speed + ) + + # Convert to WAV bytes + import io + import soundfile as sf + buffer = io.BytesIO() + sf.write(buffer, audio, tts_service.SAMPLE_RATE, format='WAV') + buffer.seek(0) + audio_bytes = buffer.read() + + logger.info(f"Generated voice preview: {len(audio_bytes)} bytes") + + return StreamingResponse( + iter([audio_bytes]), + media_type="audio/wav", + headers={"Content-Disposition": "inline; filename=preview.wav"} + ) + + except Exception as e: + logger.error(f"Error during voice preview: {str(e)}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": "PreviewGenerationError", + "message": "Failed to generate voice preview", + "details": {"error": str(e)} + } + ) + @app.post("/api/tts") async def generate_audiobook(request: TTSRequest): """ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b7d9c9..26b466a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,12 +7,15 @@ import AudioPlayerSection from './components/AudioPlayerSection' import type { Role, ScriptSegment } from './api/tts' import { detectRoles, generateScript, generateAudiobook } from './api/tts' +type AgeCategory = 'young' | 'teen' | 'adult' | 'old'; + function App() { const [text, setText] = useState('') const [phase, setPhase] = useState<'input' | 'assignment' | 'review' | 'generated'>('input') const [isLoading, setIsLoading] = useState(false) const [roles, setRoles] = useState([]) const [voiceAssignments, setVoiceAssignments] = useState>(new Map()) + const [ageSettings, setAgeSettings] = useState>(new Map()) const [scriptSegments, setScriptSegments] = useState([]) const [language, setLanguage] = useState('a') // Default: American English const [speed, setSpeed] = useState(1.0) // Default: 1.0x @@ -56,6 +59,14 @@ function App() { }) } + const handleAgeChange = (roleId: string, age: AgeCategory) => { + setAgeSettings(prev => { + const newMap = new Map(prev) + newMap.set(roleId, age) + return newMap + }) + } + const handleLanguageChange = (lang: string) => { setLanguage(lang) } @@ -281,7 +292,9 @@ function App() { // Convert response to blob for audio data return await response.blob(); } + +/** + * Request payload for voice preview + */ +export interface VoicePreviewRequest { + voice: string; + lang: string; + speed?: number; + text?: string; +} + +/** + * Generate a preview of a voice + * @param payload - Preview request with voice, language, and optional speed/text + * @returns Promise resolving to audio blob + * @throws ApiError if the request fails + */ +export async function previewVoice(payload: VoicePreviewRequest): Promise { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/tts/preview`; + + const response = await fetchWithErrorHandling(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + // Convert response to blob for audio data + return await response.blob(); +} diff --git a/frontend/src/components/VoiceAssignmentSection.tsx b/frontend/src/components/VoiceAssignmentSection.tsx index ba234a9..b132e10 100644 --- a/frontend/src/components/VoiceAssignmentSection.tsx +++ b/frontend/src/components/VoiceAssignmentSection.tsx @@ -1,9 +1,15 @@ +import { useState } from 'react'; import type { Role } from '../api/tts'; +import { previewVoice } from '../api/tts'; + +type AgeCategory = 'young' | 'teen' | 'adult' | 'old'; interface VoiceAssignmentSectionProps { roles: Role[]; voiceAssignments: Map; + ageSettings: Map; onVoiceChange: (roleId: string, voiceId: string) => void; + onAgeChange: (roleId: string, age: AgeCategory) => void; language: string; onLanguageChange: (lang: string) => void; speed: number; @@ -130,10 +136,20 @@ const LANGUAGES: LanguageOption[] = [ { code: 'z', label: 'Chinese (δΈ­ζ–‡)' }, ]; +// Age category to speed mapping +const AGE_SPEED_MAP: Record = { + young: 1.15, + teen: 1.08, + adult: 1.0, + old: 0.85, +}; + export default function VoiceAssignmentSection({ roles, voiceAssignments, + ageSettings, onVoiceChange, + onAgeChange, language, onLanguageChange, speed, @@ -143,8 +159,71 @@ export default function VoiceAssignmentSection({ isGenerating, allRolesAssigned, }: VoiceAssignmentSectionProps) { + const [previewingRole, setPreviewingRole] = useState(null); + const [audioCache, setAudioCache] = useState>(new Map()); + const handleVoiceSelect = (roleId: string, voiceId: string) => { onVoiceChange(roleId, voiceId); + // Clear cached preview when voice changes + const cacheKey = `${roleId}-${voiceId}`; + setAudioCache(prev => { + const newCache = new Map(prev); + newCache.delete(cacheKey); + return newCache; + }); + }; + + const handleAgeSelect = (roleId: string, age: AgeCategory) => { + onAgeChange(roleId, age); + // Clear cached preview when age changes + const voiceId = voiceAssignments.get(roleId); + if (voiceId) { + const cacheKey = `${roleId}-${voiceId}`; + setAudioCache(prev => { + const newCache = new Map(prev); + newCache.delete(cacheKey); + return newCache; + }); + } + }; + + const handlePreview = async (roleId: string) => { + const voiceId = voiceAssignments.get(roleId); + if (!voiceId) return; + + const age = ageSettings.get(roleId) || 'adult'; + const previewSpeed = AGE_SPEED_MAP[age]; + const cacheKey = `${roleId}-${voiceId}-${age}`; + + setPreviewingRole(roleId); + + try { + // Check cache first + let audioUrl = audioCache.get(cacheKey); + + if (!audioUrl) { + // Generate new preview + const audioBlob = await previewVoice({ + voice: voiceId, + lang: language, + speed: previewSpeed, + }); + + audioUrl = URL.createObjectURL(audioBlob); + setAudioCache(prev => new Map(prev).set(cacheKey, audioUrl!)); + } + + // Play the audio + if (audioUrl) { + const audio = new Audio(audioUrl); + audio.onended = () => setPreviewingRole(null); + audio.onerror = () => setPreviewingRole(null); + await audio.play(); + } + } catch (error) { + console.error('Preview failed:', error); + setPreviewingRole(null); + } }; const handleLanguageSelect = (e: React.ChangeEvent) => { @@ -168,30 +247,91 @@ export default function VoiceAssignmentSection({ {/* Voice Assignments */}
- {roles.map((role, index) => ( -
-
-
- {index + 1} + {roles.map((role, index) => { + const selectedAge = ageSettings.get(role.id) || 'adult'; + const hasVoice = voiceAssignments.has(role.id); + const isPreviewing = previewingRole === role.id; + + return ( +
+ {/* Header Row */} +
+
+
+ {index + 1} +
+ +
+
- + + {/* Age Category & Preview Row */} + {hasVoice && ( +
+ {/* Age Categories */} +
+ +
+ {(['young', 'teen', 'adult', 'old'] as AgeCategory[]).map((age) => ( + + ))} +
+

+ Speed: {AGE_SPEED_MAP[selectedAge]}x +

+
+ + {/* Preview Button */} + +
+ )}
- -
- ))} + ); + })}
{/* Settings Grid */}