fix(analytics): address speech preview review (#2024)

## Summary
- attach manual preview analytics metadata to official TTS preview
requests
- avoid tracking custom voice selection during settings sync or every
input keystroke

## Tests
- corepack pnpm -F @proj-airi/stage-pages typecheck
- corepack pnpm exec eslint
packages/stage-pages/src/pages/settings/modules/speech.vue
- git diff --check
This commit is contained in:
Lovehsigure_520
2026-07-01 00:20:36 +08:00
committed by GitHub
parent 3d7a8300d8
commit 5f13127810
3 changed files with 79 additions and 10 deletions
@@ -149,6 +149,31 @@ function voiceAnalyticsPayload(
}
}
/**
* Adds server-side TTS analytics metadata to official preview requests.
*/
function withManualPreviewAnalytics<TProviderConfig extends Record<string, unknown> | undefined>(
providerConfig: TProviderConfig,
providerId: string,
voiceType: VoiceType,
): TProviderConfig | Record<string, unknown> {
if (providerId !== OFFICIAL_SPEECH_PROVIDER_ID && providerId !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
return providerConfig
const baseConfig: Record<string, unknown> = providerConfig ?? {}
return {
...baseConfig,
extraBody: {
...(baseConfig.extraBody as Record<string, unknown> | 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"
/>
@@ -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<string, OfficialSpeechOptions>
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', () => {
@@ -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<string, unknown>) => {
const result = {
...originalSpeech(model),
...extraOptions,
}
result.fetch = withCredentials()
return result
}