diff --git a/apps/server/src/routes/admin/official-catalog/index.ts b/apps/server/src/routes/admin/official-catalog/index.ts index 6739514f7..bce744ae3 100644 --- a/apps/server/src/routes/admin/official-catalog/index.ts +++ b/apps/server/src/routes/admin/official-catalog/index.ts @@ -6,13 +6,17 @@ import type { LlmRouterService } from '../../../services/domain/llm-router' import type { OfficialCatalogService } from '../../../services/domain/official-catalog' import type { HonoEnv } from '../../../types/hono' +import { Buffer } from 'node:buffer' + import { Hono } from 'hono' import { any, array, boolean, integer, maxLength, minValue, nullable, number, object, optional, picklist, pipe, record, safeParse, string } from 'valibot' import { adminGuard } from '../../../middlewares/admin-guard' import { authGuard } from '../../../middlewares/auth' import { normalizeProviderVoiceForCatalog } from '../../../services/domain/official-catalog/provider-voices' -import { createBadRequestError, createNotFoundError } from '../../../utils/error' +import { createBadGatewayError, createBadRequestError, createNotFoundError } from '../../../utils/error' + +const DEFAULT_PREVIEW_TEXT = 'Hello, this is an AIRI voice preview.' const SurfaceSchema = picklist(['llm', 'asr']) @@ -55,6 +59,11 @@ const TtsVoiceSyncBodySchema = object({ routerModelId: pipe(string(), maxLength(160)), }) +const TtsVoicePreviewBodySchema = object({ + text: optional(pipe(string(), maxLength(200)), DEFAULT_PREVIEW_TEXT), + responseFormat: optional(pipe(string(), maxLength(24))), +}) + export interface AdminOfficialCatalogRoutesDeps { configKV: ConfigKVService llmRouter: LlmRouterService @@ -176,6 +185,36 @@ export function createAdminOfficialCatalogRoutes(deps: AdminOfficialCatalogRoute const synced = await deps.service.syncTtsVoices({ routerModelId: body.routerModelId, voices }) return c.json({ voices: synced, syncedCount: synced.length }) }) + .post('/tts/voices/:id/preview', async (c) => { + const body = await readBody(c, TtsVoicePreviewBodySchema) + const row = await deps.service.getTtsVoiceWithModel(c.req.param('id')) + if (!row) + throw createNotFoundError('Official TTS voice not found') + + const response = await deps.llmRouter.routeTts({ + modelName: row.model.routerModelId, + input: { + text: body.text || DEFAULT_PREVIEW_TEXT, + voice: row.voice.providerVoiceId, + responseFormat: body.responseFormat, + }, + }) + if (!response.ok) + throw createBadGatewayError(`TTS preview upstream ${response.status}`, { lastStatusCode: response.status }) + + const contentType = response.headers.get('content-type') ?? 'audio/mpeg' + const bytes = await response.arrayBuffer() + const previewAudioUrl = `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}` + const updated = await deps.service.updateTtsVoice(row.voice.id, { previewAudioUrl }) + if (!updated) + throw createNotFoundError('Official TTS voice not found') + + return c.json({ + voice: updated, + contentType, + byteLength: bytes.byteLength, + }) + }) .patch('/tts/voices/:id', async (c) => { const body = await readBody(c, TtsVoiceUpdateBodySchema) const updated = await deps.service.updateTtsVoice(c.req.param('id'), body) diff --git a/apps/server/src/routes/admin/official-catalog/route.test.ts b/apps/server/src/routes/admin/official-catalog/route.test.ts index 72167ec28..b67821764 100644 --- a/apps/server/src/routes/admin/official-catalog/route.test.ts +++ b/apps/server/src/routes/admin/official-catalog/route.test.ts @@ -40,7 +40,10 @@ function createConfigKV(): ConfigKVService { function createLlmRouter(): LlmRouterService { return { route: vi.fn(), - routeTts: vi.fn(), + routeTts: vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + })), listTtsVoices: vi.fn(async () => [ { id: 'en-US-AvaMultilingualNeural', name: 'Ava', previewUrl: 'https://example.com/ava.mp3' }, ]), @@ -69,6 +72,34 @@ function createService(): OfficialCatalogService { }))), listTtsVoices: vi.fn(async () => []), listEnabledTtsVoices: vi.fn(async () => []), + getTtsVoiceWithModel: vi.fn(async () => ({ + model: { + id: 'model-1', + routerModelId: 'microsoft/v1', + provider: 'azure', + displayName: 'Azure', + enabled: false, + displayOrder: 0, + lastSyncedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + voice: { + id: 'voice-1', + ttsModelId: 'model-1', + providerVoiceId: 'en-US-AvaMultilingualNeural', + displayName: 'Ava', + enabled: false, + displayOrder: 0, + languages: [], + labels: {}, + previewAudioUrl: null, + source: 'provider-sync', + lastSyncedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + })), updateTtsVoice: vi.fn(async (_id, input) => ({ id: 'voice-1', ...input })), assertTtsVoiceEnabled: vi.fn(), } as unknown as OfficialCatalogService @@ -153,4 +184,35 @@ describe('admin official catalog routes', () => { expect(res.status).toBe(404) }) + + it('generates and stores a TTS voice preview data URL', async () => { + const service = createService() + const llmRouter = createLlmRouter() + const app = createTestApp({ user: ADMIN, service, llmRouter }) + + const res = await jsonRequest(app, 'POST', '/api/admin/official-catalog/tts/voices/voice-1/preview', { + text: 'Preview this voice.', + }) + + expect(res.status).toBe(200) + expect(llmRouter.routeTts).toHaveBeenCalledWith({ + modelName: 'microsoft/v1', + input: { + text: 'Preview this voice.', + voice: 'en-US-AvaMultilingualNeural', + responseFormat: undefined, + }, + }) + expect(service.updateTtsVoice).toHaveBeenCalledWith('voice-1', { + previewAudioUrl: 'data:audio/mpeg;base64,AQID', + }) + expect(await res.json()).toMatchObject({ + contentType: 'audio/mpeg', + byteLength: 3, + voice: { + id: 'voice-1', + previewAudioUrl: 'data:audio/mpeg;base64,AQID', + }, + }) + }) }) diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 4f030fa1b..8de45a8dc 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -278,6 +278,7 @@ function createMockOfficialCatalogService(impl?: Partial }), listTtsVoices: vi.fn(async () => []), listEnabledTtsVoices: vi.fn(async routerModelId => syncedVoicesByModel.get(routerModelId) ?? []), + getTtsVoiceWithModel: vi.fn(async () => null), assertTtsVoiceEnabled: vi.fn(async (_routerModelId, providerVoiceId) => ({ id: 'tts-voice-1', ttsModelId: 'tts-model-1', diff --git a/apps/server/src/services/domain/official-catalog/index.ts b/apps/server/src/services/domain/official-catalog/index.ts index 7c05eba38..023e5abf9 100644 --- a/apps/server/src/services/domain/official-catalog/index.ts +++ b/apps/server/src/services/domain/official-catalog/index.ts @@ -38,6 +38,11 @@ export interface OfficialProviderAliasWithRoutes extends OfficialProviderAlias { routes: OfficialProviderAliasRoute[] } +export interface OfficialTtsVoiceWithModel { + model: OfficialTtsModel + voice: OfficialTtsVoice +} + export interface OfficialProviderAliasUpdateInput { displayName?: string enabled?: boolean @@ -369,6 +374,22 @@ export function createOfficialCatalogService(db: Database) { }) }, + async getTtsVoiceWithModel(id: string): Promise { + const voice = await db.query.officialTtsVoices.findFirst({ + where: eq(officialTtsVoices.id, id), + }) + if (!voice) + return null + + const model = await db.query.officialTtsModels.findFirst({ + where: eq(officialTtsModels.id, voice.ttsModelId), + }) + if (!model) + return null + + return { model, voice } + }, + async updateTtsVoice(id: string, input: OfficialTtsVoiceUpdateInput): Promise { const [updated] = await db.update(officialTtsVoices) .set({ ...input, updatedAt: new Date() }) diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index e796242de..92f33f9c3 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -542,4 +542,9 @@ export const adminApi = { method: 'PATCH', body: JSON.stringify(body), }), + generateOfficialTtsVoicePreview: (id: string, body: { text?: string, responseFormat?: string } = {}) => + adminFetch<{ voice: OfficialTtsVoice, contentType: string, byteLength: number }>(`/official-catalog/tts/voices/${encodeURIComponent(id)}/preview`, { + method: 'POST', + body: JSON.stringify(body), + }), } diff --git a/apps/ui-admin/src/pages/TtsCatalogPage.vue b/apps/ui-admin/src/pages/TtsCatalogPage.vue index 2498147a7..8893688e5 100644 --- a/apps/ui-admin/src/pages/TtsCatalogPage.vue +++ b/apps/ui-admin/src/pages/TtsCatalogPage.vue @@ -15,6 +15,7 @@ const loadingModels = shallowRef(false) const loadingVoices = shallowRef(false) const syncingModels = shallowRef(false) const syncingVoices = shallowRef(false) +const generatingPreviewVoiceId = shallowRef(null) const enabledModels = computed(() => models.value.filter(model => model.enabled).length) const enabledVoices = computed(() => voices.value.filter(voice => voice.enabled).length) @@ -116,6 +117,21 @@ async function updateVoice(voice: OfficialTtsVoice, patch: Partial item.id === result.voice.id ? result.voice : item) + toast.success('Preview generated') + } + catch (error) { + toast.error(errorMessageFromUnknown(error, 'Failed to generate preview')) + } + finally { + generatingPreviewVoiceId.value = null + } +} + function formatDate(value: string | null): string { if (!value) return 'Never' @@ -253,7 +269,19 @@ function languageSummary(voice: OfficialTtsVoice): string { {{ languageSummary(voice) }} - +
+ +