diff --git a/packages/stage-pages/src/pages/settings/modules/speech.vue b/packages/stage-pages/src/pages/settings/modules/speech.vue index da9142e1e..45558bf7e 100644 --- a/packages/stage-pages/src/pages/settings/modules/speech.vue +++ b/packages/stage-pages/src/pages/settings/modules/speech.vue @@ -149,6 +149,31 @@ function voiceAnalyticsPayload( } } +/** + * Adds server-side TTS analytics metadata to official preview requests. + */ +function withManualPreviewAnalytics | undefined>( + providerConfig: TProviderConfig, + providerId: string, + voiceType: VoiceType, +): TProviderConfig | Record { + if (providerId !== OFFICIAL_SPEECH_PROVIDER_ID && providerId !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + return providerConfig + + const baseConfig: Record = providerConfig ?? {} + return { + ...baseConfig, + extraBody: { + ...(baseConfig.extraBody as Record | undefined), + airi_analytics: { + trigger: 'manual', + source: 'manual_preview', + voice_type: voiceType, + }, + }, + } +} + /** * Tracks the active TTS provider while preserving the legacy provider-card event. */ @@ -318,6 +343,7 @@ async function generateTestSpeech() { const previewVoice = voice const previewModel = model const previewProvider = activeSpeechProvider.value || 'unknown' + const previewAnalytics = voiceAnalyticsPayload(previewVoice.id, previewVoicePack, previewProvider) isGenerating.value = true errorMessage.value = '' @@ -348,7 +374,10 @@ async function generateTestSpeech() { }) const response = await generateSpeech({ - ...provider.speech(model, speechRequest.providerConfig), + ...provider.speech( + model, + withManualPreviewAnalytics(speechRequest.providerConfig, previewProvider, previewAnalytics.voice_type), + ), input: speechRequest.input, voice: voice.id, }) @@ -364,7 +393,7 @@ async function generateTestSpeech() { trackVoicePreviewPlayed({ tts_provider_id: previewProvider, tts_model_id: previewModel, - ...voiceAnalyticsPayload(previewVoice.id, previewVoicePack, previewProvider), + ...previewAnalytics, source: 'manual_preview', }) }) @@ -403,6 +432,7 @@ onUnmounted(() => { }) function updateCustomVoiceName(value: string | undefined) { + activeSpeechVoiceId.value = value || '' if (!value) { activeSpeechVoice.value = undefined return @@ -417,7 +447,13 @@ function updateCustomVoiceName(value: string | undefined) { provider: activeSpeechProvider.value, gender: 'male', } - selectSpeechVoice(value) +} + +/** + * Tracks a manual voice after the input value is committed by the user. + */ +function commitCustomVoiceSelection() { + selectSpeechVoice(activeSpeechVoiceId.value) } function updateCustomModelName(value: string | undefined) { @@ -782,6 +818,7 @@ function handleDeleteProvider(providerId: string) { label="Voice Name" description="Enter the voice name for your custom voice" placeholder="Enter voice name (e.g., 'alloy', 'echo')" + @change="commitCustomVoiceSelection" @update:model-value="updateCustomVoiceName" /> diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.test.ts b/packages/stage-ui/src/libs/providers/providers/official/index.test.ts index 773308926..4c98ccfcc 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.test.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.test.ts @@ -2,11 +2,15 @@ import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils' import { describe, expect, it } from 'vitest' -import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID, providerOfficialSpeech, providerOfficialTranscription } from './index' +import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID, providerOfficialSpeech, providerOfficialSpeechStreaming, providerOfficialTranscription } from './index' interface OfficialSpeechOptions { speed?: number extraBody?: { + airi_analytics?: { + source: string + voice_type: string + } voice_pack?: { pitch?: number } @@ -39,6 +43,32 @@ describe('official speech provider', () => { }) expect(request.fetch).toBeTypeOf('function') }) + + /** + * @example + * provider.speech('volcengine/seed-tts-2.0', { extraBody: { airi_analytics: { source: 'manual_preview', voice_type: 'official_selected' } } }) + */ + it('keeps streaming speech preview analytics on the generated request config', () => { + const provider = providerOfficialSpeechStreaming.createProvider({}) as SpeechProviderWithExtraOptions + + const request = provider.speech('volcengine/seed-tts-2.0', { + extraBody: { + airi_analytics: { + source: 'manual_preview', + voice_type: 'official_selected', + }, + }, + }) + + expect(request.model).toBe('volcengine/seed-tts-2.0') + expect(request.extraBody).toEqual({ + airi_analytics: { + source: 'manual_preview', + voice_type: 'official_selected', + }, + }) + expect(request.fetch).toBeTypeOf('function') + }) }) describe('official transcription provider', () => { diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index 6b74496db..99b7d1dbf 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -234,14 +234,16 @@ export const providerOfficialSpeechStreaming = defineProvider({ createProviderConfig: () => officialConfigSchema, createProvider(_config) { // Same audio-scoped baseURL as the HTTP speech provider. The streaming - // provider does not actually use `.speech()` for synthesis (it goes - // through `streamingSynthesize` which opens its own WebSocket), but the - // OpenAI-shaped provider instance is still returned so legacy fallback - // and feature-detection helpers keep working. + // provider usually goes through `streamingSynthesize`, but settings + // previews still use the OpenAI-shaped `.speech()` API so manual preview + // analytics must be able to pass extra request body fields through. const provider = createOfficialAudioProvider() const originalSpeech = provider.speech.bind(provider) - provider.speech = (model: string) => { - const result = originalSpeech(model) + provider.speech = (model: string, extraOptions?: Record) => { + const result = { + ...originalSpeech(model), + ...extraOptions, + } result.fetch = withCredentials() return result }