From c310f1148453da69506554d997b393934663be59 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 9 Dec 2025 14:50:16 +0000 Subject: [PATCH] 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. --- frontend/src/api/tts.ts | 84 +++++++++++++ .../src/components/VoiceAssignmentSection.tsx | 114 +++++++++++++++++- 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/tts.ts b/frontend/src/api/tts.ts index 33537e5..7f2d0ac 100644 --- a/frontend/src/api/tts.ts +++ b/frontend/src/api/tts.ts @@ -256,6 +256,31 @@ export async function generateAudiobook(payload: GenerateRequest): Promise 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 { + 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 { + 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 diff --git a/frontend/src/components/VoiceAssignmentSection.tsx b/frontend/src/components/VoiceAssignmentSection.tsx index f3b9c08..a19d352 100644 --- a/frontend/src/components/VoiceAssignmentSection.tsx +++ b/frontend/src/components/VoiceAssignmentSection.tsx @@ -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(null); const [audioCache, setAudioCache] = useState>(new Map()); + const [currentEngine, setCurrentEngine] = useState('kokoro'); + const [availableVoices, setAvailableVoices] = useState(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) => { onLanguageChange(e.target.value); }; @@ -250,6 +314,50 @@ export default function VoiceAssignmentSection({

Choose a voice for each character in your story.

+ {/* TTS Engine Selector */} +
+ +
+ + +
+ {currentEngine === 'piper' && ( +
+

+ ✨ Pitch Control: Piper allows you to adjust voice pitch per character (coming soon in UI) +

+
+ )} +
+ {/* Voice Assignments */}
{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" > - {KOKORO_VOICES.map((voice) => ( + {availableVoices.map((voice) => (