diff --git a/apps/server/src/routes/admin/voice-packs/route.test.ts b/apps/server/src/routes/admin/voice-packs/route.test.ts index bfb6f8511..e692b7e99 100644 --- a/apps/server/src/routes/admin/voice-packs/route.test.ts +++ b/apps/server/src/routes/admin/voice-packs/route.test.ts @@ -42,6 +42,7 @@ function createService() { disable: vi.fn(async (id: string): Promise => makePack({ id, enabled: false })), listEnabled: vi.fn(), findById: vi.fn(), + findEnabledByVoiceId: vi.fn(), } satisfies VoicePackService } @@ -111,7 +112,7 @@ describe('admin voice packs — CRUD', () => { }) it('creates a pack with validated fields', async () => { - // @example valid body -> route forwards normalized params and enabled default. + // @example valid body -> route forwards canonical numeric params and enabled default. const service = createService() const productEventService = createProductEventService() const app = createTestApp(service, ADMIN, productEventService) @@ -122,7 +123,7 @@ describe('admin voice packs — CRUD', () => { voiceId: 'voice-neuro', upstreamVoiceId: 'voice-neuro-upstream', ttsModelId: 'volcengine/neuro-pool', - params: { pitch: '+20%' }, + params: { pitch: 20 }, costMultiplier: 1.5, } const res = await jsonRequest(app, 'POST', '/api/admin/voice-packs', body) diff --git a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts index d886fa117..c54a1a2ae 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts @@ -1,3 +1,4 @@ +import type { VoicePack } from '../../../../../schemas/voice-packs' import type { V1RouteDeps } from '../../types' import { useLogger } from '@guiiai/logg' @@ -5,6 +6,18 @@ import { ofetch } from 'ofetch' import { createBadGatewayError, createBadRequestError, createServiceUnavailableError } from '../../../../../utils/error' +function voicePackCatalogVoice(pack: VoicePack) { + const cost = `Flux cost: ${pack.costMultiplier}x` + return { + id: pack.voiceId, + name: pack.name, + description: pack.description ? `${pack.description} · ${cost}` : cost, + labels: { type: 'voice_pack' }, + tags: ['voice_pack'], + languages: [{ code: 'en', title: 'English' }], + } +} + export interface SpeechCatalogOperation { listSpeechModels: () => Promise listStreamingSpeechModels: () => Promise @@ -43,12 +56,13 @@ export function createSpeechCatalogOperation(deps: V1RouteDeps): SpeechCatalogOp : requested const voices = await deps.llmRouter.listTtsVoices(model) + const voicePacks = await deps.voicePackService.listEnabled() const recommended = (await deps.configKV.getOptional('DEFAULT_TTS_VOICES'))?.[model] ?? {} // Debug level: high-frequency catalog poll from UI selectors, no // billing / user-facing side effect — useful only when debugging // voice-picker drift, never as a permanent audit trail line. - logger.withFields({ model, voiceCount: voices.length }).debug('list tts voices') - return Response.json({ voices, recommended }) + logger.withFields({ model, voiceCount: voices.length, voicePackCount: voicePacks.length }).debug('list tts voices') + return Response.json({ voices: [...voicePacks.map(voicePackCatalogVoice), ...voices], recommended }) } /** diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index a311d5c6b..3ab439f93 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -150,6 +150,7 @@ function createMockVoicePackService(impl?: Partial): VoicePack update: vi.fn(), disable: vi.fn(), findById: vi.fn(async () => null), + findEnabledByVoiceId: vi.fn(async () => null), ...impl, } as unknown as VoicePackService } @@ -686,9 +687,9 @@ describe('v1CompletionsRoutes', () => { /** * @example - * POST /api/v1/audio/speech { "speed": 1.2, "extra_body": { "voice_pack": { "pitch": 20 } } } + * POST /api/v1/audio/speech { "model": "auto", "voice": "friendly-azure" } */ - it('forwards TTS speed and Voice Pack prosody options to the router input', async () => { + it('resolves Voice Pack aliases to server-owned model, voice, and params', async () => { const routeTts = vi.fn(async () => new Response(new Uint8Array([1]), { status: 200, headers: { 'Content-Type': 'audio/mpeg' }, @@ -704,7 +705,7 @@ describe('v1CompletionsRoutes', () => { createMockLlmTracing(), createMockProductEventService(), createMockVoicePackService({ - findById: vi.fn(async () => ({ + findEnabledByVoiceId: vi.fn(async () => ({ id: 'vp-azure', name: 'Azure', description: null, @@ -713,7 +714,7 @@ describe('v1CompletionsRoutes', () => { voiceId: 'friendly-azure', upstreamVoiceId: 'en-US-AvaMultilingualNeural', ttsModelId: 'microsoft/v1', - params: {}, + params: { pitch: 20, volume: 5, rate: 1.2 }, costMultiplier: 1.5, enabled: true, createdAt: new Date(), @@ -730,14 +731,6 @@ describe('v1CompletionsRoutes', () => { model: 'auto', input: 'test', voice: 'friendly-azure', - speed: 1.2, - extra_body: { - voice_pack: { - pack_id: 'vp-azure', - pitch: 20, - volume: 5, - }, - }, }), }), { user: testUser } as any, @@ -784,7 +777,7 @@ describe('v1CompletionsRoutes', () => { /** * @example - * POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "pack_id": "vp-premium" } } } + * POST /api/v1/audio/speech { "input": "hello", "voice": "alloy" } */ it('uses Voice Pack cost multiplier for affordability and billing units', async () => { globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), { @@ -794,7 +787,7 @@ describe('v1CompletionsRoutes', () => { const ttsMeter = createMockTtsMeter() const voicePackService = createMockVoicePackService({ - findById: vi.fn(async () => ({ + findEnabledByVoiceId: vi.fn(async () => ({ id: 'vp-premium', name: 'Premium', description: null, @@ -805,7 +798,7 @@ describe('v1CompletionsRoutes', () => { ttsModelId: 'tts-1', params: {}, costMultiplier: 2, - enabled: false, + enabled: true, createdAt: new Date(), updatedAt: new Date(), })), @@ -830,11 +823,6 @@ describe('v1CompletionsRoutes', () => { model: 'auto', input: 'hello', voice: 'alloy', - extra_body: { - voice_pack: { - pack_id: 'vp-premium', - }, - }, }), }), { user: testUser } as any, @@ -851,7 +839,7 @@ describe('v1CompletionsRoutes', () => { /** * @example - * POST /api/v1/audio/speech { "voice": "alloy", "extra_body": { "voice_pack": { "pack_id": "vp-premium" } } } + * POST /api/v1/audio/speech { "voice": "alloy" } */ it('records TTS voice and Voice Pack metadata in product events', async () => { globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), { @@ -861,7 +849,7 @@ describe('v1CompletionsRoutes', () => { const productEventService = createMockProductEventService() const voicePackService = createMockVoicePackService({ - findById: vi.fn(async () => ({ + findEnabledByVoiceId: vi.fn(async () => ({ id: 'vp-premium', name: 'Premium', description: null, @@ -898,12 +886,9 @@ describe('v1CompletionsRoutes', () => { input: 'hello', voice: 'alloy', extra_body: { - voice_pack: { - pack_id: 'vp-premium', - }, airi_analytics: { source: 'manual_preview', - voice_type: 'voice_pack', + voice_type: 'official_selected', }, }, }), @@ -1404,6 +1389,58 @@ describe('v1CompletionsRoutes', () => { expect(llmRouter.listTtsVoices).toHaveBeenCalledWith('microsoft/v1') }) + it('includes enabled Voice Packs as official catalog voices without upstream details', async () => { + const llmRouter = createMockLlmRouter({ + listTtsVoices: vi.fn(async () => [ + { id: 'en-US-AvaMultilingualNeural', name: 'Ava', languages: [{ code: 'en-US', title: 'English' }] }, + ]) as any, + }) + const voicePackService = createMockVoicePackService({ + listEnabled: vi.fn(async () => [{ + id: 'vp-1', + name: 'Narrator', + description: 'Warm voice', + provider: 'azure', + model: 'microsoft/v1', + voiceId: 'narrator-alias', + upstreamVoiceId: 'en-US-AvaMultilingualNeural', + ttsModelId: 'microsoft/v1', + params: {}, + costMultiplier: 2, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }]), + }) + const app = createTestApp( + createMockFluxService(), + createMockConfigKV({ DEFAULT_TTS_VOICES: { 'microsoft/v1': { 'en-US': 'en-US-AvaMultilingualNeural' } } }), + undefined, + undefined, + undefined, + llmRouter, + createMockLlmTracing(), + createMockProductEventService(), + voicePackService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/voices?model=microsoft/v1', { method: 'GET' }), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + const data = await res.json() as { voices: Array> } + expect(data.voices[0]).toMatchObject({ + id: 'narrator-alias', + name: 'Narrator', + description: 'Warm voice · Flux cost: 2x', + }) + expect(data.voices[0]).not.toHaveProperty('upstreamVoiceId') + expect(data.voices[0]).not.toHaveProperty('ttsModelId') + expect(data.voices[1]).toMatchObject({ id: 'en-US-AvaMultilingualNeural' }) + }) + it('returns an empty recommended map when the resolved model has no bucket', async () => { const llmRouter = createMockLlmRouter({ listTtsVoices: vi.fn(async () => []) as any, diff --git a/apps/server/src/routes/voice-packs/route.test.ts b/apps/server/src/routes/voice-packs/route.test.ts index b0fae11c5..3702602ee 100644 --- a/apps/server/src/routes/voice-packs/route.test.ts +++ b/apps/server/src/routes/voice-packs/route.test.ts @@ -32,7 +32,7 @@ function createService() { voiceId: 'friendly-voice', upstreamVoiceId: 'en-US-AvaMultilingualNeural', ttsModelId: 'microsoft/v1', - params: { pitch: '+10%' }, + params: { pitch: 10 }, costMultiplier: 2, enabled: true, createdAt: new Date('2026-01-01T00:00:00.000Z'), @@ -43,6 +43,7 @@ function createService() { update: vi.fn(), disable: vi.fn(), findById: vi.fn(), + findEnabledByVoiceId: vi.fn(), } as unknown as VoicePackService } @@ -69,7 +70,7 @@ describe('voice packs routes', () => { name: 'Enabled', description: 'Public description', voiceId: 'friendly-voice', - params: { pitch: '+10%' }, + params: { pitch: 10 }, costMultiplier: 2, enabled: true, createdAt: '2026-01-01T00:00:00.000Z', diff --git a/apps/server/src/schemas/voice-packs.ts b/apps/server/src/schemas/voice-packs.ts index 26ae54787..0d0c587d9 100644 --- a/apps/server/src/schemas/voice-packs.ts +++ b/apps/server/src/schemas/voice-packs.ts @@ -4,7 +4,11 @@ import { boolean, jsonb, pgTable, real, text, timestamp } from 'drizzle-orm/pg-c import { nanoid } from '../utils/id' -export type VoicePackParams = Record +export interface VoicePackParams { + pitch?: number + volume?: number + rate?: number +} export const voicePacks = pgTable( 'voice_packs', diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 63eee7f75..0193ad41f 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -175,7 +175,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { const ttsInput = { text: inputText, voice: routedVoice, - speed: typeof input.body.speed === 'number' ? input.body.speed : undefined, + speed: voicePackRequest.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, } @@ -418,6 +418,7 @@ async function voicePackRequestOptions( voicePackId?: string model?: string voice?: string + speed?: number }> { const extraBody = asRecord(body.extra_body) const voicePackOptions = asRecord(extraBody?.voice_pack) @@ -425,10 +426,12 @@ async function voicePackRequestOptions( const volume = readOptionalNumber(voicePackOptions, 'volume') const voicePack = await resolveVoicePackRequest(voicePackOptions, context) const extraOptions: Record = {} - if (pitch != null) - extraOptions.pitch = pitch - if (volume != null) - extraOptions.volume = volume + const resolvedPitch = voicePack?.params.pitch ?? pitch + const resolvedVolume = voicePack?.params.volume ?? volume + if (resolvedPitch != null) + extraOptions.pitch = resolvedPitch + if (resolvedVolume != null) + extraOptions.volume = resolvedVolume return { extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined, @@ -436,6 +439,7 @@ async function voicePackRequestOptions( voicePackId: voicePack?.id, model: voicePack?.ttsModelId, voice: voicePack?.upstreamVoiceId, + speed: voicePack?.params.rate, } } @@ -448,31 +452,27 @@ async function resolveVoicePackRequest( }, ): Promise> | null> { const packId = voicePackOptions?.pack_id + const requestedVoice = context.voice?.trim() 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()) + if (packId != null && (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) + const pack = typeof packId === 'string' + ? await context.voicePackService.findById(packId) + : requestedVoice + ? await context.voicePackService.findEnabledByVoiceId(requestedVoice) + : null + if (!pack && packId == null) + return null + if (!pack) throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId }) - 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, - actualVoice: context.voice, - }) - } + if (!pack.enabled) + throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId }) return pack } diff --git a/apps/server/src/services/domain/voice-packs/index.test.ts b/apps/server/src/services/domain/voice-packs/index.test.ts index 851c076df..5fa5c623f 100644 --- a/apps/server/src/services/domain/voice-packs/index.test.ts +++ b/apps/server/src/services/domain/voice-packs/index.test.ts @@ -29,7 +29,7 @@ describe('voicePackService', () => { voiceId: 'voice-neuro', upstreamVoiceId: 'voice-neuro-upstream', ttsModelId: 'volcengine/neuro-pool', - params: { pitch: '+20%', volume: '+5%' }, + params: { pitch: 20, volume: 5 }, costMultiplier: 1.5, enabled: true, }) @@ -40,7 +40,7 @@ describe('voicePackService', () => { 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.params).toEqual({ pitch: 20, volume: 5 }) expect(pack.costMultiplier).toBe(1.5) expect(pack.enabled).toBe(true) }) @@ -65,7 +65,7 @@ describe('voicePackService', () => { voiceId: 'voice-a', upstreamVoiceId: 'voice-a-upstream', ttsModelId: 'volcengine/pool', - params: { pitch: '+20%' }, + params: { pitch: 20 }, costMultiplier: 1, enabled: true, }) @@ -91,13 +91,13 @@ describe('voicePackService', () => { const updated = await service.update(pack.id, { name: 'New', - params: { rate: '+10%' }, + params: { rate: 1.1 }, costMultiplier: 2, }) expect(updated?.id).toBe(pack.id) expect(updated?.name).toBe('New') - expect(updated?.params).toEqual({ rate: '+10%' }) + expect(updated?.params).toEqual({ rate: 1.1 }) expect(updated?.costMultiplier).toBe(2) }) @@ -124,6 +124,37 @@ describe('voicePackService', () => { expect(enabled).toEqual([]) }) + it('finds only enabled packs by product-facing voice alias', async () => { + // @example TTS request voice="narrator" -> enabled Voice Pack row resolves server-side. + await service.create({ + name: 'Disabled narrator', + provider: 'azure', + model: 'v1', + voiceId: 'narrator', + upstreamVoiceId: 'disabled-upstream', + ttsModelId: 'microsoft/v1', + params: {}, + costMultiplier: 1, + enabled: false, + }) + const enabled = await service.create({ + name: 'Enabled narrator', + provider: 'azure', + model: 'v1', + voiceId: 'narrator', + upstreamVoiceId: 'enabled-upstream', + ttsModelId: 'microsoft/v1', + params: {}, + costMultiplier: 1, + enabled: true, + }) + + expect(await service.findEnabledByVoiceId('narrator')).toMatchObject({ + id: enabled.id, + upstreamVoiceId: 'enabled-upstream', + }) + }) + it('returns null when updating or disabling a missing pack', async () => { // @example unknown id -> null so routes can map to 404. expect(await service.update('missing', { name: 'Nope' })).toBeNull() diff --git a/apps/server/src/services/domain/voice-packs/index.ts b/apps/server/src/services/domain/voice-packs/index.ts index efa8c472c..3892d3642 100644 --- a/apps/server/src/services/domain/voice-packs/index.ts +++ b/apps/server/src/services/domain/voice-packs/index.ts @@ -4,14 +4,15 @@ import type { Database } from '../../../libs/db' import type { VoicePack } from '../../../schemas/voice-packs' import { and, eq } from 'drizzle-orm' -import { boolean, maxLength, minValue, nonEmpty, null_, number, object, optional, pipe, record, string, union } from 'valibot' +import { boolean, maxLength, minValue, nonEmpty, number, object, optional, pipe, string } from 'valibot' import * as schema from '../../../schemas/voice-packs' -export const VoicePackParamsSchema = record( - pipe(string(), nonEmpty('params keys must not be empty'), maxLength(100)), - union([string(), number(), boolean(), null_()]), -) +export const VoicePackParamsSchema = object({ + pitch: optional(number()), + volume: optional(number()), + rate: optional(pipe(number(), minValue(0.01, 'rate must be positive'))), +}) export const VoicePackCostMultiplierSchema = pipe( number(), @@ -105,6 +106,15 @@ export function createVoicePackService(db: Database) { }) }, + async findEnabledByVoiceId(voiceId: string) { + return await db.query.voicePacks.findFirst({ + where: and( + eq(schema.voicePacks.voiceId, voiceId), + eq(schema.voicePacks.enabled, true), + ), + }) + }, + async update(id: string, input: UpdateVoicePackInput): Promise { const [updated] = await db.update(schema.voicePacks) .set({ ...input, updatedAt: new Date() }) diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 0ad31dbbf..5ff112364 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -170,7 +170,9 @@ export interface AdminRouterConfigCurrent { } export interface VoicePackParams { - [key: string]: string | number | boolean | null + pitch?: number + volume?: number + rate?: number } export interface VoicePack { diff --git a/apps/ui-admin/src/pages/VoicePackFormPage.vue b/apps/ui-admin/src/pages/VoicePackFormPage.vue index b725acb12..5e6399bc6 100644 --- a/apps/ui-admin/src/pages/VoicePackFormPage.vue +++ b/apps/ui-admin/src/pages/VoicePackFormPage.vue @@ -272,9 +272,10 @@ function parseParams(): VoicePackParams { throw new Error('Params keys must not be empty') if (!supportedParams.has(key)) throw new Error(`Unsupported Voice Pack parameter "${key}"`) - const valid = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value == null - if (!valid) - throw new Error(`Unsupported params value for "${key}"`) + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new Error(`Voice Pack parameter "${key}" must be a finite number`) + if (key === 'rate' && value <= 0) + throw new Error('Voice Pack parameter "rate" must be positive') } return parsed as VoicePackParams @@ -350,7 +351,7 @@ async function testVoicePack() { model: form.ttsModelId.trim(), input: text, voice: form.upstreamVoiceId.trim(), - speed: normalizeRateOption(params.rate), + speed: params.rate, extra_body: voicePackExtraBody(params), } const blob = await adminApi.testSpeech(body) @@ -366,13 +367,11 @@ async function testVoicePack() { } function voicePackExtraBody(params: VoicePackParams) { - const pitch = normalizePercentOption(params.pitch, 'pitch') - const volume = normalizePercentOption(params.volume, 'volume') const voicePack: Record = {} - if (pitch != null) - voicePack.pitch = pitch - if (volume != null) - voicePack.volume = volume + if (params.pitch != null) + voicePack.pitch = params.pitch + if (params.volume != null) + voicePack.volume = params.volume return Object.keys(voicePack).length > 0 ? { voice_pack: voicePack } : undefined } @@ -411,51 +410,6 @@ function voiceOptionDescription(voice: SpeechVoice): string | undefined { function firstRecommendedVoiceId(recommended: Record): string | undefined { return recommended['zh-CN'] ?? recommended['en-US'] ?? Object.values(recommended)[0] } - -function normalizePercentOption(value: string | number | boolean | null | undefined, name: string): number | undefined { - if (value == null) - return undefined - if (typeof value === 'number') { - if (Number.isFinite(value)) - return value - throw new Error(`Voice Pack parameter "${name}" must be a finite number.`) - } - if (typeof value !== 'string') - throw new Error(`Voice Pack parameter "${name}" must be a number or percent string.`) - - const trimmed = value.trim() - const normalized = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed - const parsed = Number(normalized) - if (!Number.isFinite(parsed)) - throw new Error(`Voice Pack parameter "${name}" must be a number or percent string.`) - return parsed -} - -function normalizeRateOption(value: string | number | boolean | null | undefined): number | undefined { - if (value == null) - return undefined - if (typeof value === 'number') { - if (Number.isFinite(value) && value > 0) - return value - throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.') - } - if (typeof value !== 'string') - throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.') - - const trimmed = value.trim() - if (trimmed.endsWith('%')) { - const percent = normalizePercentOption(trimmed, 'rate') - const speed = 1 + (percent ?? 0) / 100 - if (speed > 0) - return speed - throw new Error('Voice Pack parameter "rate" percent must resolve to a positive speed.') - } - - const parsed = Number(trimmed) - if (Number.isFinite(parsed) && parsed > 0) - return parsed - throw new Error('Voice Pack parameter "rate" must be a positive finite number or percent string.') -}