feat(server): generate official tts voice previews

This commit is contained in:
RainbowBird
2026-07-01 22:34:56 +08:00
parent 87baf622c9
commit feec4da1fa
6 changed files with 159 additions and 3 deletions
@@ -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)
@@ -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',
},
})
})
})
@@ -278,6 +278,7 @@ function createMockOfficialCatalogService(impl?: Partial<OfficialCatalogService>
}),
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',
@@ -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<OfficialTtsVoiceWithModel | null> {
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<OfficialTtsVoice | null> {
const [updated] = await db.update(officialTtsVoices)
.set({ ...input, updatedAt: new Date() })
+5
View File
@@ -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),
}),
}
+29 -1
View File
@@ -15,6 +15,7 @@ const loadingModels = shallowRef(false)
const loadingVoices = shallowRef(false)
const syncingModels = shallowRef(false)
const syncingVoices = shallowRef(false)
const generatingPreviewVoiceId = shallowRef<string | null>(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<Pick<Official
}
}
async function generatePreview(voice: OfficialTtsVoice) {
generatingPreviewVoiceId.value = voice.id
try {
const result = await adminApi.generateOfficialTtsVoicePreview(voice.id)
voices.value = voices.value.map(item => 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) }}
</td>
<td>
<input :class="['h-8', 'w-full', 'min-w-48', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="voice.previewAudioUrl ?? ''" placeholder="https://..." @change="event => updateVoice(voice, { previewAudioUrl: (event.target as HTMLInputElement).value || null })">
<div :class="['flex', 'min-w-64', 'items-center', 'gap-2']">
<input :class="['h-8', 'min-w-0', 'flex-1', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" :value="voice.previewAudioUrl ?? ''" placeholder="https://..." @change="event => updateVoice(voice, { previewAudioUrl: (event.target as HTMLInputElement).value || null })">
<audio v-if="voice.previewAudioUrl" :src="voice.previewAudioUrl" controls :class="['h-8', 'w-36']" />
<Button
v-else
:disabled="generatingPreviewVoiceId === voice.id"
:icon="generatingPreviewVoiceId === voice.id ? 'i-lucide-loader-2 animate-spin' : 'i-lucide-wand-sparkles'"
label="Generate"
size="sm"
variant="secondary"
@click="generatePreview(voice)"
/>
</div>
</td>
<td>
<input :class="['h-8', 'w-20', 'rounded-md', 'border', 'border-neutral-200', 'bg-white', 'px-2', 'text-sm']" min="0" type="number" :value="voice.displayOrder" @change="event => updateVoice(voice, { displayOrder: Number((event.target as HTMLInputElement).value) })">