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:
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user