mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user