feat: Add TTS engine selector UI in frontend
- Added engine selection buttons (Kokoro vs Piper) in VoiceAssignmentSection - Implemented getEngines(), setEngine(), and getVoices() API client functions - Engine selector dynamically loads available voices for selected engine - Added visual feedback for active engine with descriptions - Piper engine shows pitch control capability notice - Voice dropdowns update automatically when switching engines - Audio cache clears when switching engines for clean state Users can now easily switch between Kokoro (high quality) and Piper (fast with pitch control) directly from the UI.
This commit is contained in:
@@ -256,6 +256,31 @@ export async function generateAudiobook(payload: GenerateRequest): Promise<Blob>
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* TTS Engine information
|
||||
*/
|
||||
export interface EngineInfo {
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from engines endpoint
|
||||
*/
|
||||
export interface EnginesResponse {
|
||||
engines: EngineInfo[];
|
||||
current: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from voices endpoint
|
||||
*/
|
||||
export interface VoicesResponse {
|
||||
voices: {
|
||||
[engine: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for voice preview
|
||||
*/
|
||||
@@ -266,6 +291,65 @@ export interface VoicePreviewRequest {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available TTS engines
|
||||
* @returns Promise resolving to engines list
|
||||
* @throws ApiError if the request fails
|
||||
*/
|
||||
export async function getEngines(): Promise<EnginesResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/tts/engines`;
|
||||
|
||||
const response = await fetchWithErrorHandling(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active TTS engine
|
||||
* @param engine - Engine name to activate (kokoro or piper)
|
||||
* @returns Promise resolving to success response
|
||||
* @throws ApiError if the request fails
|
||||
*/
|
||||
export async function setEngine(engine: string): Promise<{ status: string; engine: string }> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/tts/engines`;
|
||||
|
||||
const response = await fetchWithErrorHandling(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ engine }),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available voices for all engines
|
||||
* @returns Promise resolving to voices by engine
|
||||
* @throws ApiError if the request fails
|
||||
*/
|
||||
export async function getVoices(): Promise<VoicesResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/tts/voices`;
|
||||
|
||||
const response = await fetchWithErrorHandling(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a preview of a voice
|
||||
* @param payload - Preview request with voice, language, and optional speed/text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Role } from '../api/tts';
|
||||
import { previewVoice } from '../api/tts';
|
||||
import { previewVoice, getEngines, setEngine, getVoices } from '../api/tts';
|
||||
|
||||
type AgeCategory = 'young' | 'teen' | 'adult' | 'old';
|
||||
|
||||
@@ -161,6 +161,40 @@ export default function VoiceAssignmentSection({
|
||||
}: VoiceAssignmentSectionProps) {
|
||||
const [previewingRole, setPreviewingRole] = useState<string | null>(null);
|
||||
const [audioCache, setAudioCache] = useState<Map<string, string>>(new Map());
|
||||
const [currentEngine, setCurrentEngine] = useState<string>('kokoro');
|
||||
const [availableVoices, setAvailableVoices] = useState<VoiceOption[]>(KOKORO_VOICES);
|
||||
const [isLoadingEngine, setIsLoadingEngine] = useState(false);
|
||||
|
||||
// Load current engine and voices on mount
|
||||
useEffect(() => {
|
||||
const loadEngineInfo = async () => {
|
||||
try {
|
||||
const [enginesData, voicesData] = await Promise.all([
|
||||
getEngines(),
|
||||
getVoices()
|
||||
]);
|
||||
|
||||
setCurrentEngine(enginesData.current);
|
||||
|
||||
// Update available voices based on current engine
|
||||
if (enginesData.current === 'piper' && voicesData.voices.piper) {
|
||||
const piperVoices: VoiceOption[] = voicesData.voices.piper.map(voiceId => ({
|
||||
id: voiceId,
|
||||
label: voiceId.replace(/_/g, ' ').replace(/-/g, ' - '),
|
||||
gender: voiceId.includes('amy') || voiceId.includes('alba') ? 'female' : 'male',
|
||||
accent: voiceId.includes('GB') ? 'British' : 'American'
|
||||
}));
|
||||
setAvailableVoices(piperVoices);
|
||||
} else {
|
||||
setAvailableVoices(KOKORO_VOICES);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load engine info:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadEngineInfo();
|
||||
}, []);
|
||||
|
||||
const handleVoiceSelect = (roleId: string, voiceId: string) => {
|
||||
onVoiceChange(roleId, voiceId);
|
||||
@@ -231,6 +265,36 @@ export default function VoiceAssignmentSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEngineChange = async (engine: string) => {
|
||||
setIsLoadingEngine(true);
|
||||
try {
|
||||
await setEngine(engine);
|
||||
setCurrentEngine(engine);
|
||||
|
||||
// Load voices for new engine
|
||||
const voicesData = await getVoices();
|
||||
|
||||
if (engine === 'piper' && voicesData.voices.piper) {
|
||||
const piperVoices: VoiceOption[] = voicesData.voices.piper.map(voiceId => ({
|
||||
id: voiceId,
|
||||
label: voiceId.replace(/_/g, ' ').replace(/-/g, ' - '),
|
||||
gender: voiceId.includes('amy') || voiceId.includes('alba') ? 'female' : 'male',
|
||||
accent: voiceId.includes('GB') ? 'British' : 'American'
|
||||
}));
|
||||
setAvailableVoices(piperVoices);
|
||||
} else {
|
||||
setAvailableVoices(KOKORO_VOICES);
|
||||
}
|
||||
|
||||
// Clear audio cache when switching engines
|
||||
setAudioCache(new Map());
|
||||
} catch (error) {
|
||||
console.error('Failed to switch engine:', error);
|
||||
} finally {
|
||||
setIsLoadingEngine(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onLanguageChange(e.target.value);
|
||||
};
|
||||
@@ -250,6 +314,50 @@ export default function VoiceAssignmentSection({
|
||||
<p className="text-lg text-gray-600">Choose a voice for each character in your story.</p>
|
||||
</div>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<div className="p-6 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-2xl border-2 border-indigo-200">
|
||||
<label className="block text-base font-bold text-gray-900 mb-3">
|
||||
TTS Engine
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => handleEngineChange('kokoro')}
|
||||
disabled={isLoadingEngine}
|
||||
className={`flex-1 px-6 py-4 rounded-xl font-semibold transition-all duration-200 ${
|
||||
currentEngine === 'kokoro'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="font-bold">Kokoro TTS</div>
|
||||
<div className="text-sm opacity-90">High quality, 54 voices</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEngineChange('piper')}
|
||||
disabled={isLoadingEngine}
|
||||
className={`flex-1 px-6 py-4 rounded-xl font-semibold transition-all duration-200 ${
|
||||
currentEngine === 'piper'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="font-bold">Piper TTS</div>
|
||||
<div className="text-sm opacity-90">Fast, pitch control</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{currentEngine === 'piper' && (
|
||||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>✨ Pitch Control:</strong> Piper allows you to adjust voice pitch per character (coming soon in UI)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voice Assignments */}
|
||||
<div className="space-y-5">
|
||||
{roles.map((role, index) => {
|
||||
@@ -275,7 +383,7 @@ export default function VoiceAssignmentSection({
|
||||
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) => (
|
||||
{availableVoices.map((voice) => (
|
||||
<option key={voice.id} value={voice.id}>
|
||||
{voice.label}
|
||||
</option>
|
||||
|
||||
Reference in New Issue
Block a user