feat: add upstreamVoiceId to voice packs and related services

- Introduced upstreamVoiceId field in voice pack schema and database.
- Updated voice pack service to handle upstreamVoiceId in CRUD operations.
- Modified API routes and tests to accommodate upstreamVoiceId.
- Enhanced UI components to include upstreamVoiceId in forms and displays.
- Adjusted speech processing logic to utilize upstreamVoiceId where applicable.
- Updated related tests to ensure proper functionality with new field.
This commit is contained in:
RainbowBird
2026-07-01 16:07:33 +08:00
parent 1f1ef49eed
commit c2aee68513
20 changed files with 3221 additions and 96 deletions
@@ -75,6 +75,8 @@ const errorMessage = ref('')
const selectedSpeechSource = ref<string | null>(null)
const VOICE_PACK_SOURCE_ID = 'voice-pack'
const VOICE_PACK_REQUEST_MODEL_ID = 'auto'
const VOICE_PACK_ANALYTICS_MODEL_ID = 'voice_pack'
const STREAMING_MODEL_OPTION_PREFIX = 'streaming:'
const isOfficialSpeechProvider = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID)
@@ -198,6 +200,15 @@ function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo {
}
}
function formatVoicePackCostMultiplier(costMultiplier: number) {
return `Flux cost: ${costMultiplier}x`
}
function voicePackDescription(description: string | null | undefined, costMultiplier: number) {
const cost = formatVoicePackCostMultiplier(costMultiplier)
return description ? `${description} · ${cost}` : cost
}
function voicePackVoiceId(packId: string) {
return `voice-pack:${packId}`
}
@@ -211,7 +222,7 @@ const displayedVoiceOptions = computed(() => {
const options = voicePacks.value.map(pack => ({
id: voicePackVoiceId(pack.id),
name: pack.name,
description: pack.description ?? undefined,
description: voicePackDescription(pack.description, pack.costMultiplier),
previewURL: '',
customizable: false,
}))
@@ -221,7 +232,7 @@ const displayedVoiceOptions = computed(() => {
options.unshift({
id: frozenVoiceId,
name: voicePack.name,
description: voicePack.name,
description: voicePackDescription(voicePack.name, voicePack.costMultiplier),
previewURL: '',
customizable: false,
})
@@ -265,6 +276,12 @@ const currentSpeechVoiceId = computed(() => {
return activeSpeechVoiceId.value || ''
})
const currentVoicePackCostMultiplier = computed(() => {
if (isVoicePackSourceSelected.value && boundVoicePack.value)
return formatVoicePackCostMultiplier(boundVoicePack.value.costMultiplier)
return ''
})
function syncBoundVoicePackSelection() {
const voicePack = boundVoicePack.value
if (!voicePack)
@@ -272,7 +289,7 @@ function syncBoundVoicePackSelection() {
selectedSpeechSource.value = VOICE_PACK_SOURCE_ID
activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID
activeSpeechModel.value = voicePack.ttsModelId
activeSpeechModel.value = VOICE_PACK_REQUEST_MODEL_ID
activeSpeechVoiceId.value = voicePack.voiceId
activeSpeechVoice.value = createVoicePackVoice(voicePack)
return true
@@ -283,7 +300,7 @@ function syncBoundVoicePackSelection() {
*/
function currentTtsModelId() {
if (isVoicePackSourceSelected.value && boundVoicePack.value)
return boundVoicePack.value.ttsModelId
return VOICE_PACK_ANALYTICS_MODEL_ID
return activeSpeechModel.value || 'unknown'
}
@@ -396,7 +413,7 @@ function selectSpeechSource(sourceId: string) {
activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID
const voicePack = boundVoicePack.value
if (voicePack) {
activeSpeechModel.value = voicePack.ttsModelId
activeSpeechModel.value = VOICE_PACK_REQUEST_MODEL_ID
activeSpeechVoiceId.value = voicePack.voiceId
activeSpeechVoice.value = createVoicePackVoice(voicePack)
return
@@ -481,12 +498,12 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) {
selectedSpeechSource.value = VOICE_PACK_SOURCE_ID
activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID
activeSpeechModel.value = pack.ttsModelId
activeSpeechModel.value = VOICE_PACK_REQUEST_MODEL_ID
activeSpeechVoiceId.value = pack.voiceId
activeSpeechVoice.value = {
id: pack.voiceId,
name: pack.name,
description: pack.description ?? pack.name,
description: voicePackDescription(pack.description ?? pack.name, pack.costMultiplier),
previewURL: '',
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
@@ -495,14 +512,14 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) {
trackVoicePackBound({
tts_provider_id: activeSpeechProvider.value || 'unknown',
tts_model_id: pack.ttsModelId,
tts_model_id: VOICE_PACK_ANALYTICS_MODEL_ID,
voice_id: pack.voiceId,
voice_pack_id: pack.id,
source: 'settings',
})
trackVoiceSelected({
tts_provider_id: activeSpeechProvider.value || 'unknown',
tts_model_id: pack.ttsModelId,
tts_model_id: VOICE_PACK_ANALYTICS_MODEL_ID,
voice_id: pack.voiceId,
voice_type: 'voice_pack',
voice_pack_id: pack.id,
@@ -603,7 +620,7 @@ async function generateTestSpeech() {
const voicePack = boundVoicePack.value
if (voicePack) {
model = voicePack.ttsModelId
model = VOICE_PACK_REQUEST_MODEL_ID
if (!voice || voice.id !== voicePack.voiceId)
voice = createVoicePackVoice(voicePack)
}
@@ -928,7 +945,10 @@ function handleDeleteProvider(providerId: string) {
</h2>
<div class="flex flex-col items-start gap-1 text-neutral-400 md:flex-row md:items-center md:justify-between dark:text-neutral-500">
<span>Customize how your AI assistant speaks</span>
<span v-if="currentSpeechVoiceId" class="text-sm text-neutral-400 font-medium dark:text-neutral-400">Current voice: {{ currentSpeechVoiceId }}</span>
<span v-if="currentSpeechVoiceId" class="text-sm text-neutral-400 font-medium dark:text-neutral-400">
Current voice: {{ currentSpeechVoiceId }}
<span v-if="currentVoicePackCostMultiplier">· {{ currentVoicePackCostMultiplier }}</span>
</span>
</div>
</div>
@@ -964,7 +984,6 @@ function handleDeleteProvider(providerId: string) {
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="displayedSpeechVoiceId"
:show-visualizer="false"
:voices="displayedVoiceOptions"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
@@ -436,7 +436,7 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
const voicePack = voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack)
if (voicePack) {
model = voicePack.ttsModelId
model = 'auto'
if (!voice || voice.id !== voicePack.voiceId)
voice = createVoicePackVoice(voicePack)
}
@@ -117,10 +117,7 @@ describe('airi-card store', () => {
const pack = {
id: 'vp-1',
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: '+20%', volume: '+5%' },
costMultiplier: 1.5,
}
@@ -130,15 +127,12 @@ describe('airi-card store', () => {
expect(bound).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({
provider: OFFICIAL_SPEECH_PROVIDER_ID,
model: 'volcengine/neuro-pool',
model: 'auto',
voice_id: 'voice-neuro',
voicePack: {
packId: 'vp-1',
name: 'Neuro Sama',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-neuro',
ttsModelId: 'volcengine/neuro-pool',
params: { pitch: '+20%', volume: '+5%' },
costMultiplier: 1.5,
},
@@ -157,12 +151,9 @@ describe('airi-card store', () => {
cardStore.bindVoicePackToActiveCard({
id: 'vp-1',
name: 'Frozen',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
ttsModelId: 'volcengine/pool-a',
params,
costMultiplier: 1,
costMultiplier: 2,
})
params.pitch = '-10%'
@@ -23,10 +23,7 @@ export type VoicePackParams = Record<string, string | number | boolean | null>
export interface VoicePackBindingInput {
id: string
name: string
provider: string
model: string
voiceId: string
ttsModelId: string
params: VoicePackParams
costMultiplier: number
}
@@ -34,10 +31,7 @@ export interface VoicePackBindingInput {
export interface VoicePackSnapshot {
packId: string
name: string
provider: string
model: string
voiceId: string
ttsModelId: string
params: VoicePackParams
costMultiplier: number
}
@@ -210,8 +204,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return updateActiveCardModules(({ modules }) => {
const existingVoicePack = modules.speech.voicePack
const shouldKeepVoicePack = speech.provider === OFFICIAL_SPEECH_PROVIDER_ID
&& existingVoicePack?.ttsModelId === speech.model
&& existingVoicePack.voiceId === speech.voice_id
&& existingVoicePack?.voiceId === speech.voice_id
return {
speech: {
@@ -368,10 +361,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const voicePack: VoicePackSnapshot = {
packId: pack.id,
name: pack.name,
provider: pack.provider,
model: pack.model,
voiceId: pack.voiceId,
ttsModelId: pack.ttsModelId,
params: { ...pack.params },
costMultiplier: pack.costMultiplier,
}
@@ -379,7 +369,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const speech: AiriExtension['modules']['speech'] = {
...extension.modules.speech,
provider: OFFICIAL_SPEECH_PROVIDER_ID,
model: pack.ttsModelId,
model: 'auto',
voice_id: pack.voiceId,
voicePack,
}
@@ -399,7 +389,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
})
activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID
activeSpeechModel.value = pack.ttsModelId
activeSpeechModel.value = 'auto'
activeSpeechVoiceId.value = pack.voiceId
return true
@@ -66,10 +66,7 @@ describe('speech store helpers', () => {
const voicePack = {
packId: 'vp-1',
name: 'Frozen',
provider: 'volcengine',
model: 'seed-tts-2.0',
voiceId: 'voice-a',
ttsModelId: 'volcengine/pool-a',
params: {},
costMultiplier: 1,
}
@@ -198,9 +195,9 @@ describe('speech store helpers', () => {
/**
* @example
* speechStore.resolveVoicePackSpeechInput({ text, voice, voicePack: { packId: 'vp-1', costMultiplier: 1.5 } })
* speechStore.resolveVoicePackSpeechInput({ text, voice, voicePack: { packId: 'vp-1' } })
*/
it('passes Voice Pack snapshot billing metadata through adapter options', () => {
it('passes only Voice Pack identity through adapter options', () => {
const speechStore = useSpeechStore()
const voice = {
id: 'voice-1',
@@ -215,7 +212,6 @@ describe('speech store helpers', () => {
params: {},
voicePack: {
packId: 'vp-1',
costMultiplier: 1.5,
},
supportsAdapterProsody: true,
})
@@ -223,7 +219,6 @@ describe('speech store helpers', () => {
expect(request.providerConfig.extraBody).toEqual({
voice_pack: {
pack_id: 'vp-1',
cost_multiplier: 1.5,
},
})
})
@@ -29,7 +29,7 @@ interface VoicePackSpeechInputOptions {
voice: VoiceInfo
providerConfig?: Record<string, unknown>
params?: VoicePackParams
voicePack?: Pick<VoicePackSnapshot, 'packId' | 'costMultiplier'>
voicePack?: Pick<VoicePackSnapshot, 'packId'>
forceSSML?: boolean
supportsSSML?: boolean
supportsAdapterProsody?: boolean
@@ -517,7 +517,6 @@ export const useSpeechStore = defineStore('speech', () => {
...(providerConfig.extraBody as Record<string, unknown> | undefined),
voice_pack: {
pack_id: options.voicePack.packId,
cost_multiplier: options.voicePack.costMultiplier,
...(needsProsody && options.supportsAdapterProsody
? { pitch, volume }
: {}),