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:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "voice_packs" ADD COLUMN "upstream_voice_id" text;
|
||||
UPDATE "voice_packs" SET "upstream_voice_id" = "voice_id";
|
||||
ALTER TABLE "voice_packs" ALTER COLUMN "upstream_voice_id" SET NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1780498188308,
|
||||
"tag": "0015_concerned_piledriver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1782847276369,
|
||||
"tag": "0016_tired_dagger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ function createService() {
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-neuro',
|
||||
upstreamVoiceId: 'voice-neuro-upstream',
|
||||
ttsModelId: 'volcengine/neuro-pool',
|
||||
params: {},
|
||||
costMultiplier: 1.5,
|
||||
@@ -119,6 +120,7 @@ describe('admin voice packs — CRUD', () => {
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-neuro',
|
||||
upstreamVoiceId: 'voice-neuro-upstream',
|
||||
ttsModelId: 'volcengine/neuro-pool',
|
||||
params: { pitch: '+20%' },
|
||||
costMultiplier: 1.5,
|
||||
@@ -149,6 +151,7 @@ describe('admin voice packs — CRUD', () => {
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-neuro',
|
||||
upstreamVoiceId: 'voice-neuro-upstream',
|
||||
ttsModelId: 'volcengine/neuro-pool',
|
||||
params: {},
|
||||
costMultiplier: -1,
|
||||
|
||||
@@ -710,7 +710,8 @@ describe('v1CompletionsRoutes', () => {
|
||||
description: null,
|
||||
provider: 'azure',
|
||||
model: 'microsoft/v1',
|
||||
voiceId: 'en-US-AvaMultilingualNeural',
|
||||
voiceId: 'friendly-azure',
|
||||
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
|
||||
ttsModelId: 'microsoft/v1',
|
||||
params: {},
|
||||
costMultiplier: 1.5,
|
||||
@@ -728,12 +729,11 @@ describe('v1CompletionsRoutes', () => {
|
||||
body: JSON.stringify({
|
||||
model: 'auto',
|
||||
input: 'test',
|
||||
voice: 'en-US-AvaMultilingualNeural',
|
||||
voice: 'friendly-azure',
|
||||
speed: 1.2,
|
||||
extra_body: {
|
||||
voice_pack: {
|
||||
pack_id: 'vp-azure',
|
||||
cost_multiplier: 1.5,
|
||||
pitch: 20,
|
||||
volume: 5,
|
||||
},
|
||||
@@ -784,7 +784,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "cost_multiplier": 2 } } }
|
||||
* POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "pack_id": "vp-premium" } } }
|
||||
*/
|
||||
it('uses Voice Pack cost multiplier for affordability and billing units', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), {
|
||||
@@ -801,6 +801,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
provider: 'azure',
|
||||
model: 'microsoft/v1',
|
||||
voiceId: 'alloy',
|
||||
upstreamVoiceId: 'upstream-alloy',
|
||||
ttsModelId: 'tts-1',
|
||||
params: {},
|
||||
costMultiplier: 2,
|
||||
@@ -832,7 +833,6 @@ describe('v1CompletionsRoutes', () => {
|
||||
extra_body: {
|
||||
voice_pack: {
|
||||
pack_id: 'vp-premium',
|
||||
cost_multiplier: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -868,6 +868,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
provider: 'azure',
|
||||
model: 'microsoft/v1',
|
||||
voiceId: 'alloy',
|
||||
upstreamVoiceId: 'upstream-alloy',
|
||||
ttsModelId: 'tts-1',
|
||||
params: {},
|
||||
costMultiplier: 2,
|
||||
@@ -899,7 +900,6 @@ describe('v1CompletionsRoutes', () => {
|
||||
extra_body: {
|
||||
voice_pack: {
|
||||
pack_id: 'vp-premium',
|
||||
cost_multiplier: 2,
|
||||
},
|
||||
airi_analytics: {
|
||||
source: 'manual_preview',
|
||||
|
||||
@@ -5,6 +5,20 @@ import { Hono } from 'hono'
|
||||
|
||||
import { authGuard } from '../../middlewares/auth'
|
||||
|
||||
function publicVoicePack(pack: Awaited<ReturnType<VoicePackService['listEnabled']>>[number]) {
|
||||
return {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
description: pack.description,
|
||||
voiceId: pack.voiceId,
|
||||
params: pack.params,
|
||||
costMultiplier: pack.costMultiplier,
|
||||
enabled: pack.enabled,
|
||||
createdAt: pack.createdAt,
|
||||
updatedAt: pack.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing Voice Pack routes.
|
||||
*
|
||||
@@ -17,6 +31,6 @@ export function createVoicePackRoutes(service: VoicePackService) {
|
||||
.use('*', authGuard)
|
||||
.get('/', async (c) => {
|
||||
const packs = await service.listEnabled()
|
||||
return c.json(packs)
|
||||
return c.json(packs.map(publicVoicePack))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,7 +23,21 @@ function createTestApp(service: VoicePackService, user: { id: string } | null) {
|
||||
|
||||
function createService() {
|
||||
return {
|
||||
listEnabled: vi.fn(async () => [{ id: 'vp-1', name: 'Enabled', enabled: true }]),
|
||||
listEnabled: vi.fn(async () => [{
|
||||
id: 'vp-1',
|
||||
name: 'Enabled',
|
||||
description: 'Public description',
|
||||
provider: 'azure',
|
||||
model: 'microsoft/v1',
|
||||
voiceId: 'friendly-voice',
|
||||
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
|
||||
ttsModelId: 'microsoft/v1',
|
||||
params: { pitch: '+10%' },
|
||||
costMultiplier: 2,
|
||||
enabled: true,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
}]),
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -50,7 +64,17 @@ describe('voice packs routes', () => {
|
||||
const res = await app.request('/api/v1/voice-packs')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([{ id: 'vp-1', name: 'Enabled', enabled: true }])
|
||||
expect(await res.json()).toEqual([{
|
||||
id: 'vp-1',
|
||||
name: 'Enabled',
|
||||
description: 'Public description',
|
||||
voiceId: 'friendly-voice',
|
||||
params: { pitch: '+10%' },
|
||||
costMultiplier: 2,
|
||||
enabled: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
}])
|
||||
expect(service.listEnabled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export const voicePacks = pgTable(
|
||||
provider: text('provider').notNull(),
|
||||
model: text('model').notNull(),
|
||||
voiceId: text('voice_id').notNull(),
|
||||
upstreamVoiceId: text('upstream_voice_id').notNull(),
|
||||
ttsModelId: text('tts_model_id').notNull(),
|
||||
params: jsonb('params').notNull().$type<VoicePackParams>().default({}),
|
||||
costMultiplier: real('cost_multiplier').notNull().default(1),
|
||||
|
||||
@@ -90,20 +90,23 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
|
||||
|
||||
async function handleSpeechRequest(input: OpenAiSpeechRequest): Promise<Response> {
|
||||
const requestId = nanoid()
|
||||
let requestModel = typeof input.body.model === 'string' ? input.body.model : 'auto'
|
||||
const requestedModel = typeof input.body.model === 'string' ? input.body.model : 'auto'
|
||||
let requestModel = requestedModel
|
||||
const requestVoice = typeof input.body.voice === 'string' ? input.body.voice : undefined
|
||||
const inputText = typeof input.body.input === 'string' ? input.body.input : ''
|
||||
const analytics = ttsAnalyticsContext(input.body)
|
||||
|
||||
if (requestModel === 'auto')
|
||||
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
|
||||
|
||||
const voicePackRequest = await voicePackRequestOptions(input.body, {
|
||||
model: requestModel,
|
||||
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
|
||||
requestedModel,
|
||||
voice: requestVoice,
|
||||
voicePackService: deps.voicePackService,
|
||||
})
|
||||
requestModel = voicePackRequest.model ?? requestModel
|
||||
if (requestModel === 'auto')
|
||||
requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL')
|
||||
const routedVoice = voicePackRequest.voice ?? requestVoice
|
||||
const voiceMetadata = ttsVoiceMetadata({
|
||||
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
|
||||
voice: requestVoice,
|
||||
voicePackId: voicePackRequest.voicePackId,
|
||||
voiceType: analytics.voiceType,
|
||||
})
|
||||
@@ -114,7 +117,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
|
||||
userId: input.userId,
|
||||
model: requestModel,
|
||||
inputChars: inputText.length,
|
||||
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
|
||||
voice: requestVoice,
|
||||
}).log('tts speech request')
|
||||
|
||||
void deps.productEventService.track({
|
||||
@@ -171,7 +174,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
|
||||
|
||||
const ttsInput = {
|
||||
text: inputText,
|
||||
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
|
||||
voice: routedVoice,
|
||||
speed: typeof input.body.speed === 'number' ? input.body.speed : undefined,
|
||||
responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined,
|
||||
extraOptions: voicePackRequest.extraOptions,
|
||||
@@ -405,19 +408,22 @@ function ttsVoiceMetadata(input: {
|
||||
async function voicePackRequestOptions(
|
||||
body: Record<string, unknown>,
|
||||
context: {
|
||||
model: string
|
||||
requestedModel: string
|
||||
voice?: string
|
||||
voicePackService: VoicePackService
|
||||
},
|
||||
): Promise<{ extraOptions: Record<string, unknown> | undefined, costMultiplier: number, voicePackId?: string }> {
|
||||
): Promise<{
|
||||
extraOptions: Record<string, unknown> | undefined
|
||||
costMultiplier: number
|
||||
voicePackId?: string
|
||||
model?: string
|
||||
voice?: string
|
||||
}> {
|
||||
const extraBody = asRecord(body.extra_body)
|
||||
const voicePackOptions = asRecord(extraBody?.voice_pack)
|
||||
const pitch = readOptionalNumber(voicePackOptions, 'pitch')
|
||||
const volume = readOptionalNumber(voicePackOptions, 'volume')
|
||||
const costMultiplier = await resolveVoicePackCostMultiplier(voicePackOptions, context)
|
||||
const voicePackId = typeof voicePackOptions?.pack_id === 'string' && voicePackOptions.pack_id.trim()
|
||||
? voicePackOptions.pack_id
|
||||
: undefined
|
||||
const voicePack = await resolveVoicePackRequest(voicePackOptions, context)
|
||||
const extraOptions: Record<string, unknown> = {}
|
||||
if (pitch != null)
|
||||
extraOptions.pitch = pitch
|
||||
@@ -426,40 +432,49 @@ async function voicePackRequestOptions(
|
||||
|
||||
return {
|
||||
extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined,
|
||||
costMultiplier,
|
||||
voicePackId,
|
||||
costMultiplier: voicePack?.costMultiplier ?? 1,
|
||||
voicePackId: voicePack?.id,
|
||||
model: voicePack?.ttsModelId,
|
||||
voice: voicePack?.upstreamVoiceId,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveVoicePackCostMultiplier(
|
||||
async function resolveVoicePackRequest(
|
||||
voicePackOptions: Record<string, unknown> | undefined,
|
||||
context: {
|
||||
model: string
|
||||
requestedModel: string
|
||||
voice?: string
|
||||
voicePackService: VoicePackService
|
||||
},
|
||||
): Promise<number> {
|
||||
): Promise<Awaited<ReturnType<VoicePackService['findById']>> | null> {
|
||||
const packId = voicePackOptions?.pack_id
|
||||
const value = voicePackOptions?.cost_multiplier
|
||||
if (packId == null && value == null)
|
||||
return 1
|
||||
if (voicePackOptions?.cost_multiplier != null) {
|
||||
throw createBadRequestError('voice_pack.cost_multiplier is server-managed', 'INVALID_VOICE_PACK', {
|
||||
field: 'voice_pack.cost_multiplier',
|
||||
})
|
||||
}
|
||||
if (packId == null)
|
||||
return null
|
||||
if (typeof packId !== 'string' || !packId.trim())
|
||||
throw createBadRequestError('voice_pack.pack_id is required when Voice Pack billing metadata is provided', 'INVALID_VOICE_PACK')
|
||||
|
||||
const pack = await context.voicePackService.findById(packId)
|
||||
if (!pack)
|
||||
throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId })
|
||||
if (pack.ttsModelId !== context.model || pack.voiceId !== context.voice) {
|
||||
if (context.requestedModel !== 'auto' && pack.ttsModelId !== context.requestedModel) {
|
||||
throw createBadRequestError('Voice Pack does not match requested model and voice', 'INVALID_VOICE_PACK', {
|
||||
packId,
|
||||
actualModel: context.requestedModel,
|
||||
})
|
||||
}
|
||||
if (pack.voiceId !== context.voice) {
|
||||
throw createBadRequestError('Voice Pack does not match requested model and voice', 'INVALID_VOICE_PACK', {
|
||||
packId,
|
||||
expectedModel: pack.ttsModelId,
|
||||
actualModel: context.model,
|
||||
expectedVoice: pack.voiceId,
|
||||
actualVoice: context.voice,
|
||||
})
|
||||
}
|
||||
|
||||
return pack.costMultiplier
|
||||
return pack
|
||||
}
|
||||
|
||||
function routerFailure(error: unknown): { status: number, reason: string, message: string } {
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('voicePackService', () => {
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-neuro',
|
||||
upstreamVoiceId: 'voice-neuro-upstream',
|
||||
ttsModelId: 'volcengine/neuro-pool',
|
||||
params: { pitch: '+20%', volume: '+5%' },
|
||||
costMultiplier: 1.5,
|
||||
@@ -37,6 +38,7 @@ describe('voicePackService', () => {
|
||||
expect(pack.provider).toBe('volcengine')
|
||||
expect(pack.model).toBe('seed-tts-2.0')
|
||||
expect(pack.voiceId).toBe('voice-neuro')
|
||||
expect(pack.upstreamVoiceId).toBe('voice-neuro-upstream')
|
||||
expect(pack.ttsModelId).toBe('volcengine/neuro-pool')
|
||||
expect(pack.params).toEqual({ pitch: '+20%', volume: '+5%' })
|
||||
expect(pack.costMultiplier).toBe(1.5)
|
||||
@@ -50,6 +52,7 @@ describe('voicePackService', () => {
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-a',
|
||||
upstreamVoiceId: 'voice-a-upstream',
|
||||
ttsModelId: 'volcengine/pool',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
@@ -60,6 +63,7 @@ describe('voicePackService', () => {
|
||||
provider: 'volcengine',
|
||||
model: 'seed-tts-2.0',
|
||||
voiceId: 'voice-a',
|
||||
upstreamVoiceId: 'voice-a-upstream',
|
||||
ttsModelId: 'volcengine/pool',
|
||||
params: { pitch: '+20%' },
|
||||
costMultiplier: 1,
|
||||
@@ -78,6 +82,7 @@ describe('voicePackService', () => {
|
||||
provider: 'azure',
|
||||
model: 'v1',
|
||||
voiceId: 'en-US-AvaMultilingualNeural',
|
||||
upstreamVoiceId: 'en-US-AvaMultilingualNeural',
|
||||
ttsModelId: 'microsoft/v1',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
@@ -103,6 +108,7 @@ describe('voicePackService', () => {
|
||||
provider: 'dashscope-cosyvoice',
|
||||
model: 'cosyvoice-v2',
|
||||
voiceId: 'longxiaochun_v2',
|
||||
upstreamVoiceId: 'longxiaochun_v2',
|
||||
ttsModelId: 'alibaba/cosyvoice-v2',
|
||||
params: {},
|
||||
costMultiplier: 1,
|
||||
|
||||
@@ -24,6 +24,7 @@ export const CreateVoicePackInputSchema = object({
|
||||
provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)),
|
||||
model: pipe(string(), nonEmpty('model is required'), maxLength(200)),
|
||||
voiceId: pipe(string(), nonEmpty('voiceId is required'), maxLength(200)),
|
||||
upstreamVoiceId: pipe(string(), nonEmpty('upstreamVoiceId is required'), maxLength(200)),
|
||||
ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)),
|
||||
params: optional(VoicePackParamsSchema, {}),
|
||||
costMultiplier: VoicePackCostMultiplierSchema,
|
||||
@@ -36,6 +37,7 @@ export const UpdateVoicePackInputSchema = object({
|
||||
provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))),
|
||||
model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))),
|
||||
voiceId: optional(pipe(string(), nonEmpty('voiceId must not be empty'), maxLength(200))),
|
||||
upstreamVoiceId: optional(pipe(string(), nonEmpty('upstreamVoiceId must not be empty'), maxLength(200))),
|
||||
ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))),
|
||||
params: optional(VoicePackParamsSchema),
|
||||
costMultiplier: optional(VoicePackCostMultiplierSchema),
|
||||
@@ -74,6 +76,7 @@ export function createVoicePackService(db: Database) {
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
voiceId: input.voiceId,
|
||||
upstreamVoiceId: input.upstreamVoiceId,
|
||||
ttsModelId: input.ttsModelId,
|
||||
params: input.params,
|
||||
costMultiplier: input.costMultiplier,
|
||||
|
||||
Reference in New Issue
Block a user