diff --git a/src/apps/settings-modal/VoiceOutSettings.tsx b/src/apps/settings-modal/VoiceOutSettings.tsx index ba8f36607..b9c5db40f 100644 --- a/src/apps/settings-modal/VoiceOutSettings.tsx +++ b/src/apps/settings-modal/VoiceOutSettings.tsx @@ -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[] = [ @@ -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 */} + setTtsCharLimit(checked ? 4096 : null)} + /> + {/* Engine configuration */} diff --git a/src/modules/speex/protocols/rpc/synthesize-elevenlabs.ts b/src/modules/speex/protocols/rpc/synthesize-elevenlabs.ts index 458fa03b0..88be461dd 100644 --- a/src/modules/speex/protocols/rpc/synthesize-elevenlabs.ts +++ b/src/modules/speex/protocols/rpc/synthesize-elevenlabs.ts @@ -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; diff --git a/src/modules/speex/protocols/rpc/synthesize-openai.ts b/src/modules/speex/protocols/rpc/synthesize-openai.ts index 3288540eb..85072bdd2 100644 --- a/src/modules/speex/protocols/rpc/synthesize-openai.ts +++ b/src/modules/speex/protocols/rpc/synthesize-openai.ts @@ -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 diff --git a/src/modules/speex/speex.client.ts b/src/modules/speex/speex.client.ts index 2e48069f1..16fb488cb 100644 --- a/src/modules/speex/speex.client.ts +++ b/src/modules/speex/speex.client.ts @@ -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))); diff --git a/src/modules/speex/store-module-speex.ts b/src/modules/speex/store-module-speex.ts index b861f5dca..97a93fe3d 100644 --- a/src/modules/speex/store-module-speex.ts +++ b/src/modules/speex/store-module-speex.ts @@ -19,6 +19,9 @@ interface SpeexStoreState { engines: Record; 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['sources']) => boolean; @@ -51,6 +57,7 @@ export const useSpeexStore = create()(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()(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':