feat(stage-pages,stage-ui,i18n): add OpenRouter as a speech (TTS) provider (#1302)

* Implement OpenRouter TTS

* Implement suggestions from Gemini code review

* Extract OpenRouter speech logic outside of providers.ts

* Address Gemini's comments

* break loop properly

* refactor to match existing pattern

---------

Co-authored-by-agent: Cursor
This commit is contained in:
monolithic827
2026-03-18 21:49:45 +08:00
committed by GitHub
parent 8b91d441fc
commit 30354e9c75
5 changed files with 372 additions and 1 deletions
@@ -772,6 +772,9 @@ pages:
openrouter:
description: openrouter.ai
title: OpenRouter
openrouter-audio-speech:
description: openrouter.ai
title: OpenRouter
perplexity:
description: perplexity.ai
title: Perplexity
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { SpeechProvider } from '@xsai-ext/providers/utils'
import {
SpeechPlayground,
SpeechProviderSettings,
} from '@proj-airi/stage-ui/components'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const providerId = 'openrouter-audio-speech'
const defaultModel = 'openai/gpt-audio-mini'
const model = computed({
get: () => providers.value[providerId]?.model as string | undefined || defaultModel,
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].model = value
},
})
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
const isLoadingModels = computed(() => providersStore.isLoadingModels[providerId] || false)
const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)
const availableVoices = computed(() => {
return speechStore.availableVoices[providerId] || []
})
onMounted(async () => {
await providersStore.loadModelsForConfiguredProviders()
await providersStore.fetchModelsForProvider(providerId)
await speechStore.loadVoicesForProvider(providerId)
})
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance<SpeechProvider<string>>(providerId)
if (!provider)
throw new Error('Failed to initialize speech provider')
const providerConfig = providersStore.getProviderConfig(providerId)
const modelToUse = model.value || defaultModel
return await speechStore.speech(
provider,
modelToUse,
input,
voiceId,
providerConfig,
)
}
</script>
<template>
<SpeechProviderSettings :provider-id="providerId" :default-model="defaultModel">
<template #voice-settings>
<FieldSelect
v-model="model"
label="Model"
description="Select the audio-capable model to use for speech generation"
:options="providerModels.map(m => ({ value: m.id, label: m.name }))"
:disabled="isLoadingModels || providerModels.length === 0"
placeholder="Select a model..."
/>
</template>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
default-text="Hello! This is a test of OpenRouter Speech."
/>
</template>
</SpeechProviderSettings>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -131,7 +131,14 @@ export const useSpeechStore = defineStore('speech', () => {
watch(
() => providersStore.configuredSpeechProvidersMetadata.map(provider => provider.id),
(configuredProviderIds) => {
if (!activeSpeechProvider.value)
if (!activeSpeechProvider.value || activeSpeechProvider.value === 'speech-noop')
return
// NOTICE: only reset when the provider has actually been validated and found unconfigured.
// Skip reset if validation hasn't run yet (validatedCredentialHash is undefined)
// to avoid a race condition where immediate watcher fires before async validation completes.
const runtimeState = providersStore.providerRuntimeState[activeSpeechProvider.value]
if (runtimeState && runtimeState.validatedCredentialHash === undefined)
return
// NOTICE: clear stale selection when the currently selected speech provider
@@ -55,6 +55,7 @@ import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './prov
import { convertProviderDefinitionsToMetadata } from './providers/converters'
import { models as elevenLabsModels } from './providers/elevenlabs/list-models'
import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder'
import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech'
import { createWebSpeechAPIProvider } from './providers/web-speech-api'
const ALIYUN_NLS_REGIONS = [
@@ -1359,6 +1360,7 @@ export const useProvidersStore = defineStore('providers', () => {
},
},
},
'openrouter-audio-speech': buildOpenRouterAudioSpeechProvider(v => baseUrlValidator.value(v)),
'comet-api-speech': buildOpenAICompatibleProvider({
id: 'comet-api-speech',
name: 'CometAPI Speech',
@@ -2207,6 +2209,7 @@ export const useProvidersStore = defineStore('providers', () => {
deleteProvider,
availableProviders,
configuredProviders,
providerRuntimeState,
providerMetadata,
getProviderMetadata,
getTranscriptionFeatures,
@@ -0,0 +1,267 @@
import type { SpeechProvider } from '@xsai-ext/providers/utils'
import type { ModelInfo, ProviderMetadata, VoiceInfo } from '../../providers'
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1/'
const DEFAULT_MODEL = 'openai/gpt-audio-mini'
const PROVIDER_ID = 'openrouter-audio-speech'
function TTS_PROMPT_TEMPLATE(input: string) {
return `Read this text aloud exactly as written, without any commentary or extra words:\n\n${input}`
}
const OPENAI_VOICES = [
'alloy',
'ash',
'ballad',
'coral',
'echo',
'fable',
'onyx',
'nova',
'sage',
'shimmer',
'verse',
'marin',
'cedar',
] as const
function normalizeBaseUrl(value: unknown): string {
let base = typeof value === 'string' ? value.trim() : ''
if (!base)
base = DEFAULT_BASE_URL
if (!base.endsWith('/'))
base += '/'
return base
}
function normalizeApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/**
* Collects base64-encoded audio chunks from an SSE stream where each
* `data:` line is a chat-completion delta containing `delta.audio.data`.
*/
async function collectAudioChunksFromSSE(body: ReadableStream<Uint8Array>): Promise<string[]> {
const reader = body.getReader()
const decoder = new TextDecoder()
const audioDataChunks: string[] = []
let buffer = ''
let done = false
while (!done) {
const result = await reader.read()
if (result.done)
break
buffer += decoder.decode(result.value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()!
for (const line of lines) {
if (!line.startsWith('data: '))
continue
const data = line.slice('data: '.length).trim()
if (data === '[DONE]') {
done = true
break
}
try {
const chunk = JSON.parse(data)
const audio = chunk.choices?.[0]?.delta?.audio
if (audio?.data)
audioDataChunks.push(audio.data)
}
catch (e) {
console.warn('Skipping malformed SSE chunk from OpenRouter audio stream:', data, e)
}
}
}
return audioDataChunks
}
/** Decodes concatenated base64 strings into a raw PCM byte array. */
function decodeBase64PCM(chunks: string[]): Uint8Array {
const binaryString = atob(chunks.join(''))
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++)
bytes[i] = binaryString.charCodeAt(i)
return bytes
}
/** Wraps raw PCM16 mono data in a minimal WAV container. */
function wrapPCM16InWAV(pcmBytes: Uint8Array, sampleRate = 24000): Uint8Array {
const numChannels = 1
const bitsPerSample = 16
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
const blockAlign = numChannels * (bitsPerSample / 8)
const header = new ArrayBuffer(44)
const view = new DataView(header)
const writeStr = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++)
view.setUint8(offset + i, str.charCodeAt(i))
}
writeStr(0, 'RIFF')
view.setUint32(4, 36 + pcmBytes.length, true)
writeStr(8, 'WAVE')
writeStr(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, byteRate, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, bitsPerSample, true)
writeStr(36, 'data')
view.setUint32(40, pcmBytes.length, true)
const wav = new Uint8Array(44 + pcmBytes.length)
wav.set(new Uint8Array(header), 0)
wav.set(pcmBytes, 44)
return wav
}
/**
* Custom fetch adapter that translates an OpenAI-compatible TTS request
* into an OpenRouter streaming chat-completion with audio modality,
* then returns the assembled WAV as a standard Response.
*/
function createAudioFetch(apiKey: string, baseUrl: string, model: string) {
return async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
if (!init?.body || typeof init.body !== 'string')
throw new Error('Invalid request body')
const body = JSON.parse(init.body)
const sseResponse = await globalThis.fetch(new URL('chat/completions', baseUrl), {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [
{ role: 'user', content: TTS_PROMPT_TEMPLATE(body.input) },
],
modalities: ['text', 'audio'],
audio: { voice: body.voice, format: 'pcm16' },
stream: true,
}),
})
if (!sseResponse.ok) {
const errorText = await sseResponse.text()
throw new Error(`OpenRouter audio request failed: ${sseResponse.status} ${errorText}`)
}
const audioChunks = await collectAudioChunksFromSSE(sseResponse.body!)
const pcmBytes = decodeBase64PCM(audioChunks)
const wavBytes = wrapPCM16InWAV(pcmBytes)
return new Response(new Blob([wavBytes.buffer as ArrayBuffer], { type: 'audio/wav' }), {
status: 200,
headers: { 'Content-Type': 'audio/wav' },
})
}
}
function createSpeechProvider(apiKey: string, baseUrl: string): SpeechProvider {
return {
speech: (model?: string) => {
const resolvedModel = model || DEFAULT_MODEL
return {
baseURL: baseUrl,
model: resolvedModel,
fetch: createAudioFetch(apiKey, baseUrl, resolvedModel),
}
},
}
}
async function listModels(baseUrl: string): Promise<ModelInfo[]> {
const res = await fetch(`${baseUrl}models?output_modality=audio`)
if (!res.ok)
return []
const json = await res.json()
const models = json.data || []
return models.map((m: { id: string, name?: string, description?: string, context_length?: number }) => ({
id: m.id,
name: m.name || m.id,
provider: PROVIDER_ID,
description: m.description || '',
contextLength: m.context_length || 0,
deprecated: false,
} satisfies ModelInfo))
}
function listVoices(): VoiceInfo[] {
return OPENAI_VOICES.map(id => ({
id,
name: id.charAt(0).toUpperCase() + id.slice(1),
provider: PROVIDER_ID,
languages: [],
} satisfies VoiceInfo))
}
export function buildOpenRouterAudioSpeechProvider(
baseUrlValidator: (baseUrl: unknown) => { errors: unknown[], reason: string, valid: boolean } | null | undefined,
): ProviderMetadata {
return {
id: PROVIDER_ID,
category: 'speech',
tasks: ['text-to-speech'],
nameKey: 'settings.pages.providers.provider.openrouter-audio-speech.title',
name: 'OpenRouter',
descriptionKey: 'settings.pages.providers.provider.openrouter-audio-speech.description',
description: 'openrouter.ai',
icon: 'i-lobe-icons:openrouter',
defaultOptions: () => ({
baseUrl: DEFAULT_BASE_URL,
}),
createProvider: async (config) => {
const apiKey = normalizeApiKey(config.apiKey)
const baseUrl = normalizeBaseUrl(config.baseUrl)
return createSpeechProvider(apiKey, baseUrl)
},
capabilities: {
listModels: async (config: Record<string, unknown>) => {
try {
return await listModels(normalizeBaseUrl(config.baseUrl))
}
catch (error) {
console.error('Failed to fetch OpenRouter audio models:', error)
return []
}
},
listVoices: async () => listVoices(),
},
validators: {
validateProviderConfig: (config) => {
const errors: Error[] = []
if (!config.apiKey)
errors.push(new Error('API Key is required.'))
if (config.baseUrl) {
const res = baseUrlValidator(config.baseUrl)
if (res)
return res
}
return {
errors,
reason: errors.map(e => e.message).join(', '),
valid: errors.length === 0,
}
},
},
}
}