diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index bd7653ad8..47a644ce3 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -209,7 +209,10 @@ export async function buildApp(deps: AppDeps) { if (!session?.user) return createUnauthorizedWsEvents() - return audioSpeechWsSetup(session.user.id) + return audioSpeechWsSetup(session.user.id, { + trigger: c.req.query('tts_trigger') === 'auto' ? 'auto' : 'manual', + source: parseTtsSource(c.req.query('tts_source'), 'audio.speech.ws'), + }) })) // Cross-instance config invalidation. The subscriber owns its own @@ -429,6 +432,20 @@ export async function buildApp(deps: AppDeps) { return { app: builtApp, injectWebSocket } } +function parseTtsSource( + value: string | undefined, + fallback: 'audio.speech.ws', +): 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' { + switch (value) { + case 'chat_auto_tts': + case 'manual_preview': + case 'settings_test': + return value + default: + return fallback + } +} + export type AppType = Awaited>['app'] export async function createApp() { diff --git a/apps/server/src/routes/audio-speech-ws/index.ts b/apps/server/src/routes/audio-speech-ws/index.ts index 924e38259..79bd18839 100644 --- a/apps/server/src/routes/audio-speech-ws/index.ts +++ b/apps/server/src/routes/audio-speech-ws/index.ts @@ -1,5 +1,6 @@ import type { WSEvents } from 'hono/ws' +import type { AudioSpeechSessionAnalytics } from './session' import type { AudioSpeechWsHandlersOptions } from './types' import { useLogger } from '@guiiai/logg' @@ -31,8 +32,8 @@ export type { AudioSpeechWsHandlersOptions } from './types' * peer registry because streaming TTS is single-session per connection. */ export function createAudioSpeechWsHandlers(opts: AudioSpeechWsHandlersOptions) { - return function setupPeer(userId: string): WSEvents { - const sessionState = createSessionState(userId, opts) + return function setupPeer(userId: string, analytics?: AudioSpeechSessionAnalytics): WSEvents { + const sessionState = createSessionState(userId, opts, analytics) return { onOpen(_event, ws) { diff --git a/apps/server/src/routes/audio-speech-ws/route.test.ts b/apps/server/src/routes/audio-speech-ws/route.test.ts index 6d6e677c9..44235d7f6 100644 --- a/apps/server/src/routes/audio-speech-ws/route.test.ts +++ b/apps/server/src/routes/audio-speech-ws/route.test.ts @@ -152,7 +152,10 @@ function makeFakeDeps(overrides: { decryptedKey?: string }) { const ttsMeter = { - assertCanAfford: vi.fn(async () => undefined), + assertCanAfford: vi.fn(async (_userId: string, _newUnits: number, currentBalance: number) => { + if (currentBalance <= 0) + throw Object.assign(new Error('Insufficient flux'), { statusCode: 402 }) + }), accumulate: vi.fn(async () => ({ fluxDebited: 1, debtAfter: 0, @@ -282,7 +285,7 @@ describe('audio-speech-ws route', () => { upstream = await startMockUpstream([]) const deps = makeFakeDeps({ upstreamURL: upstream.url, fluxBalance: 0 }) const handlers = createAudioSpeechWsHandlers(deps as any) - const events = handlers('user-broke') + const events = handlers('user-broke', { trigger: 'auto', source: 'chat_auto_tts' }) const client = makeMockClientWs() await driveClientSession(events, client, []) @@ -299,6 +302,18 @@ describe('audio-speech-ws route', () => { }) expect(client.closed).toBe(true) expect(client.closeCode).toBe(1008) + expect(deps.productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'user-broke', + feature: 'tts', + action: 'speech_blocked', + status: 'blocked', + source: 'chat_auto_tts', + reason: 'insufficient_balance', + metadata: expect.objectContaining({ + trigger: 'auto', + balance_state: 'insufficient', + }), + })) }) it('refuses with streaming_tts_not_configured when UNSPEECH_UPSTREAM.streaming is empty', async () => { diff --git a/apps/server/src/routes/audio-speech-ws/session.ts b/apps/server/src/routes/audio-speech-ws/session.ts index d086f025d..393998159 100644 --- a/apps/server/src/routes/audio-speech-ws/session.ts +++ b/apps/server/src/routes/audio-speech-ws/session.ts @@ -11,6 +11,7 @@ import WebSocket from 'ws' import { useLogger } from '@guiiai/logg' import { context as otelContext, SpanStatusCode, trace } from '@opentelemetry/api' +import { ApiError } from '../../utils/error' import { nanoid } from '../../utils/id' import { AIRI_ATTR_BILLING_FLUX_CONSUMED, @@ -49,6 +50,14 @@ export interface AudioSpeechSessionState { handleClientClose: () => void } +export type StreamingTtsTrigger = 'auto' | 'manual' +export type StreamingTtsSource = 'audio.speech.ws' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' + +export interface AudioSpeechSessionAnalytics { + trigger?: StreamingTtsTrigger + source?: StreamingTtsSource +} + /** * Creates the per-connection streaming speech state machine. * @@ -63,9 +72,14 @@ export interface AudioSpeechSessionState { * Returns: * - A connection-scoped state object with no global peer registry. */ -export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOptions): AudioSpeechSessionState { +export function createSessionState( + userId: string, + opts: AudioSpeechWsHandlersOptions, + analyticsInput: AudioSpeechSessionAnalytics = {}, +): AudioSpeechSessionState { const requestId = nanoid() const startedAt = Date.now() + const analytics = normalizeAnalytics(analyticsInput) const span = tracer.startSpan('llm.gateway.tts.stream', { attributes: { [AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech_stream', @@ -96,8 +110,11 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp feature: 'tts', action: 'speech_requested', status: 'started', - source: 'audio.speech.ws', + source: analytics.source, model: modelLabel, + metadata: { + trigger: analytics.trigger, + }, }) let unspeech: Awaited> @@ -120,10 +137,6 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp // afford the worst-case session. try { const flux = await opts.fluxService.getFlux(userId) - if (flux.flux <= 0) { - closeWithError(1008, 'insufficient_flux') - return - } await opts.ttsMeter.assertCanAfford(userId, STREAMING_PREFLIGHT_CHARS_ESTIMATE, flux.flux) } catch (err) { @@ -131,7 +144,10 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp // assertCanAfford throws PaymentRequiredError (402) — translate to ws // policy-violation close. The client can read the close code/reason to // surface a 'top up' prompt. - closeWithError(1008, 'insufficient_flux') + if (isPaymentRequiredError(err)) + closeWithBlockedPreflight(1008, 'insufficient_flux') + else + closeWithError(1011, 'flux_preflight_failed') return } @@ -205,11 +221,12 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp feature: 'tts', action: 'speech_failed', status: 'failed', - source: 'audio.speech.ws', + source: analytics.source, model: modelLabel, reason: 'upstream_error', metadata: { duration_ms: Date.now() - startedAt, + trigger: analytics.trigger, }, }) try { @@ -409,12 +426,13 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp feature: 'tts', action: 'speech_succeeded', status: 'succeeded', - source: 'audio.speech.ws', + source: analytics.source, model: modelLabel, metadata: { input_chars: units, duration_ms: durationMs, flux_consumed: fluxConsumed, + trigger: analytics.trigger, }, }) @@ -445,12 +463,46 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp feature: 'tts', action: 'speech_failed', status: 'failed', - source: 'audio.speech.ws', + source: analytics.source, model: modelLabel, reason, metadata: { close_code: code, duration_ms: Date.now() - startedAt, + trigger: analytics.trigger, + }, + }) + if (clientWs) { + try { + clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason })) + } + catch {} + try { + clientWs.close(code, reason) + } + catch {} + } + closed = true + span.end() + } + + function closeWithBlockedPreflight(code: number, reason: string) { + if (closed) + return + void opts.productEventService.track({ + userId, + feature: 'tts', + action: 'speech_blocked', + status: 'blocked', + source: analytics.source, + model: modelLabel, + reason: 'insufficient_balance', + metadata: { + balance_state: 'insufficient', + billing_units: STREAMING_PREFLIGHT_CHARS_ESTIMATE, + close_code: code, + duration_ms: Date.now() - startedAt, + trigger: analytics.trigger, }, }) if (clientWs) { @@ -474,3 +526,35 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp handleClientClose, } } + +function normalizeAnalytics(input: AudioSpeechSessionAnalytics): Required { + return { + trigger: normalizeTrigger(input.trigger), + source: normalizeSource(input.source), + } +} + +function normalizeTrigger(trigger: AudioSpeechSessionAnalytics['trigger']): StreamingTtsTrigger { + return trigger === 'auto' ? 'auto' : 'manual' +} + +function normalizeSource(source: AudioSpeechSessionAnalytics['source']): StreamingTtsSource { + switch (source) { + case 'audio.speech.ws': + case 'chat_auto_tts': + case 'manual_preview': + case 'settings_test': + return source + default: + return 'audio.speech.ws' + } +} + +function isPaymentRequiredError(err: unknown): boolean { + if (err instanceof ApiError) + return err.statusCode === 402 + return typeof err === 'object' + && err != null + && 'statusCode' in err + && (err as { statusCode?: unknown }).statusCode === 402 +} diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index fb484c794..4e050c234 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -75,7 +75,12 @@ function createMockRequestLogService(): RequestLogService { function createMockTtsMeter(unitsPerFlux = 1000) { let debt = 0 return { - assertCanAfford: vi.fn(async () => undefined), + assertCanAfford: vi.fn(async (_userId: string, newUnits: number, currentBalance: number) => { + const projectedFlux = Math.floor((debt + newUnits) / unitsPerFlux) + const required = Math.max(projectedFlux, currentBalance <= 0 ? 1 : 0) + if (currentBalance < required) + throw new ApiError(402, 'PAYMENT_REQUIRED', 'Insufficient flux') + }), accumulate: vi.fn(async ({ units, currentBalance }: { units: number, currentBalance: number }) => { debt += units const fluxDebited = Math.floor(debt / unitsPerFlux) @@ -910,10 +915,18 @@ describe('v1CompletionsRoutes', () => { ) }) - it('should return 402 when flux is insufficient', async () => { + it('returns 402 and records blocked event for manual TTS when flux is insufficient', async () => { + const productEventService = createMockProductEventService() + const llmRouter = createMockLlmRouter() const app = createTestApp( createMockFluxService(0), createMockConfigKV(), + undefined, + undefined, + undefined, + llmRouter, + createMockLlmTracing(), + productEventService, ) const res = await app.fetch( @@ -925,6 +938,63 @@ describe('v1CompletionsRoutes', () => { { user: testUser } as any, ) expect(res.status).toBe(402) + expect(llmRouter.routeTts).not.toHaveBeenCalled() + expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ + action: 'speech_blocked', + status: 'blocked', + source: 'audio.speech', + reason: 'insufficient_balance', + metadata: expect.objectContaining({ + trigger: 'manual', + balance_state: 'insufficient', + }), + })) + }) + + it('returns 204 and records blocked event for auto TTS when flux is insufficient', async () => { + const productEventService = createMockProductEventService() + const llmRouter = createMockLlmRouter() + const app = createTestApp( + createMockFluxService(0), + createMockConfigKV(), + undefined, + undefined, + undefined, + llmRouter, + createMockLlmTracing(), + productEventService, + ) + + const res = await app.fetch( + new Request('http://localhost/api/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'auto', + input: 'hello', + voice: 'alloy', + extra_body: { + airi_analytics: { + trigger: 'auto', + source: 'chat_auto_tts', + }, + }, + }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(204) + expect(llmRouter.routeTts).not.toHaveBeenCalled() + expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ + action: 'speech_blocked', + status: 'blocked', + source: 'chat_auto_tts', + reason: 'insufficient_balance', + metadata: expect.objectContaining({ + trigger: 'auto', + balance_state: 'insufficient', + }), + })) }) it('should not charge when input is empty', async () => { diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 193fb0bad..6e3c7cdf4 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -62,6 +62,13 @@ export interface OpenAiSpeechRequest { abortSignal?: AbortSignal } +type TtsTrigger = 'auto' | 'manual' + +interface TtsAnalyticsContext { + trigger: TtsTrigger + source: 'audio.speech' | 'chat_auto_tts' | 'manual_preview' | 'settings_test' +} + /** * Runs the OpenAI-shaped text-to-speech gateway flow. * @@ -83,6 +90,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { const requestId = nanoid() let requestModel = typeof input.body.model === 'string' ? input.body.model : 'auto' 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') @@ -107,17 +115,50 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { feature: 'tts', action: 'speech_requested', status: 'started', - source: 'audio.speech', + source: analytics.source, model: requestModel, metadata: { input_chars: inputText.length, + trigger: analytics.trigger, }, }) const flux = await deps.fluxService.getFlux(input.userId) - if (flux.flux <= 0) + try { + await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux) + } + catch (err) { + if (!(err instanceof ApiError) || err.statusCode !== 402) + throw err + + void deps.productEventService.track({ + userId: input.userId, + feature: 'tts', + action: 'speech_blocked', + status: 'blocked', + source: analytics.source, + model: requestModel, + reason: 'insufficient_balance', + metadata: { + input_chars: inputText.length, + billing_units: billingUnits, + balance_state: 'insufficient', + trigger: analytics.trigger, + }, + }) + logger.withError(err).withFields({ + requestId, + userId: input.userId, + model: requestModel, + trigger: analytics.trigger, + source: analytics.source, + }).warn('tts speech blocked by pre-flight balance check') + + if (analytics.trigger === 'auto') + return new Response(null, { status: 204 }) + throw createPaymentRequiredError('Insufficient flux') - await deps.ttsMeter.assertCanAfford(input.userId, billingUnits, flux.flux) + } const ttsInput = { text: inputText, @@ -170,13 +211,14 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { feature: 'tts', action: 'speech_failed', status: 'failed', - source: 'audio.speech', + source: analytics.source, model: requestModel, provider: routeCtx.provider, reason: failure.reason, metadata: { http_status: failure.status, duration_ms: Date.now() - startedAt, + trigger: analytics.trigger, }, }) throw err @@ -195,13 +237,14 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { feature: 'tts', action: 'speech_failed', status: 'failed', - source: 'audio.speech', + source: analytics.source, model: requestModel, provider: routeCtx.provider, reason: 'upstream_error', metadata: { http_status: response.status, duration_ms: durationMs, + trigger: analytics.trigger, }, }) logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs }) @@ -243,7 +286,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { feature: 'tts', action: 'speech_succeeded', status: 'succeeded', - source: 'audio.speech', + source: analytics.source, model: requestModel, provider: routeCtx.provider, metadata: { @@ -253,6 +296,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { cost_multiplier: voicePackRequest.costMultiplier, duration_ms: durationMs, flux_consumed: fluxConsumed, + trigger: analytics.trigger, }, }) deps.requestLogService.logRequest({ @@ -300,6 +344,20 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { return { handleSpeechRequest } } +function ttsAnalyticsContext(body: Record): TtsAnalyticsContext { + const extraBody = asRecord(body.extra_body) + const analytics = asRecord(extraBody?.airi_analytics) + const trigger = analytics?.trigger === 'auto' ? 'auto' : 'manual' + const rawSource = analytics?.source + const source = rawSource === 'chat_auto_tts' + || rawSource === 'manual_preview' + || rawSource === 'settings_test' + ? rawSource + : 'audio.speech' + + return { trigger, source } +} + async function voicePackRequestOptions( body: Record, context: { diff --git a/apps/server/src/services/domain/product-events.test.ts b/apps/server/src/services/domain/product-events.test.ts index 5a49956bb..61914d824 100644 --- a/apps/server/src/services/domain/product-events.test.ts +++ b/apps/server/src/services/domain/product-events.test.ts @@ -99,4 +99,38 @@ describe('productEventService', () => { distinctUsers: 2, }]) }) + + it('writes blocked TTS events for server-side preflight decisions', async () => { + const events = { add: vi.fn() } + const service = createProductEventService(db, { events } as unknown as ProductMetrics) + + await service.track({ + userId: 'user-1', + feature: 'tts', + action: 'speech_blocked', + status: 'blocked', + source: 'chat_auto_tts', + reason: 'insufficient_balance', + metadata: { + trigger: 'auto', + balance_state: 'insufficient', + }, + }) + + const rows = await db.select().from(schema.productEvents) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + feature: 'tts', + action: 'speech_blocked', + status: 'blocked', + source: 'chat_auto_tts', + reason: 'insufficient_balance', + }) + expect(events.add).toHaveBeenCalledWith(1, { + feature: 'tts', + action: 'speech_blocked', + status: 'blocked', + source: 'chat_auto_tts', + }) + }) }) diff --git a/apps/server/src/services/domain/product-events.ts b/apps/server/src/services/domain/product-events.ts index 3d836a9ba..e2f76a514 100644 --- a/apps/server/src/services/domain/product-events.ts +++ b/apps/server/src/services/domain/product-events.ts @@ -11,7 +11,7 @@ const logger = useLogger('product-events') export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' | 'voice_pack' -export type ProductEventStatus = 'started' | 'succeeded' | 'failed' +export type ProductEventStatus = 'started' | 'succeeded' | 'failed' | 'blocked' export type ProductAction = | 'user_signed_up' @@ -23,6 +23,7 @@ export type ProductAction | 'speech_requested' | 'speech_succeeded' | 'speech_failed' + | 'speech_blocked' | 'voice_pack_created' | 'voice_pack_updated' | 'voice_pack_disabled' diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 35b3d2b93..3e5add23f 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -430,8 +430,20 @@ const speechPipeline = createSpeechPipeline({ // Non-streaming providers only: synth via REST. Streaming provider // was already early-returned above; it owns its own ws path opened // in `onBeforeMessageComposed`. + const providerConfigWithAnalytics = activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID + ? { + ...speechRequest.providerConfig, + extraBody: { + ...(speechRequest.providerConfig.extraBody as Record | undefined), + airi_analytics: { + trigger: 'auto', + source: 'chat_auto_tts', + }, + }, + } + : speechRequest.providerConfig const res = await generateSpeech({ - ...provider.speech(model, speechRequest.providerConfig), + ...provider.speech(model, providerConfigWithAnalytics), input: speechRequest.input, voice: voice.id, }) diff --git a/packages/stage-ui/src/libs/speech/streaming-pipeline.ts b/packages/stage-ui/src/libs/speech/streaming-pipeline.ts index 83babbfbe..f46116bab 100644 --- a/packages/stage-ui/src/libs/speech/streaming-pipeline.ts +++ b/packages/stage-ui/src/libs/speech/streaming-pipeline.ts @@ -48,6 +48,10 @@ export interface StreamingTtsPipelineOptions extends StreamingTtsPipelineEvents responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'pcm' /** Backend-specific knobs forwarded as the `extra_body` of the `start` frame. */ extraBody?: Record + /** Business trigger hint sent to server-side product analytics. */ + ttsTrigger?: 'auto' | 'manual' + /** Low-cardinality source hint sent to server-side product analytics. */ + ttsSource?: 'chat_auto_tts' | 'manual_preview' | 'settings_test' /** * Decoder context. The pipeline calls `decodeAudioData` on it for each * sentence (or once at session end in buffered mode). Reusing the page's @@ -115,7 +119,10 @@ export function createStreamingTtsPipeline(options: StreamingTtsPipelineOptions) return noopHandle() } - const wsUrl = toWebSocketUrl(options.serverUrl ?? SERVER_URL, '/api/v1/audio/speech/ws', token) + const wsUrl = toWebSocketUrl(options.serverUrl ?? SERVER_URL, '/api/v1/audio/speech/ws', token, { + ttsTrigger: options.ttsTrigger ?? 'auto', + ttsSource: options.ttsSource ?? 'chat_auto_tts', + }) const ws = new WebSocket(wsUrl) ws.binaryType = 'arraybuffer' @@ -400,10 +407,17 @@ function noopHandle(): StreamingTtsPipelineHandle { return { appendText: () => {}, finish: () => {}, cancel: () => {} } } -function toWebSocketUrl(httpBase: string, path: string, token: string): string { +function toWebSocketUrl( + httpBase: string, + path: string, + token: string, + analytics: { ttsTrigger: 'auto' | 'manual', ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test' }, +): string { const u = new URL(path, httpBase) u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:' u.searchParams.set('token', token) + u.searchParams.set('tts_trigger', analytics.ttsTrigger) + u.searchParams.set('tts_source', analytics.ttsSource) return u.toString() } diff --git a/packages/stage-ui/src/libs/speech/streaming-session.ts b/packages/stage-ui/src/libs/speech/streaming-session.ts index 5afcd7020..20aed8581 100644 --- a/packages/stage-ui/src/libs/speech/streaming-session.ts +++ b/packages/stage-ui/src/libs/speech/streaming-session.ts @@ -54,6 +54,10 @@ export interface StreamingTtsSessionOptions { * `section_id`, `context_texts`, etc. */ extraBody?: Record + /** Business trigger hint sent to server-side product analytics. */ + ttsTrigger?: 'auto' | 'manual' + /** Low-cardinality source hint sent to server-side product analytics. */ + ttsSource?: 'chat_auto_tts' | 'manual_preview' | 'settings_test' /** Caller-side abort signal. Closes the ws and rejects with `AbortError`. */ signal?: AbortSignal } @@ -85,7 +89,10 @@ export async function streamingSynthesize(options: StreamingTtsSessionOptions): throw new Error('streaming-tts: not authenticated') const baseUrl = options.serverUrl ?? SERVER_URL - const wsUrl = toWebSocketUrl(baseUrl, '/api/v1/audio/speech/ws', token) + const wsUrl = toWebSocketUrl(baseUrl, '/api/v1/audio/speech/ws', token, { + ttsTrigger: options.ttsTrigger ?? 'manual', + ttsSource: options.ttsSource ?? 'manual_preview', + }) const audioChunks: ArrayBuffer[] = [] const sentences: StreamingTtsSessionResult['sentences'] = [] @@ -225,10 +232,17 @@ export async function streamingSynthesize(options: StreamingTtsSessionOptions): }) } -function toWebSocketUrl(httpBase: string, path: string, token: string): string { +function toWebSocketUrl( + httpBase: string, + path: string, + token: string, + analytics: { ttsTrigger: 'auto' | 'manual', ttsSource: 'chat_auto_tts' | 'manual_preview' | 'settings_test' }, +): string { const u = new URL(path, httpBase) u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:' u.searchParams.set('token', token) + u.searchParams.set('tts_trigger', analytics.ttsTrigger) + u.searchParams.set('tts_source', analytics.ttsSource) return u.toString() } diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index 0a9707a22..2a134f342 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -393,10 +393,15 @@ export const useSpeechStore = defineStore('speech', () => { voice: string, providerConfig: Record = {}, ): Promise { + const requestProviderConfig = activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID + || activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + ? withAiriTtsAnalytics(providerConfig, { + trigger: 'manual', + source: 'manual_preview', + }) + : providerConfig const response = await generateSpeech({ - ...provider.speech(model, { - ...providerConfig, - }), + ...provider.speech(model, requestProviderConfig), input, voice, }) @@ -404,6 +409,19 @@ export const useSpeechStore = defineStore('speech', () => { return response } + function withAiriTtsAnalytics( + providerConfig: Record, + analytics: { trigger: 'auto' | 'manual', source: 'chat_auto_tts' | 'manual_preview' | 'settings_test' }, + ): Record { + return { + ...providerConfig, + extraBody: { + ...(providerConfig.extraBody as Record | undefined), + airi_analytics: analytics, + }, + } + } + function generateSSML( text: string, voice: VoiceInfo,