mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a10024fbd8 | ||
|
|
7ebf57d8f4 |
@@ -125,22 +125,22 @@ export function FloatingGenerateBox({
|
|||||||
}, [watchedEngine, setSelectedEngine]);
|
}, [watchedEngine, setSelectedEngine]);
|
||||||
|
|
||||||
// Sync generation form language, engine, and effects with selected profile
|
// Sync generation form language, engine, and effects with selected profile
|
||||||
|
type EngineValue = 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedProfile?.language) {
|
if (selectedProfile?.language) {
|
||||||
form.setValue('language', selectedProfile.language as LanguageCode);
|
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||||
}
|
}
|
||||||
// Auto-switch engine if profile has a default
|
// Auto-switch engine to match the profile
|
||||||
if (selectedProfile?.default_engine) {
|
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
|
||||||
form.setValue(
|
if (engine) {
|
||||||
'engine',
|
form.setValue('engine', engine as EngineValue);
|
||||||
selectedProfile.default_engine as
|
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
|
||||||
| 'qwen'
|
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
|
||||||
| 'luxtts'
|
const currentEngine = form.getValues('engine');
|
||||||
| 'chatterbox'
|
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
|
||||||
| 'chatterbox_turbo'
|
if (presetEngines.has(currentEngine)) {
|
||||||
| 'tada'
|
form.setValue('engine', 'qwen');
|
||||||
| 'kokoro',
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// Pre-fill effects from profile defaults
|
// Pre-fill effects from profile defaults
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
|||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
profile: VoiceProfileResponse;
|
profile: VoiceProfileResponse;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
@@ -40,6 +41,12 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
const isSelected = selectedProfileId === profile.id;
|
const isSelected = selectedProfileId === profile.id;
|
||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
|
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
|
||||||
|
if (disabled && isSelected) {
|
||||||
|
setSelectedProfileId(null);
|
||||||
|
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedProfileId(isSelected ? null : profile.id);
|
setSelectedProfileId(isSelected ? null : profile.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,8 +87,9 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
'cursor-pointer transition-all flex flex-col h-[162px]',
|
||||||
isSelected && 'ring-2 ring-accent shadow-md',
|
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
|
||||||
|
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Mic, Music, Sparkles } from 'lucide-react';
|
import { Info, Mic, Sparkles } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
@@ -9,16 +10,31 @@ import { ProfileForm } from './ProfileForm';
|
|||||||
/** Engines that use preset (built-in) voices instead of cloned profiles. */
|
/** Engines that use preset (built-in) voices instead of cloned profiles. */
|
||||||
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
||||||
|
|
||||||
/** Human-readable engine names for empty state messages. */
|
|
||||||
const ENGINE_NAMES: Record<string, string> = {
|
|
||||||
kokoro: 'Kokoro',
|
|
||||||
qwen_custom_voice: 'Qwen CustomVoice',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProfileList() {
|
export function ProfileList() {
|
||||||
const { data: profiles, isLoading, error } = useProfiles();
|
const { data: profiles, isLoading, error } = useProfiles();
|
||||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||||
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
// Scroll to the selected profile after engine/sort changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProfileId) return;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
const el = cardRefs.current.get(selectedProfileId);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Temporarily apply scroll-margin so it doesn't land flush at the top
|
||||||
|
el.style.scrollMarginTop = '180px';
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||||
|
timeoutId = setTimeout(() => { el.style.scrollMarginTop = ''; }, 500);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [selectedProfileId, selectedEngine]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
@@ -35,10 +51,18 @@ export function ProfileList() {
|
|||||||
const allProfiles = profiles || [];
|
const allProfiles = profiles || [];
|
||||||
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
|
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
|
||||||
|
|
||||||
// Filter profiles based on selected engine
|
/** Whether a profile is supported by the currently selected engine. */
|
||||||
const filteredProfiles = isPresetEngine
|
const isSupported = (p: (typeof allProfiles)[number]) =>
|
||||||
? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine)
|
isPresetEngine
|
||||||
: allProfiles.filter((p) => p.voice_type !== 'preset');
|
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
|
||||||
|
: p.voice_type !== 'preset';
|
||||||
|
|
||||||
|
// Sort so supported profiles come first
|
||||||
|
const sortedProfiles = [...allProfiles].sort(
|
||||||
|
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -56,29 +80,26 @@ export function ProfileList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : filteredProfiles.length === 0 && isPresetEngine ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Music className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground mb-2">
|
|
||||||
No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Create a profile to choose a specific voice before generating.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
|
||||||
Create {ENGINE_NAMES[selectedEngine] ?? selectedEngine} Voice
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
|
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
|
||||||
{filteredProfiles.map((profile) => (
|
{sortedProfiles.map((profile) => (
|
||||||
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
|
<div
|
||||||
<ProfileCard profile={profile} />
|
key={profile.id}
|
||||||
|
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) cardRefs.current.set(profile.id, el);
|
||||||
|
else cardRefs.current.delete(profile.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{hasUnsupported && (
|
||||||
|
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||||
|
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>Only supported voice profiles can be selected for the current model.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration';
|
|||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
const generationSchema = z.object({
|
||||||
text: z.string().min(1, '').max(50000),
|
text: z.string().min(1, '').max(50000),
|
||||||
@@ -45,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
|
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
seed: undefined,
|
seed: undefined,
|
||||||
modelSize: '1.7B',
|
modelSize: '1.7B',
|
||||||
instruct: '',
|
instruct: '',
|
||||||
engine: 'qwen',
|
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
|
||||||
...options.defaultValues,
|
...options.defaultValues,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import io
|
|||||||
import json as _json
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
|||||||
Reference in New Issue
Block a user