-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat: Kokoro 82M TTS engine + voice profile type system #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
3584283
9e726ad
0fc2192
d6f48ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { | |||||||||||||||||||||||||||||||
| SelectTrigger, | ||||||||||||||||||||||||||||||||
| SelectValue, | ||||||||||||||||||||||||||||||||
| } from '@/components/ui/select'; | ||||||||||||||||||||||||||||||||
| import type { VoiceProfileResponse } from '@/lib/api/types'; | ||||||||||||||||||||||||||||||||
| import { getLanguageOptionsForEngine } from '@/lib/constants/languages'; | ||||||||||||||||||||||||||||||||
| import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm'; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
@@ -15,13 +16,14 @@ import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm'; | |||||||||||||||||||||||||||||||
| * Adding a new engine means adding one entry here. | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| const ENGINE_OPTIONS = [ | ||||||||||||||||||||||||||||||||
| { value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B' }, | ||||||||||||||||||||||||||||||||
| { value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B' }, | ||||||||||||||||||||||||||||||||
| { value: 'luxtts', label: 'LuxTTS' }, | ||||||||||||||||||||||||||||||||
| { value: 'chatterbox', label: 'Chatterbox' }, | ||||||||||||||||||||||||||||||||
| { value: 'chatterbox_turbo', label: 'Chatterbox Turbo' }, | ||||||||||||||||||||||||||||||||
| { value: 'tada:1B', label: 'TADA 1B' }, | ||||||||||||||||||||||||||||||||
| { value: 'tada:3B', label: 'TADA 3B Multilingual' }, | ||||||||||||||||||||||||||||||||
| { value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' }, | ||||||||||||||||||||||||||||||||
| { value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' }, | ||||||||||||||||||||||||||||||||
| { value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' }, | ||||||||||||||||||||||||||||||||
| { value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' }, | ||||||||||||||||||||||||||||||||
| { value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' }, | ||||||||||||||||||||||||||||||||
| { value: 'tada:1B', label: 'TADA 1B', engine: 'tada' }, | ||||||||||||||||||||||||||||||||
| { value: 'tada:3B', label: 'TADA 3B Multilingual', engine: 'tada' }, | ||||||||||||||||||||||||||||||||
| { value: 'kokoro', label: 'Kokoro 82M', engine: 'kokoro' }, | ||||||||||||||||||||||||||||||||
| ] as const; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const ENGINE_DESCRIPTIONS: Record<string, string> = { | ||||||||||||||||||||||||||||||||
|
|
@@ -30,11 +32,38 @@ const ENGINE_DESCRIPTIONS: Record<string, string> = { | |||||||||||||||||||||||||||||||
| chatterbox: '23 languages, incl. Hebrew', | ||||||||||||||||||||||||||||||||
| chatterbox_turbo: 'English, [laugh] [cough] tags', | ||||||||||||||||||||||||||||||||
| tada: 'HumeAI, 700s+ coherent audio', | ||||||||||||||||||||||||||||||||
| kokoro: '82M params, CPU realtime, 8 langs', | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** Engines that only support English and should force language to 'en' on select. */ | ||||||||||||||||||||||||||||||||
| const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** Engines that support cloned (reference audio) profiles. */ | ||||||||||||||||||||||||||||||||
| const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** Engines that are preset-only (no cloning). */ | ||||||||||||||||||||||||||||||||
| const PRESET_ONLY_ENGINES = new Set(['kokoro']); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Get which engine options are available for the selected profile. | ||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||
| * - Preset profiles: locked to their preset engine | ||||||||||||||||||||||||||||||||
| * - All other profiles: all engines available | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) { | ||||||||||||||||||||||||||||||||
| if (!selectedProfile) return ENGINE_OPTIONS; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const voiceType = selectedProfile.voice_type || 'cloned'; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (voiceType === 'preset') { | ||||||||||||||||||||||||||||||||
| // Preset profiles lock to their specific engine | ||||||||||||||||||||||||||||||||
| const presetEngine = selectedProfile.preset_engine; | ||||||||||||||||||||||||||||||||
| return ENGINE_OPTIONS.filter((opt) => opt.engine === presetEngine); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return ENGINE_OPTIONS; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| function getSelectValue(engine: string, modelSize?: string): string { | ||||||||||||||||||||||||||||||||
| if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`; | ||||||||||||||||||||||||||||||||
| if (engine === 'tada') return `tada:${modelSize || '1B'}`; | ||||||||||||||||||||||||||||||||
|
|
@@ -85,12 +114,21 @@ function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: st | |||||||||||||||||||||||||||||||
| interface EngineModelSelectorProps { | ||||||||||||||||||||||||||||||||
| form: UseFormReturn<GenerationFormValues>; | ||||||||||||||||||||||||||||||||
| compact?: boolean; | ||||||||||||||||||||||||||||||||
| selectedProfile?: VoiceProfileResponse | null; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) { | ||||||||||||||||||||||||||||||||
| export function EngineModelSelector({ form, compact, selectedProfile }: EngineModelSelectorProps) { | ||||||||||||||||||||||||||||||||
| const engine = form.watch('engine') || 'qwen'; | ||||||||||||||||||||||||||||||||
| const modelSize = form.watch('modelSize'); | ||||||||||||||||||||||||||||||||
| const selectValue = getSelectValue(engine, modelSize); | ||||||||||||||||||||||||||||||||
| const availableOptions = getAvailableOptions(selectedProfile); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // If current engine isn't in available options, auto-switch to first available | ||||||||||||||||||||||||||||||||
| const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue); | ||||||||||||||||||||||||||||||||
| if (!currentEngineAvailable && availableOptions.length > 0) { | ||||||||||||||||||||||||||||||||
| // Defer to avoid setting state during render | ||||||||||||||||||||||||||||||||
| setTimeout(() => handleEngineChange(form, availableOptions[0].value), 0); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+111
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify render-phase side effect currently exists and useEffect is absent.
rg -n --type=tsx -C3 'setTimeout\(\(\) => handleEngineChange\(form, availableOptions\[0\]\.value\), 0\)' app/src/components/Generation/EngineModelSelector.tsx
rg -n --type=tsx 'useEffect\(' app/src/components/Generation/EngineModelSelector.tsxRepository: jamiepine/voicebox Length of output: 117 🏁 Script executed: fd -name "EngineModelSelector.tsx" app/src/components/Repository: jamiepine/voicebox Length of output: 292 🏁 Script executed: cat -n app/src/components/Generation/EngineModelSelector.tsx | head -130 | tail -50Repository: jamiepine/voicebox Length of output: 2341 🏁 Script executed: rg -n "setTimeout" app/src/components/Generation/EngineModelSelector.tsxRepository: jamiepine/voicebox Length of output: 143 🏁 Script executed: rg -n "useEffect" app/src/components/Generation/EngineModelSelector.tsxRepository: jamiepine/voicebox Length of output: 44 Move engine fallback logic to Lines 113–116 schedule a form update via +import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
@@
- // If current engine isn't in available options, auto-switch to first available
- const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
- if (!currentEngineAvailable && availableOptions.length > 0) {
- // Defer to avoid setting state during render
- setTimeout(() => handleEngineChange(form, availableOptions[0].value), 0);
- }
+ useEffect(() => {
+ if (!currentEngineAvailable && availableOptions.length > 0) {
+ handleEngineChange(form, availableOptions[0].value);
+ }
+ }, [currentEngineAvailable, availableOptions, form]);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const itemClass = compact ? 'text-xs text-muted-foreground' : undefined; | ||||||||||||||||||||||||||||||||
| const triggerClass = compact | ||||||||||||||||||||||||||||||||
|
|
@@ -105,7 +143,7 @@ export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) | |||||||||||||||||||||||||||||||
| </SelectTrigger> | ||||||||||||||||||||||||||||||||
| </FormControl> | ||||||||||||||||||||||||||||||||
| <SelectContent> | ||||||||||||||||||||||||||||||||
| {ENGINE_OPTIONS.map((opt) => ( | ||||||||||||||||||||||||||||||||
| {availableOptions.map((opt) => ( | ||||||||||||||||||||||||||||||||
| <SelectItem key={opt.value} value={opt.value} className={itemClass}> | ||||||||||||||||||||||||||||||||
| {opt.label} | ||||||||||||||||||||||||||||||||
| </SelectItem> | ||||||||||||||||||||||||||||||||
|
|
@@ -119,3 +157,17 @@ export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) | |||||||||||||||||||||||||||||||
| export function getEngineDescription(engine: string): string { | ||||||||||||||||||||||||||||||||
| return ENGINE_DESCRIPTIONS[engine] ?? ''; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Check if a profile is compatible with the currently selected engine. | ||||||||||||||||||||||||||||||||
| * Useful for UI hints. | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| export function isProfileCompatibleWithEngine( | ||||||||||||||||||||||||||||||||
| profile: VoiceProfileResponse, | ||||||||||||||||||||||||||||||||
| engine: string, | ||||||||||||||||||||||||||||||||
| ): boolean { | ||||||||||||||||||||||||||||||||
| const voiceType = profile.voice_type || 'cloned'; | ||||||||||||||||||||||||||||||||
| if (voiceType === 'preset') return profile.preset_engine === engine; | ||||||||||||||||||||||||||||||||
| if (voiceType === 'cloned') return CLONING_ENGINES.has(engine); | ||||||||||||||||||||||||||||||||
| return !PRESET_ONLY_ENGINES.has(engine); // designed — future | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Match available options to the compatibility rules below.
getAvailableOptions()still returns Kokoro for cloned profiles, even thoughisProfileCompatibleWithEngine()correctly says cloned voices only work withCLONING_ENGINES. Right now the selector can still drive the form into an invalid profile/engine combination.💡 Keep the dropdown consistent with the helper
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) { if (!selectedProfile) return ENGINE_OPTIONS; const voiceType = selectedProfile.voice_type || 'cloned'; if (voiceType === 'preset') { // Preset profiles lock to their specific engine const presetEngine = selectedProfile.preset_engine; return ENGINE_OPTIONS.filter((opt) => opt.engine === presetEngine); } - return ENGINE_OPTIONS; + return ENGINE_OPTIONS.filter((opt) => CLONING_ENGINES.has(opt.engine)); }📝 Committable suggestion
🤖 Prompt for AI Agents