feat(server): generate official tts voice previews
This commit is contained in:
@@ -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() })
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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) })">
|
||||
|
||||
Reference in New Issue
Block a user