Speex: Config UI Done

This commit is contained in:
Enrico Ros
2025-11-28 04:27:53 -08:00
parent 78e663f955
commit a8c98056b6
9 changed files with 356 additions and 172 deletions
@@ -1,85 +1,96 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Box, Button, Divider, FormControl, FormHelperText, Input, Typography } from '@mui/joy';
import { Box, Button, Divider, FormControl, Typography } from '@mui/joy';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import StopRoundedIcon from '@mui/icons-material/StopRounded';
import { FormChipControl } from '~/common/components/forms/FormChipControl';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { FormSecretField } from '~/common/components/forms/FormSecretField';
import { FormSliderControl } from '~/common/components/forms/FormSliderControl';
import { FormTextField } from '~/common/components/forms/FormTextField';
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
import type { DCredentialsApiKey, DSpeexEngine, DSpeexEngineAny, DVoiceElevenLabs, DVoiceLocalAI, DVoiceOpenAI, DVoiceWebSpeech } from '../speex.types';
import { SPEEX_DEFAULTS, SPEEX_PREVIEW_TEXT } from '../speex.config';
import type { DCredentialsApiKey, DSpeexEngine, DSpeexEngineAny, DSpeexVendorType, DVoiceElevenLabs, DVoiceLocalAI, DVoiceOpenAI, DVoiceWebSpeech } from '../speex.types';
import { SPEEX_DEFAULTS, SPEEX_PREVIEW_STREAM, SPEEX_PREVIEW_TEXT } from '../speex.config';
import { SpeexVoiceAutocomplete } from './SpeexVoiceAutocomplete';
import { SpeexVoiceSelect } from './SpeexVoiceSelect';
import { speakText } from '../speex.client';
import { speexVendorTypeLabel } from './SpeexEngineSelect';
// Credential input helper - shared across vendors
function CredentialsApiKeyInputs({ credentials, onUpdate, showHost, hostRequired, hostPlaceholder }: {
function CredentialsApiKeyInputs({ credentials, onUpdate, vendorType, showHost, hostRequired, hostPlaceholder }: {
credentials: DCredentialsApiKey;
onUpdate: (credentials: DCredentialsApiKey) => void;
vendorType: DSpeexVendorType;
showHost?: boolean;
hostRequired?: boolean;
hostPlaceholder?: string;
}) {
return <>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='API Key' description={hostRequired ? 'Optional' : 'Required'} />
<Input
type='password'
value={credentials.apiKey}
onChange={(e) => onUpdate({ ...credentials, apiKey: e.target.value })}
placeholder='sk-...'
sx={{ minWidth: 200 }}
/>
</FormControl>
<FormSecretField
autoCompleteId={`speex-${vendorType}-key`}
title='API Key'
description={hostRequired ? 'Optional' : speexVendorTypeLabel(vendorType)}
value={credentials.apiKey}
onChange={value => onUpdate({ ...credentials, apiKey: value })}
required={!hostRequired}
startDecorator={credentials.apiKey ? false : undefined}
// placeholder='Required'
inputSx={{ maxWidth: 220 }}
/>
{showHost && (
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='API Host' description={hostRequired ? 'Required' : 'Optional'} />
<Input
value={credentials.apiHost ?? ''}
onChange={(e) => onUpdate({ ...credentials, apiHost: e.target.value || undefined })}
placeholder={hostPlaceholder ?? 'https://api.example.com'}
sx={{ minWidth: 200 }}
/>
</FormControl>
<FormTextField
autoCompleteId={`speex-${vendorType}-host`}
title='API Host'
description={hostRequired ? 'Required' : 'Optional'}
value={credentials.apiHost ?? ''}
onChange={text => onUpdate({ ...credentials, apiHost: text || undefined })}
placeholder={hostPlaceholder ?? 'https://api.example.com'}
inputSx={{ maxWidth: 220 }}
/>
)}
{showHost && <Divider inset='context' />}
</>;
}
function PreviewButton({ engineId }: { engineId: DSpeexEngineAny['engineId'] }) {
// state
const [isSpeaking, setIsSpeaking] = React.useState(false);
const handlePreview = React.useCallback(async () => {
if (isSpeaking) return;
setIsSpeaking(true);
await speakText(
SPEEX_PREVIEW_TEXT,
{ engineId: engineId },
{ streaming: true, playback: true },
{ onComplete: () => setIsSpeaking(false), onError: () => setIsSpeaking(false) },
);
}, [engineId, isSpeaking]);
// async + cache
const { isFetching, isError, error, refetch: previewVoice } = useQuery({
enabled: false, // manual trigger only
queryKey: ['speex-preview', engineId],
queryFn: async () => {
const result = await speakText(
SPEEX_PREVIEW_TEXT,
{ engineId: engineId },
{ streaming: SPEEX_PREVIEW_STREAM },
);
if (!result.success) throw new Error(result.error || 'Preview failed');
return result;
},
});
return (
<Button
variant='outlined'
color='neutral'
size='sm'
onClick={handlePreview}
disabled={isSpeaking}
startDecorator={isSpeaking ? <StopRoundedIcon /> : <PlayArrowRoundedIcon />}
sx={{ ml: 'auto' }}
>
{isSpeaking ? 'Speaking...' : 'Preview'}
</Button>
<TooltipOutlined color='danger' title={error?.message ? <pre>{error.message}</pre> : false}>
<Button
variant='outlined'
color={isError ? 'danger' : 'neutral'}
size='sm'
onClick={() => previewVoice()}
disabled={isFetching}
startDecorator={isFetching ? <StopRoundedIcon /> : <PlayArrowRoundedIcon />}
sx={{ ml: 'auto', minWidth: 130 }}
>
{isFetching ? 'Speaking...' : isError ? 'Retry' : 'Preview'}
</Button>
</TooltipOutlined>
);
}
@@ -95,7 +106,8 @@ export function SpeexConfigureEngineFull(props: {
return <>
{/*<Box mt={2} />*/}
<Divider sx={{ my: 1 }} inset='context' />
{/*<Divider sx={{ my: 1 }} inset='context' />*/}
<Divider sx={{ my: 1 }} inset='context'>{isMobile ? 'Configuration' : 'App Voice Configuration'}</Divider>
{/* Engine-Specific pane */}
{engine.vendorType === 'elevenlabs' ? (
@@ -122,9 +134,9 @@ export function SpeexConfigureEngineFull(props: {
// Vendor-specific configs
function ElevenLabsConfig({ engine, onUpdate, mode }: {
function ElevenLabsConfig({ engine, onUpdate, mode, isMobile }: {
engine: DSpeexEngine<'elevenlabs'>,
onUpdate: (updates: Partial<DSpeexEngineAny>) => void;
onUpdate: (updates: Partial<DSpeexEngine<'elevenlabs'>>) => void;
isMobile: boolean;
mode: 'full' | 'voice-only';
}) {
@@ -137,33 +149,31 @@ function ElevenLabsConfig({ engine, onUpdate, mode }: {
}, [onUpdate]);
const handleVoiceChange = React.useCallback((ttsVoiceId: DVoiceElevenLabs['ttsVoiceId']) => {
onUpdate({ voice: { ...voice, ttsVoiceId } });
const { ttsVoiceId: _, ...restVoice } = voice;
onUpdate({
voice: {
...restVoice,
...(ttsVoiceId && { ttsVoiceId }),
},
});
}, [onUpdate, voice]);
return <>
{/* Credentials (only for manually added engines in full mode) */}
{showCredentials && (
<CredentialsApiKeyInputs
credentials={credentials}
onUpdate={handleCredentialsUpdate}
vendorType='elevenlabs'
/>
)}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center', overflow: 'hidden' }}>
<FormLabelStart title='Voice' description='ElevenLabs voice' />
<SpeexVoiceSelect
engine={engine}
voiceId={voice.ttsVoiceId ?? null}
onVoiceChange={handleVoiceChange}
autoPreview
/>
</FormControl>
<FormChipControl<Exclude<DVoiceElevenLabs['ttsModel'], undefined>>
title='Model'
alignEnd
options={[
{ value: 'eleven_multilingual_v2', label: 'Multilingual v2', description: 'Recommended' },
{ value: 'eleven_multilingual_v2', label: 'Multilingual v2', description: 'Default' },
{ value: 'eleven_turbo_v2_5', label: 'Turbo v2.5', description: 'Fast' },
{ value: 'eleven_flash_v2_5', label: 'Flash v2.5', description: 'Fastest' },
{ value: 'eleven_v3', label: 'v3', description: 'Newest' },
@@ -172,18 +182,29 @@ function ElevenLabsConfig({ engine, onUpdate, mode }: {
onChange={(value) => onUpdate({ voice: { ...voice, ttsModel: value } })}
/>
{showCredentials && (
<FormHelperText>
Voice listing requires API key. Language auto-detected from preferences.
</FormHelperText>
)}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center', overflow: 'hidden' }}>
<FormLabelStart title='Voice' description={isMobile ? undefined : 'ElevenLabs voice'} />
<SpeexVoiceSelect
autoPreview
engine={engine}
voiceId={voice.ttsVoiceId ?? null}
onVoiceChange={handleVoiceChange}
/>
</FormControl>
{/*{showCredentials && (*/}
{/* <FormHelperText>*/}
{/* Voice listing requires API key. Language auto-detected from preferences.*/}
{/* </FormHelperText>*/}
{/*)}*/}
</>;
}
function LocalAIConfig({ engine, onUpdate, mode }: {
function LocalAIConfig({ engine, onUpdate, mode, isMobile }: {
engine: DSpeexEngine<'localai'>,
onUpdate: (updates: Partial<DSpeexEngineAny>) => void;
onUpdate: (updates: Partial<DSpeexEngine<'localai'>>) => void;
isMobile: boolean;
mode: 'full' | 'voice-only';
}) {
@@ -195,7 +216,13 @@ function LocalAIConfig({ engine, onUpdate, mode }: {
}, [onUpdate]);
const handleModelChange = React.useCallback((ttsModel: DVoiceLocalAI['ttsModel']) => {
onUpdate({ voice: { ...voice, ttsModel } });
const { ttsModel: _, ...restVoice } = voice;
onUpdate({
voice: {
...restVoice,
...(ttsModel && { ttsModel }),
},
});
}, [onUpdate, voice]);
return <>
@@ -205,40 +232,33 @@ function LocalAIConfig({ engine, onUpdate, mode }: {
<CredentialsApiKeyInputs
credentials={credentials}
onUpdate={handleCredentialsUpdate}
vendorType='localai'
showHost
hostRequired
hostPlaceholder='http://localhost:8080'
/>
)}
{/* Model: autocomplete with suggestions + free-form input */}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center', overflow: 'hidden' }}>
<FormLabelStart title='Model' description='TTS model' />
<SpeexVoiceSelect
<FormLabelStart title='Model' description={isMobile ? undefined : 'Select or type'} />
<SpeexVoiceAutocomplete
engine={engine}
voiceId={voice.ttsModel ?? null}
onVoiceChange={handleModelChange}
/>
</FormControl>
<FormControl>
<FormLabelStart title='Model Override' description='Manual model name' />
<Input
value={voice.ttsModel ?? ''}
onChange={(e) => onUpdate({ voice: { ...voice, ttsModel: e.target.value } })}
value={voice.ttsModel}
onValueChange={handleModelChange}
placeholder='e.g., kokoro'
/>
<FormHelperText>Override if model not in dropdown</FormHelperText>
</FormControl>
<FormControl>
<FormLabelStart title='Backend' description='TTS backend (optional)' />
<Input
value={voice.ttsBackend ?? ''}
onChange={(e) => onUpdate({ voice: { ...voice, ttsBackend: e.target.value || undefined } })}
placeholder='e.g., coqui, bark, piper'
/>
<FormHelperText>Leave empty for default backend</FormHelperText>
</FormControl>
<FormTextField
autoCompleteId='speex-localai-backend'
title='Backend'
description='Optional'
placeholder='e.g., coqui, bark, piper'
value={voice.ttsBackend ?? ''}
onChange={(text) => onUpdate({ voice: { ...voice, ttsBackend: text || undefined } })}
inputSx={{ maxWidth: 220 }}
/>
</>;
}
@@ -259,7 +279,13 @@ function OpenAIConfig({ engine, onUpdate, isMobile, mode }: {
}, [onUpdate]);
const handleVoiceChange = React.useCallback((ttsVoiceId: DVoiceOpenAI['ttsVoiceId']) => {
onUpdate({ voice: { ...voice, ttsVoiceId } });
const { ttsVoiceId: _, ...restVoice } = voice;
onUpdate({
voice: {
...restVoice,
...(ttsVoiceId && { ttsVoiceId }),
},
});
}, [onUpdate, voice]);
const handleSpeedChange = React.useCallback((value: number) => {
@@ -273,6 +299,7 @@ function OpenAIConfig({ engine, onUpdate, isMobile, mode }: {
<CredentialsApiKeyInputs
credentials={credentials}
onUpdate={handleCredentialsUpdate}
vendorType='openai'
showHost
hostPlaceholder='https://api.openai.com (optional)'
/>
@@ -287,7 +314,12 @@ function OpenAIConfig({ engine, onUpdate, isMobile, mode }: {
{ value: 'tts-1-hd', label: 'TTS-1-HD', description: 'Quality' },
]}
value={voice.ttsModel ?? SPEEX_DEFAULTS.OPENAI_MODEL}
onChange={(value) => onUpdate({ voice: { ...voice, ttsModel: value as DVoiceOpenAI['ttsModel'] } })}
onChange={value => onUpdate({
voice: {
...voice,
ttsModel: value,
},
})}
/>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center', overflow: 'hidden' }}>
@@ -330,7 +362,7 @@ function OpenAIConfig({ engine, onUpdate, isMobile, mode }: {
function WebSpeechConfig({ engine, onUpdate, isMobile }: {
engine: DSpeexEngine<'webspeech'>
onUpdate: (updates: Partial<DSpeexEngineAny>) => void;
onUpdate: (updates: Partial<DSpeexEngine<'webspeech'>>) => void;
isMobile: boolean;
mode: 'full' | 'voice-only';
}) {
@@ -338,7 +370,13 @@ function WebSpeechConfig({ engine, onUpdate, isMobile }: {
const { voice } = engine;
const handleVoiceChange = React.useCallback((ttsVoiceURI: DVoiceWebSpeech['ttsVoiceURI']) => {
onUpdate({ voice: { ...voice, ttsVoiceURI } });
const { ttsVoiceURI: _, ...restVoice } = voice;
onUpdate({
voice: {
...restVoice,
...(ttsVoiceURI && { ttsVoiceURI }),
},
});
}, [onUpdate, voice]);
const handleSpeedChange = React.useCallback((value: number) => {
@@ -12,6 +12,7 @@ import * as React from 'react';
import { Box, Button, Chip, Dropdown, ListItemDecorator, Menu, MenuButton, MenuItem, SvgIconProps, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import LinkIcon from '@mui/icons-material/Link';
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
@@ -212,6 +213,7 @@ export function SpeexConfigureEngines(_props: { isMobile: boolean }) {
// startDecorator={isActive && <Box sx={_styles.chipSymbol}>
// <CheckRoundedIcon sx={{ fontSize: 16, color: 'text.primary' }} />
// </Box>}
endDecorator={engine.isAutoLinked && <LinkIcon sx={{ fontSize: 16 }} />}
onClick={event => handleEngineSelect(event.shiftKey ? null : engine.engineId)}
sx={isConfigured ? _styles.chip : _styles.chipUnconfigured}
>
@@ -241,10 +243,11 @@ export function SpeexConfigureEngines(_props: { isMobile: boolean }) {
// <GoodTooltip title='Delete this service'>
<Button
size='sm'
color='danger'
variant='outlined'
color='neutral'
variant='plain'
onClick={handleDeleteClick}
startDecorator={<DeleteOutlineIcon />}
// sx={{ minWidth: 120 }}
>
Delete
</Button>
@@ -10,6 +10,20 @@ import type { DSpeexVendorType, SpeexEngineId } from '../speex.types';
import { speexAreCredentialsValid, useSpeexEngines } from '../store-module-speex';
export function speexVendorTypeLabel(vendorType: DSpeexVendorType): string {
switch (vendorType) {
case 'elevenlabs':
return 'ElevenLabs';
case 'openai':
return 'OpenAI';
case 'localai':
return 'LocalAI';
case 'webspeech':
return 'System';
}
}
interface SpeexEngineSelectProps {
/** Selected engine ID (null = none selected) */
engineId: string | null;
@@ -21,7 +35,6 @@ interface SpeexEngineSelectProps {
placeholder?: string;
}
export function SpeexEngineSelect(props: SpeexEngineSelectProps) {
const { engineId, onEngineChange, disabled, placeholder = 'Select engine...' } = props;
@@ -49,7 +62,7 @@ export function SpeexEngineSelect(props: SpeexEngineSelectProps) {
{label}
{!label.toLowerCase().includes(vendorType) && (
<Typography level='body-xs' sx={{ ml: 1, color: 'text.tertiary' }}>
({_vendorTypeLabel(vendorType)})
({speexVendorTypeLabel(vendorType)})
</Typography>
)}
</Option>
@@ -57,17 +70,3 @@ export function SpeexEngineSelect(props: SpeexEngineSelectProps) {
</Select>
);
}
function _vendorTypeLabel(vendorType: DSpeexVendorType): string {
switch (vendorType) {
case 'elevenlabs':
return 'ElevenLabs';
case 'openai':
return 'OpenAI';
case 'localai':
return 'LocalAI';
case 'webspeech':
return 'System';
}
}
@@ -0,0 +1,131 @@
/**
* SpeexVoiceAutocomplete - Combined voice/model selector + free-form input
*
* Uses MUI Joy Autocomplete with freeSolo to allow:
* - Selecting from fetched voice/model list (suggestions)
* - Typing custom value (free-form)
*
* Used by LocalAI where models can be selected from list or typed manually.
*/
import * as React from 'react';
import { Autocomplete, AutocompleteOption, Box, CircularProgress, IconButton, Typography } from '@mui/joy';
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
import type { DSpeexEngineAny, SpeexListVoiceOption } from '../speex.types';
import { useSpeexVoices } from './useSpeexVoices';
interface SpeexVoiceAutocompleteProps {
engine: DSpeexEngineAny;
/** Current value (can be from list or custom) */
value: string | undefined;
/** Called when value changes (selection or typed). undefined = cleared */
onValueChange: (value: string | undefined) => void;
/** Placeholder text */
placeholder?: string;
disabled?: boolean;
}
export function SpeexVoiceAutocomplete(props: SpeexVoiceAutocompleteProps) {
const { engine, value /* e.g. ttsModel */, onValueChange, placeholder = 'Select or type...', disabled } = props;
// fetch voices/models
const { voices, isLoading, error, refetch } = useSpeexVoices(engine);
// local input state for freeSolo
const [inputValue, setInputValue] = React.useState(value ?? '');
// sync input when value prop changes externally
React.useEffect(() => {
setInputValue(value ?? '');
}, [value]);
// handlers
const handleChange = React.useCallback((_event: unknown, newValue: string | SpeexListVoiceOption | null) => {
// newValue can be: string (typed), SpeexListVoiceOption (selected), or null (cleared)
if (newValue === null)
onValueChange(undefined);
else if (typeof newValue === 'string')
onValueChange(newValue || undefined);
else
onValueChange(newValue.id || undefined);
}, [onValueChange]);
const handleInputChange = React.useCallback((_event: unknown, newInputValue: string, reason: string) => {
// BUGFIX: when re-clicking on the same option on the popup, reason will be 'reset', but the inputValue
// will be the label of the selected option and not the value. This fixes it
if (reason !== 'input')
return;
setInputValue(newInputValue);
// For freeSolo, also update value on input change (typing)
onValueChange(newInputValue || undefined);
}, [onValueChange]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* Refresh button (only if refetch available) */}
{refetch && (
<TooltipOutlined color={error ? 'danger' : undefined} title={error ? <pre>{error}</pre> : 'Refresh'}>
<IconButton
color={error ? 'danger' : 'neutral'}
variant='plain'
disabled={isLoading}
onClick={() => refetch()}
>
{!isLoading ? <RefreshRoundedIcon /> : <CircularProgress size='sm' />}
</IconButton>
</TooltipOutlined>
)}
{/* Autocomplete */}
<Autocomplete<SpeexListVoiceOption, false, false, true>
freeSolo
openOnFocus
clearOnEscape
disabled={disabled}
placeholder={placeholder}
options={voices}
getOptionKey={(option) => typeof option === 'string' ? option : option.id}
getOptionLabel={(option) => typeof option === 'string' ? option : option.name}
isOptionEqualToValue={(option, val) => option.id === (typeof val === 'string' ? val : val.id)}
value={voices.find(o => o.id === value) ?? (value || null)}
onChange={handleChange}
inputValue={inputValue}
onInputChange={handleInputChange}
loading={isLoading}
renderOption={(optionProps, option) => {
const { key, ...rest } = optionProps as any;
return (
<AutocompleteOption key={key} {...rest} sx={{ display: 'block' }}>
<Typography level='title-sm'>{option.name}</Typography>
{option.description && (
<Typography level='body-xs' sx={{ opacity: 0.6 }}>{option.description}</Typography>
)}
</AutocompleteOption>
);
}}
slotProps={{
root: {
sx: { minWidth: 180, maxWidth: 220, flexGrow: 1 },
},
listbox: {
sx: { maxWidth: 'min(400px, calc(100dvw - 1rem))' },
},
}}
/>
</Box>
);
}
@@ -1,21 +1,21 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { CircularProgress, Option, optionClasses, Select, SelectSlotsAndSlotProps } from '@mui/joy';
import { Box, CircularProgress, IconButton, Option, optionClasses, Select, SelectSlotsAndSlotProps } from '@mui/joy';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
import { DSpeexEngineAny, SpeexListVoiceOption, SpeexListVoicesResult } from '../speex.types';
import { speexListVoices_RPC } from '../protocols/rpc/rpc.client';
import { useSpeexWebSpeechVoices } from '../protocols/webspeech/webspeech.client';
import type { DSpeexEngineAny } from '../speex.types';
import { useSpeexVoices } from './useSpeexVoices';
// copied from useLLMSelect.tsx - inspired by optimaSelectSlotProps.listbox
const _selectSlotProps: SelectSlotsAndSlotProps<false>['slotProps'] = {
root: {
sx: {
minWidth: 220,
minWidth: 220, // 180 = 220 - 36 - 4
},
},
button: {
@@ -31,14 +31,7 @@ const _selectSlotProps: SelectSlotsAndSlotProps<false>['slotProps'] = {
// size: 'md',
// className: 'agi-ellipsize',
sx: {
// larger list
// '--ListItem-paddingLeft': '1rem',
// '--ListItem-minHeight': '2.5rem', // note that in the Optima Dropdowns we use 2.75rem
'--ListItemDecorator-size': '2rem', // compensate for the border
boxShadow: 'xl',
// v-size: keep the default
// maxHeight: 'calc(100dvh - 200px)',
// Option: clip width to 200...360px
[`& .${optionClasses.root}`]: {
// minWidth: 300,
@@ -61,7 +54,7 @@ export function SpeexVoiceSelect(props: {
const { engine, voiceId, onVoiceChange, disabled, autoPreview } = props;
// external state - module
const { voices, isLoading, error } = useSpeexVoices(engine);
const { voices, isLoading, error, refetch } = useSpeexVoices(engine);
// track user-initiated voice changes for preview (not initial load or voice list changes)
const [userSelectedVoiceId, setUserSelectedVoiceId] = React.useState<string | null>(null);
@@ -83,7 +76,21 @@ export function SpeexVoiceSelect(props: {
value && onVoiceChange(value);
}, [onVoiceChange]);
return (
return <Box sx={{ display: 'flex', alignItems: 'center' }}>
{refetch && (
<TooltipOutlined color={error ? 'danger' : undefined} title={error ? <pre>{error}</pre> : 'Refresh voices'}>
<IconButton
color={error ? 'danger' : 'neutral'}
variant='plain'
disabled={isLoading}
onClick={() => refetch()}
>
{!isLoading ? <RefreshRoundedIcon /> : <CircularProgress size='sm' />}
</IconButton>
</TooltipOutlined>
)}
<Select
variant='outlined'
disabled={disabled || isLoading || voices.length === 0}
@@ -97,7 +104,7 @@ export function SpeexVoiceSelect(props: {
: 'Select a voice'
}
// startDecorator={<PhVoice />}
endDecorator={isLoading && <CircularProgress size='sm' />}
// endDecorator={isLoading && <CircularProgress size='sm' />}
indicator={<KeyboardArrowDownIcon />}
slotProps={_selectSlotProps}
>
@@ -109,35 +116,6 @@ export function SpeexVoiceSelect(props: {
</Option>
))}
</Select>
);
}
// hooks - voice data: returns voices given an engine
const _stableEmptyVoices: SpeexListVoiceOption[] = [] as const;
function useSpeexVoices(engine: DSpeexEngineAny): SpeexListVoicesResult {
// props
const { vendorType, engineId } = engine;
const isWebspeech = vendorType === 'webspeech';
// use browser voices
const browserVoicesResult = useSpeexWebSpeechVoices(isWebspeech);
// use RPC voices
const { data: cloudVoices, error: cloudError, isLoading: cloudIsLoading } = useQuery({
enabled: !isWebspeech,
queryKey: ['speex', 'listVoices', engineId, vendorType],
queryFn: () => speexListVoices_RPC(engine as any /* will not run for 'webspeech' */),
staleTime: 5 * 60 * 1000, // 5 minutes - voices don't change often
});
// switch result
return isWebspeech ? browserVoicesResult : {
voices: cloudVoices?.length ? cloudVoices : _stableEmptyVoices,
isLoading: cloudIsLoading,
error: cloudError instanceof Error ? cloudError.message : null,
};
</Box>;
}
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import type { DSpeexEngineAny, SpeexListVoiceOption, SpeexListVoicesResult } from '../speex.types';
import { speexListVoices_RPC_orThrow } from '../protocols/rpc/rpc.client';
import { useSpeexWebSpeechVoices } from '../protocols/webspeech/webspeech.client';
const _stableEmptyVoices: SpeexListVoiceOption[] = [] as const;
// returns voices given an engine
export function useSpeexVoices(engine: DSpeexEngineAny): SpeexListVoicesResult {
// props
const { vendorType, engineId } = engine;
const isWebspeech = vendorType === 'webspeech';
// use browser voices
const browserVoicesResult = useSpeexWebSpeechVoices(isWebspeech);
// use RPC voices
const { data: cloudVoices, error: cloudError, isFetching: cloudIsFetching, refetch } = useQuery({
enabled: !isWebspeech,
queryKey: ['speex', 'listVoices', engineId, vendorType],
queryFn: () => speexListVoices_RPC_orThrow(engine as any /* will not run for 'webspeech' */),
staleTime: 5 * 60 * 1000, // 5 minutes - voices don't change often
});
// do not refetch openai, voices are hardcoded
const needsRefetch = vendorType !== 'openai';
// switch result
return isWebspeech ? browserVoicesResult : {
voices: cloudVoices?.length ? cloudVoices : _stableEmptyVoices,
isLoading: cloudIsFetching,
error: cloudError instanceof Error ? cloudError.message : null,
refetch: needsRefetch ? refetch : undefined,
};
}
@@ -8,6 +8,7 @@
import { apiAsync, apiStream } from '~/common/util/trpc.client';
import { convert_Base64_To_UInt8Array, convert_UInt8Array_To_Base64 } from '~/common/util/blobUtils';
import { findModelsServiceOrNull } from '~/common/stores/llms/store-llms';
import { stripUndefined } from '~/common/util/objectUtils';
import type { DLocalAIServiceSettings } from '~/modules/llms/vendors/localai/localai.vendor';
import type { DOpenAIServiceSettings } from '~/modules/llms/vendors/openai/openai.vendor';
@@ -46,7 +47,7 @@ export async function speexSynthesize_RPC(
// engine credentials (DCredentials..) -> wire Access
if (SPEEX_DEBUG) console.log(`[Speex RPC] Synthesize request (engine: ${engine.engineId}, ${text.length} chars) - options:`, options);
const access = _buildRPCWireAccess(engine);
const access = stripUndefined(_buildRPCWireAccess(engine));
if (!access) {
const error = new Error(`Failed to resolve credentials for engine ${engine.engineId}`);
callbacks?.onError?.(error);
@@ -55,7 +56,7 @@ export async function speexSynthesize_RPC(
// engine voice -> wire Voice
// IMPORTANT: TS ensures structural compatibility here between the DVoice* and Voice*_schema types
const voice: SpeexWire_Voice = engine.voice;
const voice: SpeexWire_Voice = stripUndefined(engine.voice);
// audio player for streaming playback
@@ -162,17 +163,12 @@ export async function speexSynthesize_RPC(
/**
* List voices via speex.router
*/
export async function speexListVoices_RPC(engine: _DSpeexEngineRPC): Promise<SpeexListVoiceOption[]> {
export async function speexListVoices_RPC_orThrow(engine: _DSpeexEngineRPC): Promise<SpeexListVoiceOption[]> {
const access = _buildRPCWireAccess(engine);
if (!access)
return [];
try {
return (await apiAsync.speex.listVoices.query({ access })).voices;
} catch (error) {
// console.log('[DEV] speexListVoicesRPC. Failed to list voices:', error);
return [];
}
return (await apiAsync.speex.listVoices.query({ access })).voices;
}
@@ -164,7 +164,7 @@ export async function listVoicesLocalAIOrThrow(access: SpeexWire_Access_OpenAI):
} catch (error: any) {
// ok to be user visible
console.warn('[DEV] Speex: listVoicesLocalAI: Failed to fetch models:', error.message);
return { voices: [] };
throw error;
}
// Filter to known TTS models to provide a better start
+1
View File
@@ -110,6 +110,7 @@ export type SpeexListVoicesResult = {
voices: SpeexListVoiceOption[];
isLoading: boolean;
error: string | null;
refetch?: () => void;
}