mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Speex: Config UI Done
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -110,6 +110,7 @@ export type SpeexListVoicesResult = {
|
||||
voices: SpeexListVoiceOption[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refetch?: () => void;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user