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.
This commit is contained in:
2025-12-09 14:17:58 +00:00
parent a8636bcf71
commit 69eb0b3507
4 changed files with 298 additions and 22 deletions

View File

@@ -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<Role[]>([])
const [voiceAssignments, setVoiceAssignments] = useState<Map<string, string>>(new Map())
const [ageSettings, setAgeSettings] = useState<Map<string, AgeCategory>>(new Map())
const [scriptSegments, setScriptSegments] = useState<ScriptSegment[]>([])
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() {
<VoiceAssignmentSection
roles={roles}
voiceAssignments={voiceAssignments}
ageSettings={ageSettings}
onVoiceChange={handleVoiceChange}
onAgeChange={handleAgeChange}
language={language}
onLanguageChange={handleLanguageChange}
speed={speed}

View File

@@ -255,3 +255,35 @@ export async function generateAudiobook(payload: GenerateRequest): Promise<Blob>
// 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<Blob> {
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();
}

View File

@@ -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<string, string>;
ageSettings: Map<string, AgeCategory>;
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<AgeCategory, number> = {
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<string | null>(null);
const [audioCache, setAudioCache] = useState<Map<string, string>>(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<HTMLSelectElement>) => {
@@ -168,30 +247,91 @@ export default function VoiceAssignmentSection({
{/* Voice Assignments */}
<div className="space-y-5">
{roles.map((role, index) => (
<div key={role.id} className="flex flex-col sm:flex-row sm:items-center gap-4 p-6 bg-gradient-to-br from-gray-50 to-white rounded-2xl border-2 border-gray-200 hover:border-indigo-300 transition-all duration-200">
<div className="flex items-center gap-4 sm:w-56">
<div className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-500/30">
{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 (
<div key={role.id} className="p-6 bg-gradient-to-br from-gray-50 to-white rounded-2xl border-2 border-gray-200 hover:border-indigo-300 transition-all duration-200">
{/* Header Row */}
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-4">
<div className="flex items-center gap-4 sm:w-56">
<div className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-500/30">
{index + 1}
</div>
<label className="font-bold text-gray-900 text-lg">
{role.label}
</label>
</div>
<select
value={voiceAssignments.get(role.id) || ''}
onChange={(e) => handleVoiceSelect(role.id, e.target.value)}
className="flex-1 p-4 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100 focus:outline-none bg-white text-base font-medium transition-all duration-200"
>
<option value="">Select a voice...</option>
{KOKORO_VOICES.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.label}
</option>
))}
</select>
</div>
<label className="font-bold text-gray-900 text-lg">
{role.label}
</label>
{/* Age Category & Preview Row */}
{hasVoice && (
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
{/* Age Categories */}
<div className="flex-1">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Age Category:
</label>
<div className="flex gap-2 flex-wrap">
{(['young', 'teen', 'adult', 'old'] as AgeCategory[]).map((age) => (
<button
key={age}
onClick={() => handleAgeSelect(role.id, age)}
className={`px-4 py-2 rounded-lg font-semibold text-sm transition-all duration-200 ${
selectedAge === age
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
: 'bg-white text-gray-700 border-2 border-gray-200 hover:border-indigo-300'
}`}
>
{age === 'young' && '👶 Young'}
{age === 'teen' && '🧒 Teen'}
{age === 'adult' && '👤 Adult'}
{age === 'old' && '👴 Old'}
</button>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
Speed: {AGE_SPEED_MAP[selectedAge]}x
</p>
</div>
{/* Preview Button */}
<button
onClick={() => handlePreview(role.id)}
disabled={isPreviewing || isGenerating}
className="px-6 py-3 bg-green-600 text-white font-semibold rounded-xl hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 shadow-lg shadow-green-500/30"
>
{isPreviewing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
Playing...
</>
) : (
<>
<span>🔊</span>
Try Voice
</>
)}
</button>
</div>
)}
</div>
<select
value={voiceAssignments.get(role.id) || ''}
onChange={(e) => handleVoiceSelect(role.id, e.target.value)}
className="flex-1 p-4 border-2 border-gray-200 rounded-xl focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100 focus:outline-none bg-white text-base font-medium transition-all duration-200"
>
<option value="">Select a voice...</option>
{KOKORO_VOICES.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.label}
</option>
))}
</select>
</div>
))}
);
})}
</div>
{/* Settings Grid */}