diff --git a/packages/stage-pages/src/pages/settings/modules/speech.vue b/packages/stage-pages/src/pages/settings/modules/speech.vue index 7ef767861..4979695a6 100644 --- a/packages/stage-pages/src/pages/settings/modules/speech.vue +++ b/packages/stage-pages/src/pages/settings/modules/speech.vue @@ -16,7 +16,7 @@ import { import { useAnalytics } from '@proj-airi/stage-ui/composables' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official' import { useAiriCardStore, useVoicePacksStore } from '@proj-airi/stage-ui/stores' -import { useSpeechStore, VOICE_PACK_MODEL_ID, voicePackForSpeechProvider } from '@proj-airi/stage-ui/stores/modules/speech' +import { useSpeechStore, voicePackForSpeechProvider } from '@proj-airi/stage-ui/stores/modules/speech' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { FieldCheckbox, @@ -72,43 +72,120 @@ const isGenerating = ref(false) const audioUrl = ref('') const audioPlayer = ref(null) const errorMessage = ref('') +const selectedSpeechSource = ref(null) -const VOICE_PACK_MODEL_OPTION = { - id: VOICE_PACK_MODEL_ID, - name: 'Voice Pack', - description: 'Server-curated voices', -} +const VOICE_PACK_SOURCE_ID = 'voice-pack' +const STREAMING_MODEL_OPTION_PREFIX = 'streaming:' const isOfficialSpeechProvider = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID) -const shouldShowVoicePackModel = computed(() => isOfficialSpeechProvider.value && voicePacks.value.length > 0) -const isVoicePackModelSelected = computed(() => shouldShowVoicePackModel.value && activeSpeechModel.value === VOICE_PACK_MODEL_ID) const boundVoicePack = computed(() => voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack), ) -const selectableSpeechProvidersMetadata = computed(() => { +const selectableSpeechSources = computed(() => { + const configuredSources = configuredSpeechProvidersMetadata.value + .filter(metadata => + metadata.id !== 'speech-noop' + && metadata.id !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + ) + .map(metadata => ({ + id: metadata.id, + providerId: metadata.id, + title: metadata.localizedName || 'Unknown', + description: metadata.localizedDescription, + })) + return [ - ...configuredSpeechProvidersMetadata.value.filter(metadata => metadata.id !== 'speech-noop'), - ...allAudioSpeechProvidersMetadata.value.filter(metadata => metadata.id === 'speech-noop'), + { + id: VOICE_PACK_SOURCE_ID, + providerId: undefined, + title: 'Voice Pack', + description: 'Server-curated voices', + }, + ...configuredSources, + ...allAudioSpeechProvidersMetadata.value + .filter(metadata => metadata.id === 'speech-noop') + .map(metadata => ({ + id: metadata.id, + providerId: metadata.id, + title: metadata.localizedName || 'Unknown', + description: metadata.localizedDescription, + })), ] }) +const displayedSpeechSource = computed({ + get: () => { + if (selectedSpeechSource.value === VOICE_PACK_SOURCE_ID) + return VOICE_PACK_SOURCE_ID + if (boundVoicePack.value && isOfficialSpeechProvider.value) + return VOICE_PACK_SOURCE_ID + if (activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + return OFFICIAL_SPEECH_PROVIDER_ID + return activeSpeechProvider.value + }, + set: (value: string) => { + selectSpeechSource(value) + }, +}) + +const isVoicePackSourceSelected = computed(() => displayedSpeechSource.value === VOICE_PACK_SOURCE_ID) +const isOfficialSpeechSourceSelected = computed(() => displayedSpeechSource.value === OFFICIAL_SPEECH_PROVIDER_ID) + +function streamingModelOptionId(modelId: string) { + return `${STREAMING_MODEL_OPTION_PREFIX}${modelId}` +} + +function modelIdFromStreamingOptionId(optionId: string) { + return optionId.startsWith(STREAMING_MODEL_OPTION_PREFIX) + ? optionId.slice(STREAMING_MODEL_OPTION_PREFIX.length) + : null +} + const displayedProviderModels = computed(() => { - if (!shouldShowVoicePackModel.value) + if (!isOfficialSpeechSourceSelected.value) return providerModels.value - return [VOICE_PACK_MODEL_OPTION, ...providerModels.value] + const regularModels = providersStore.getModelsForProvider(OFFICIAL_SPEECH_PROVIDER_ID) + const streamingModels = providersStore.getModelsForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + return [ + ...regularModels, + ...streamingModels.map(model => ({ + ...model, + id: streamingModelOptionId(model.id), + name: model.name, + description: model.description || 'Low-latency streaming TTS', + })), + ] }) const displayedSpeechModel = computed({ - get: () => activeSpeechModel.value === VOICE_PACK_MODEL_ID && !shouldShowVoicePackModel.value - ? '' + get: () => activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID && activeSpeechModel.value + ? streamingModelOptionId(activeSpeechModel.value) : activeSpeechModel.value, set: (value: string) => { - activeSpeechModel.value = value + selectSpeechModel(value) }, }) +const currentSpeechModelId = computed(() => activeSpeechModel.value || '') + +const displayedModelsLoading = computed(() => { + if (!isOfficialSpeechSourceSelected.value) + return isLoadingActiveProviderModels.value + return providersStore.isLoadingModels[OFFICIAL_SPEECH_PROVIDER_ID] + || providersStore.isLoadingModels[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID] + || false +}) + +const displayedModelError = computed(() => { + if (!isOfficialSpeechSourceSelected.value) + return activeProviderModelError.value + return providersStore.modelLoadError[OFFICIAL_SPEECH_PROVIDER_ID] + || providersStore.modelLoadError[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID] + || null +}) + function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo { return { id: voicePack.voiceId, @@ -129,32 +206,8 @@ function packIdFromVoicePackVoiceId(voiceId: string) { return voiceId.startsWith('voice-pack:') ? voiceId.slice('voice-pack:'.length) : null } -function createVoicePackPickerVoice(pack: (typeof voicePacks.value)[number]): VoiceInfo { - return { - id: voicePackVoiceId(pack.id), - name: pack.name, - description: pack.description ?? pack.name, - previewURL: '', - languages: [{ code: 'en', title: 'English' }], - provider: activeSpeechProvider.value, - gender: 'neutral', - } -} - -function createVoicePackSnapshotPickerVoice(voicePack: VoicePackSnapshot): VoiceInfo { - return { - id: voicePackVoiceId(voicePack.packId), - name: voicePack.name, - description: voicePack.name, - previewURL: '', - languages: [{ code: 'en', title: 'English' }], - provider: activeSpeechProvider.value, - gender: 'neutral', - } -} - const displayedVoiceOptions = computed(() => { - if (isVoicePackModelSelected.value) { + if (isVoicePackSourceSelected.value) { const options = voicePacks.value.map(pack => ({ id: voicePackVoiceId(pack.id), name: pack.name, @@ -191,14 +244,37 @@ const displayedVoiceOptions = computed(() => { })) }) +const displayedSpeechVoiceId = computed({ + get: () => { + if (isVoicePackSourceSelected.value && boundVoicePack.value) + return voicePackVoiceId(boundVoicePack.value.packId) + return activeSpeechVoiceId.value + }, + set: (value: string) => { + if (isVoicePackSourceSelected.value) { + void selectSpeechVoice(value) + return + } + activeSpeechVoiceId.value = value + }, +}) + +const currentSpeechVoiceId = computed(() => { + if (isVoicePackSourceSelected.value && boundVoicePack.value) + return boundVoicePack.value.voiceId + return activeSpeechVoiceId.value || '' +}) + function syncBoundVoicePackSelection() { const voicePack = boundVoicePack.value - if (!shouldShowVoicePackModel.value || !voicePack) + if (!voicePack) return false - activeSpeechModel.value = VOICE_PACK_MODEL_ID - activeSpeechVoiceId.value = voicePackVoiceId(voicePack.packId) - activeSpeechVoice.value = createVoicePackSnapshotPickerVoice(voicePack) + selectedSpeechSource.value = VOICE_PACK_SOURCE_ID + activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID + activeSpeechModel.value = voicePack.ttsModelId + activeSpeechVoiceId.value = voicePack.voiceId + activeSpeechVoice.value = createVoicePackVoice(voicePack) return true } @@ -206,7 +282,7 @@ function syncBoundVoicePackSelection() { * Resolves the current TTS model id for low-cardinality analytics payloads. */ function currentTtsModelId() { - if (isVoicePackModelSelected.value && boundVoicePack.value) + if (isVoicePackSourceSelected.value && boundVoicePack.value) return boundVoicePack.value.ttsModelId return activeSpeechModel.value || 'unknown' } @@ -296,7 +372,7 @@ async function selectSpeechVoice(voiceId: string | undefined) { return const voicePackId = packIdFromVoicePackVoiceId(voiceId) - if (isVoicePackModelSelected.value && voicePackId) { + if (isVoicePackSourceSelected.value && voicePackId) { const pack = voicePacks.value.find(item => item.id === voicePackId) if (!pack) return @@ -313,6 +389,53 @@ async function selectSpeechVoice(voiceId: string | undefined) { }) } +function selectSpeechSource(sourceId: string) { + selectedSpeechSource.value = sourceId === VOICE_PACK_SOURCE_ID ? VOICE_PACK_SOURCE_ID : null + + if (sourceId === VOICE_PACK_SOURCE_ID) { + activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID + const voicePack = boundVoicePack.value + if (voicePack) { + activeSpeechModel.value = voicePack.ttsModelId + activeSpeechVoiceId.value = voicePack.voiceId + activeSpeechVoice.value = createVoicePackVoice(voicePack) + return + } + activeSpeechModel.value = '' + activeSpeechVoiceId.value = '' + activeSpeechVoice.value = undefined + return + } + + if (sourceId === OFFICIAL_SPEECH_PROVIDER_ID && boundVoicePack.value) { + airiCardStore.updateActiveCardSpeech({ + provider: OFFICIAL_SPEECH_PROVIDER_ID, + model: '', + voice_id: '', + }) + } + + activeSpeechProvider.value = sourceId +} + +function selectSpeechModel(modelOptionId: string) { + const streamingModelId = modelIdFromStreamingOptionId(modelOptionId) + const nextProvider = streamingModelId == null + ? activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + ? OFFICIAL_SPEECH_PROVIDER_ID + : activeSpeechProvider.value + : OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + const nextModel = streamingModelId ?? modelOptionId + + if (activeSpeechProvider.value !== nextProvider) { + activeSpeechProvider.value = nextProvider + activeSpeechVoiceId.value = '' + activeSpeechVoice.value = undefined + } + + activeSpeechModel.value = nextModel +} + // Sync OpenAI Compatible model and voice from provider config function syncOpenAICompatibleSettings() { if (activeSpeechProvider.value !== 'openai-compatible-audio-speech') @@ -343,9 +466,11 @@ function syncOpenAICompatibleSettings() { onMounted(async () => { await providersStore.loadModelsForConfiguredProviders() await voicePacksStore.load() - syncBoundVoicePackSelection() - speechStore.ensureActiveSpeechModel() - await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined) + const syncedVoicePack = syncBoundVoicePackSelection() + if (!syncedVoicePack) { + speechStore.ensureActiveSpeechModel() + await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined) + } syncOpenAICompatibleSettings() }) @@ -354,9 +479,19 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) { if (!bound) return - activeSpeechModel.value = VOICE_PACK_MODEL_ID - activeSpeechVoiceId.value = voicePackVoiceId(pack.id) - activeSpeechVoice.value = createVoicePackPickerVoice(pack) + selectedSpeechSource.value = VOICE_PACK_SOURCE_ID + activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID + activeSpeechModel.value = pack.ttsModelId + activeSpeechVoiceId.value = pack.voiceId + activeSpeechVoice.value = { + id: pack.voiceId, + name: pack.name, + description: pack.description ?? pack.name, + previewURL: '', + languages: [{ code: 'en', title: 'English' }], + provider: activeSpeechProvider.value, + gender: 'neutral', + } trackVoicePackBound({ tts_provider_id: activeSpeechProvider.value || 'unknown', @@ -379,12 +514,22 @@ watch(activeSpeechProvider, async (newProvider, oldProvider) => { await providersStore.loadModelsForConfiguredProviders() // Reset model and voice when switching providers (but not on initial load) - if (oldProvider !== undefined && oldProvider !== newProvider) { + const isMergedOfficialSwitch = ( + oldProvider === OFFICIAL_SPEECH_PROVIDER_ID + || oldProvider === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + ) && ( + newProvider === OFFICIAL_SPEECH_PROVIDER_ID + || newProvider === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + ) + if (oldProvider !== undefined && oldProvider !== newProvider && !isMergedOfficialSwitch) { activeSpeechModel.value = '' activeSpeechVoiceId.value = '' activeSpeechVoice.value = undefined } + if (isVoicePackSourceSelected.value) + return + // Re-seed the streaming default model after the reset above so its voices // load model-scoped (the server only returns recommended voices for an // explicit ?model=). No-op for other providers / when a model is selected. @@ -406,7 +551,7 @@ watch(activeSpeechModel, async (model) => { if (!activeSpeechProvider.value) return - if (model === VOICE_PACK_MODEL_ID) + if (isVoicePackSourceSelected.value) return activeSpeechVoiceId.value = '' @@ -416,18 +561,6 @@ watch(activeSpeechModel, async (model) => { }) watch([activeSpeechProvider, activeSpeechModel, activeSpeechVoiceId], ([provider, model, voiceId]) => { - if (provider === OFFICIAL_SPEECH_PROVIDER_ID && model === VOICE_PACK_MODEL_ID) { - const voicePack = boundVoicePack.value - if (voicePack) { - airiCardStore.updateActiveCardSpeech({ - provider, - model: voicePack.ttsModelId, - voice_id: voicePack.voiceId, - }) - } - return - } - airiCardStore.updateActiveCardSpeech({ provider, model, voice_id: voiceId }) }) @@ -636,26 +769,26 @@ function handleDeleteProvider(providerId: string) {