Speex: TTS character limit settings. Fixes #942

This commit is contained in:
Enrico Ros
2026-01-23 10:05:35 -08:00
parent d6adebb711
commit 086d7ecae4
5 changed files with 53 additions and 7 deletions
+13 -2
View File
@@ -1,10 +1,11 @@
import { SpeexConfigureEngines } from '~/modules/speex/components/SpeexConfigureEngines';
import { useSpeexEngines } from '~/modules/speex/store-module-speex';
import { useSpeexEngines, useSpeexTtsCharLimit } from '~/modules/speex/store-module-speex';
import { ChatAutoSpeakType, useChatAutoAI } from '../chat/store-app-chat';
import { FormRadioOption } from '~/common/components/forms/FormRadioControl';
import { FormChipControl } from '~/common/components/forms/FormChipControl';
import { FormRadioOption } from '~/common/components/forms/FormRadioControl';
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
const _autoSpeakOptions: FormRadioOption<ChatAutoSpeakType>[] = [
@@ -21,6 +22,7 @@ export function VoiceOutSettings(props: { isMobile: boolean }) {
// external state
const { autoSpeak, setAutoSpeak } = useChatAutoAI();
const { ttsCharLimit, setTtsCharLimit } = useSpeexTtsCharLimit();
// external state - module
const hasEngines = useSpeexEngines().length > 0;
@@ -39,6 +41,15 @@ export function VoiceOutSettings(props: { isMobile: boolean }) {
onChange={setAutoSpeak}
/>
{/* TTS character limit toggle */}
<FormSwitchControl
title='Speak Cost Guard'
description={ttsCharLimit !== null ? 'Max ~3 min' : 'Unlimited'}
tooltip='Limits text sent to TTS providers, helping prevent unexpected costs with cloud services'
checked={ttsCharLimit !== null}
onChange={(checked) => setTtsCharLimit(checked ? 4096 : null)}
/>
{/* Engine configuration */}
<SpeexConfigureEngines isMobile={props.isMobile} />
@@ -9,7 +9,7 @@ import { returnAudioWholeOrThrow, streamAudioChunksOrThrow } from './rpc.streami
// configuration
const SAFETY_TEXT_LENGTH = 1000;
const SAFETY_TEXT_LENGTH = 40000; // fallback safety net (user limit applied in speex.client.ts)
const MIN_CHUNK_SIZE = 4096;
@@ -12,7 +12,7 @@ import { returnAudioWholeOrThrow, streamAudioChunksOrThrow } from './rpc.streami
// configuration
const SAFETY_TEXT_LENGTH = 4096; // OpenAI max
const SAFETY_TEXT_LENGTH = 40000; // fallback safety net (user limit applied in speex.client.ts)
const MIN_CHUNK_SIZE = 4096; // bytes
+10 -3
View File
@@ -9,7 +9,7 @@
import { useUIPreferencesStore } from '~/common/stores/store-ui';
import type { DSpeexEngineAny, DVoiceWebSpeech, SpeexSpeakOptions, SpeexSpeakResult, SpeexVoiceSelector } from './speex.types';
import { speexFindEngineById, speexFindGlobalEngine, speexFindValidEngineByType } from './store-module-speex';
import { speexFindEngineById, speexFindGlobalEngine, speexFindValidEngineByType, speexGetTtsCharLimit } from './store-module-speex';
import { speexSynthesize_RPC } from './protocols/rpc/rpc.client';
import { speexSynthesize_WebSpeech } from './protocols/webspeech/webspeech.client';
@@ -43,17 +43,24 @@ export async function speakText(
// apply voice override from selector (merge with engine defaults)
const effectiveEngine = _engineApplyVoiceOverride(engine, voiceSelector);
// apply user-configurable character limit (null = unlimited)
const charLimit = speexGetTtsCharLimit();
const truncated = charLimit !== null && inputText.length > charLimit;
const text = truncated ? inputText.slice(0, charLimit) : inputText;
if (truncated)
console.log(`[Speex] Text truncated from ${inputText.length} to ${charLimit} characters`);
try {
switch (effectiveEngine.vendorType) {
// RPC providers: route through speex.router RPC
case 'elevenlabs':
case 'openai':
case 'localai':
return speexSynthesize_RPC(effectiveEngine, inputText, { streaming, playback, returnAudio, languageCode, priority }, callbacks);
return speexSynthesize_RPC(effectiveEngine, text, { streaming, playback, returnAudio, languageCode, priority }, callbacks);
// Web Speech: client-only, no RPC
case 'webspeech':
return speexSynthesize_WebSpeech(inputText, effectiveEngine.voice as DVoiceWebSpeech, callbacks);
return speexSynthesize_WebSpeech(text, effectiveEngine.voice as DVoiceWebSpeech, callbacks);
}
} catch (error) {
callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
+28
View File
@@ -19,6 +19,9 @@ interface SpeexStoreState {
engines: Record<SpeexEngineId, DSpeexEngineAny>;
activeEngineId: SpeexEngineId | null; // null = no user selection = use global auto-selection
// TTS character limit: number = limit chars, null = unlimited (default: 4096)
ttsCharLimit: number | null;
// to avoid repeated migrations
hasInitializedLlms: boolean;
hasMigratedElevenLabs: boolean;
@@ -35,6 +38,9 @@ interface SpeexStoreActions {
// selection
setActiveEngineId: (engineId: SpeexEngineId | null) => void;
// TTS settings
setTtsCharLimit: (limit: number | null) => void;
// business logic: auto-detection or migration
syncWebSpeechEngine: () => boolean;
syncEnginesFromLLMServices: (llmsSources: ReturnType<typeof useModelsStore.getState>['sources']) => boolean;
@@ -51,6 +57,7 @@ export const useSpeexStore = create<SpeexStore>()(persist(
// initial state
engines: {},
activeEngineId: null,
ttsCharLimit: 4096, // default: ~3 min of speech, null = unlimited
hasInitializedLlms: false,
hasMigratedElevenLabs: false,
@@ -129,6 +136,13 @@ export const useSpeexStore = create<SpeexStore>()(persist(
},
// TTS settings
setTtsCharLimit: (limit) => {
set({ ttsCharLimit: limit });
},
// Auto-detections
syncWebSpeechEngine: () => {
@@ -378,6 +392,20 @@ export function speexFindGlobalEngine({ engines, activeEngineId }: SpeexStore =
}
// TTS character limit
export function useSpeexTtsCharLimit() {
return useSpeexStore(useShallow(state => ({
ttsCharLimit: state.ttsCharLimit,
setTtsCharLimit: state.setTtsCharLimit,
})));
}
export function speexGetTtsCharLimit(): number | null {
return useSpeexStore.getState().ttsCharLimit;
}
export function speexAreCredentialsValid(credentials: DSpeexCredentialsAny): boolean {
switch (credentials.type) {
case 'api-key':