mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
refactor(providers): now simplified
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { AliyunRealtimeSpeechExtraOptions } from '../../../../stores/providers/aliyun/stream-transcription'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createAliyunNLSProvider } from '../../../../stores/providers/aliyun/stream-transcription'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const aliyunNlsRegions = [
|
||||
'cn-shanghai',
|
||||
'cn-shanghai-internal',
|
||||
'cn-beijing',
|
||||
'cn-beijing-internal',
|
||||
'cn-shenzhen',
|
||||
'cn-shenzhen-internal',
|
||||
] as const
|
||||
|
||||
const aliyunNlsConfigSchema = z.object({
|
||||
accessKeyId: z.string(),
|
||||
accessKeySecret: z.string(),
|
||||
appKey: z.string(),
|
||||
region: z.enum(aliyunNlsRegions).default('cn-shanghai'),
|
||||
})
|
||||
|
||||
type AliyunNlsConfig = z.input<typeof aliyunNlsConfigSchema>
|
||||
|
||||
export const providerAliyunNlsTranscription = defineProvider<AliyunNlsConfig>({
|
||||
id: 'aliyun-nls-transcription',
|
||||
name: 'Aliyun NLS',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.aliyun-nls.title'),
|
||||
description: 'nls-console.aliyun.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.aliyun-nls.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'],
|
||||
icon: 'i-lobe-icons:alibabacloud',
|
||||
capabilities: {
|
||||
transcription: {
|
||||
protocol: 'websocket',
|
||||
generateOutput: false,
|
||||
streamOutput: true,
|
||||
streamInput: true,
|
||||
},
|
||||
},
|
||||
createProviderConfig: () => aliyunNlsConfigSchema,
|
||||
createProvider(config) {
|
||||
const accessKeyId = config.accessKeyId.trim()
|
||||
const accessKeySecret = config.accessKeySecret.trim()
|
||||
const appKey = config.appKey.trim()
|
||||
if (!accessKeyId || !accessKeySecret || !appKey)
|
||||
throw new Error('Aliyun NLS credentials are incomplete.')
|
||||
|
||||
const provider = createAliyunNLSProvider(accessKeyId, accessKeySecret, appKey, {
|
||||
region: config.region ?? 'cn-shanghai',
|
||||
})
|
||||
|
||||
return {
|
||||
transcription: (model: string, extraOptions?: AliyunRealtimeSpeechExtraOptions) => provider.speech(model, {
|
||||
...extraOptions,
|
||||
sessionOptions: {
|
||||
format: 'pcm',
|
||||
sample_rate: 16000,
|
||||
enable_punctuation_prediction: true,
|
||||
enable_intermediate_result: true,
|
||||
enable_words: true,
|
||||
...extraOptions?.sessionOptions,
|
||||
},
|
||||
}),
|
||||
} as TranscriptionProviderWithExtraOptions<string, AliyunRealtimeSpeechExtraOptions>
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.accessKeyId?.trim() && config.accessKeySecret?.trim() && config.appKey?.trim()),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'aliyun-nls:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
if (!config.accessKeyId?.trim())
|
||||
errors.push({ error: new Error('Access Key ID is required.') })
|
||||
if (!config.accessKeySecret?.trim())
|
||||
errors.push({ error: new Error('Access Key Secret is required.') })
|
||||
if (!config.appKey?.trim())
|
||||
errors.push({ error: new Error('App Key is required.') })
|
||||
if (config.region && !aliyunNlsRegions.includes(config.region))
|
||||
errors.push({ error: new Error('Region is invalid.') })
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.map(item => (item.error as Error).message).join(', '),
|
||||
reasonKey: '',
|
||||
valid: errors.length === 0,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async () => [{
|
||||
id: 'aliyun-nls-v1',
|
||||
name: 'Aliyun NLS Realtime',
|
||||
provider: 'aliyun-nls-transcription',
|
||||
description: 'Realtime streaming transcription using Aliyun NLS.',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
}],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createWebSpeechAPIProvider } from '../../../../stores/providers/web-speech-api'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const webSpeechApiConfigSchema = z.object({
|
||||
language: z.string().default('en-US'),
|
||||
continuous: z.boolean().default(true),
|
||||
interimResults: z.boolean().default(true),
|
||||
maxAlternatives: z.number().int().positive().default(1),
|
||||
})
|
||||
|
||||
function isWebSpeechApiAvailable() {
|
||||
if (typeof window === 'undefined')
|
||||
return false
|
||||
|
||||
return 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window
|
||||
}
|
||||
|
||||
export const providerBrowserWebSpeechApi = defineProvider({
|
||||
id: 'browser-web-speech-api',
|
||||
name: 'Web Speech API (Browser)',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.browser-web-speech-api.title'),
|
||||
description: 'Browser-native speech recognition. No API keys.',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.browser-web-speech-api.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'],
|
||||
icon: 'i-solar:microphone-bold-duotone',
|
||||
requiresCredentials: false,
|
||||
capabilities: {
|
||||
transcription: {
|
||||
protocol: 'http',
|
||||
generateOutput: false,
|
||||
streamOutput: true,
|
||||
streamInput: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Electron uses Chromium, but it does not include the Google API keys that
|
||||
// the Web Speech API needs. The provider only works in browser contexts.
|
||||
isAvailableBy: () => !isStageTamagotchi() && isWebSpeechApiAvailable(),
|
||||
createProviderConfig: () => webSpeechApiConfigSchema,
|
||||
createProvider: createWebSpeechAPIProvider,
|
||||
validationRequiredWhen: () => false,
|
||||
|
||||
extraMethods: {
|
||||
listModels: async () => [
|
||||
{
|
||||
id: 'web-speech-api',
|
||||
name: 'Web Speech API',
|
||||
provider: 'browser-web-speech-api',
|
||||
description: 'Browser-native speech recognition (no API keys required)',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'browser-web-speech-api:check-availability',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async () => {
|
||||
const valid = isWebSpeechApiAvailable()
|
||||
return {
|
||||
errors: valid
|
||||
? []
|
||||
: [{ error: new Error('Web Speech API is not available. It requires a browser context with SpeechRecognition support (Chrome, Edge, Safari).') }],
|
||||
reason: valid ? '' : 'Web Speech API is not available in this environment.',
|
||||
reasonKey: '',
|
||||
valid,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createChatProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
|
||||
import { createChatProvider, createModelProvider, createSpeechProvider, createTranscriptionProvider, merge } from '@xsai-ext/providers/utils'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ProviderValidationCheck } from '../../types'
|
||||
@@ -55,3 +55,50 @@ export const providerCometAPI = defineProvider<CometApiConfig>({
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const providerCometAPISpeech = defineProvider<CometApiConfig>({
|
||||
id: 'comet-api-speech',
|
||||
name: 'CometAPI Speech',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.comet-api.title'),
|
||||
description: 'cometapi.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.comet-api.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:cometapi',
|
||||
createProviderConfig: providerCometAPI.createProviderConfig,
|
||||
createProvider(config) {
|
||||
return merge(
|
||||
createModelProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
createSpeechProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
)
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim()),
|
||||
validators: createOpenAICompatibleValidators({ checks: [ProviderValidationCheck.ModelList] }),
|
||||
})
|
||||
|
||||
export const providerCometAPITranscription = defineProvider<CometApiConfig>({
|
||||
id: 'comet-api-transcription',
|
||||
name: 'CometAPI Transcription',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.comet-api.title'),
|
||||
description: 'cometapi.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.comet-api.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
icon: 'i-lobe-icons:cometapi',
|
||||
capabilities: {
|
||||
transcription: { protocol: 'http', generateOutput: true, streamOutput: false, streamInput: false },
|
||||
},
|
||||
createProviderConfig: providerCometAPI.createProviderConfig,
|
||||
createProvider(config) {
|
||||
const provider = merge(
|
||||
createModelProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
createTranscriptionProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
)
|
||||
const transcription = provider.transcription.bind(provider)
|
||||
provider.transcription = (model: string, extraOptions?: Record<string, unknown>) => ({
|
||||
...transcription(model),
|
||||
...extraOptions,
|
||||
})
|
||||
return provider
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim()),
|
||||
validators: createOpenAICompatibleValidators({ checks: [ProviderValidationCheck.ModelList] }),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
import type { ListVoicesOptions, UnElevenLabsOptions, VoiceProviderWithExtraOptions } from 'unspeech'
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import { createUnElevenLabs, listVoices } from 'unspeech'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { models as elevenLabsModels } from '../../../../stores/providers/elevenlabs/list-models'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const elevenLabsConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default('https://unspeech.hyp3r.link/v1/'),
|
||||
voiceSettings: z.object({
|
||||
similarityBoost: z.number().default(0.75),
|
||||
stability: z.number().default(0.5),
|
||||
}).default({ similarityBoost: 0.75, stability: 0.5 }),
|
||||
})
|
||||
|
||||
type ElevenLabsConfig = z.input<typeof elevenLabsConfigSchema>
|
||||
|
||||
function toListVoicesOptions(provider: VoiceProviderWithExtraOptions<UnElevenLabsOptions>): ListVoicesOptions {
|
||||
const { fetch: _fetch, ...voiceOptions } = provider.voice()
|
||||
return voiceOptions
|
||||
}
|
||||
|
||||
function createElevenLabsValidators() {
|
||||
return {
|
||||
validateConfig: [
|
||||
({ t }: { t: ComposerTranslation }) => ({
|
||||
id: 'elevenlabs:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config: ElevenLabsConfig) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
const apiKey = config.apiKey?.trim() ?? ''
|
||||
const baseUrl = config.baseUrl?.trim() ?? ''
|
||||
|
||||
if (!apiKey)
|
||||
errors.push({ error: new Error('API key is required.') })
|
||||
|
||||
if (!baseUrl) {
|
||||
errors.push({ error: new Error('Base URL is required.') })
|
||||
}
|
||||
else {
|
||||
try {
|
||||
if (!new URL(baseUrl).host)
|
||||
errors.push({ error: new Error('Base URL is not absolute. Try to include a scheme (http:// or https://).') })
|
||||
else if (!baseUrl.endsWith('/'))
|
||||
errors.push({ error: new Error('Base URL must end with a trailing slash (/).') })
|
||||
}
|
||||
catch {
|
||||
errors.push({ error: new Error('Base URL is not absolute. Try to include a scheme (http:// or https://).') })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.map(item => (item.error as Error).message).join(', '),
|
||||
reasonKey: '',
|
||||
valid: errors.length === 0,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const providerElevenLabs = defineProvider<ElevenLabsConfig>({
|
||||
id: 'elevenlabs',
|
||||
name: 'ElevenLabs',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.elevenlabs.title'),
|
||||
description: 'elevenlabs.io',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.elevenlabs.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-simple-icons:elevenlabs',
|
||||
|
||||
createProviderConfig: ({ t }) => elevenLabsConfigSchema.extend({
|
||||
apiKey: elevenLabsConfigSchema.shape.apiKey.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'),
|
||||
type: 'password',
|
||||
}),
|
||||
baseUrl: elevenLabsConfigSchema.shape.baseUrl.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createUnElevenLabs(config.apiKey.trim(), config.baseUrl?.trim() ?? 'https://unspeech.hyp3r.link/v1/') as SpeechProviderWithExtraOptions<string, UnElevenLabsOptions>
|
||||
},
|
||||
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createElevenLabsValidators(),
|
||||
extraMethods: {
|
||||
listModels: async () => elevenLabsModels.map(model => ({
|
||||
id: model.model_id,
|
||||
name: model.name,
|
||||
provider: 'elevenlabs',
|
||||
description: model.description,
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
})),
|
||||
listVoices: async (config) => {
|
||||
const provider = createUnElevenLabs(config.apiKey.trim(), config.baseUrl?.trim() ?? 'https://unspeech.hyp3r.link/v1/') as VoiceProviderWithExtraOptions<UnElevenLabsOptions>
|
||||
const voices = await listVoices(toListVoicesOptions(provider))
|
||||
if (!Array.isArray(voices))
|
||||
return []
|
||||
|
||||
// Keep the default ElevenLabs voices together at the end of the list.
|
||||
const ariaIndex = voices.findIndex(voice => voice.name.includes('Aria'))
|
||||
const billIndex = voices.findIndex(voice => voice.name.includes('Bill'))
|
||||
const startIndex = ariaIndex !== -1 ? ariaIndex : 0
|
||||
const endIndex = billIndex !== -1 ? billIndex : voices.length - 1
|
||||
const lowerIndex = Math.min(startIndex, endIndex)
|
||||
const higherIndex = Math.max(startIndex, endIndex)
|
||||
const rearrangedVoices = [
|
||||
...voices.slice(0, lowerIndex),
|
||||
...voices.slice(higherIndex + 1),
|
||||
...voices.slice(lowerIndex, higherIndex + 1),
|
||||
]
|
||||
|
||||
return rearrangedVoices.map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
provider: 'elevenlabs',
|
||||
previewURL: voice.preview_audio_url,
|
||||
languages: voice.languages,
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { toWavFromPCM16 } from '@proj-airi/audio/encoding'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/'
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash-preview-tts'
|
||||
|
||||
const googleGeminiSpeechConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default(DEFAULT_BASE_URL),
|
||||
})
|
||||
|
||||
type GoogleGeminiSpeechConfig = z.input<typeof googleGeminiSpeechConfigSchema>
|
||||
|
||||
const googleGeminiTtsModels = [
|
||||
'gemini-2.5-flash-preview-tts',
|
||||
'gemini-2.5-pro-preview-tts',
|
||||
'gemini-3.1-flash-tts-preview',
|
||||
] as const
|
||||
|
||||
const googleGeminiTtsVoices: Array<[string, string]> = [
|
||||
['Zephyr', 'Bright'],
|
||||
['Puck', 'Upbeat'],
|
||||
['Charon', 'Informative'],
|
||||
['Kore', 'Firm'],
|
||||
['Fenrir', 'Excitable'],
|
||||
['Leda', 'Youthful'],
|
||||
['Orus', 'Firm'],
|
||||
['Aoede', 'Breezy'],
|
||||
['Callirrhoe', 'Easy-going'],
|
||||
['Autonoe', 'Bright'],
|
||||
['Enceladus', 'Breathy'],
|
||||
['Iapetus', 'Clear'],
|
||||
['Umbriel', 'Easy-going'],
|
||||
['Algieba', 'Smooth'],
|
||||
['Despina', 'Smooth'],
|
||||
['Erinome', 'Clear'],
|
||||
['Algenib', 'Gravelly'],
|
||||
['Rasalgethi', 'Informative'],
|
||||
['Laomedeia', 'Upbeat'],
|
||||
['Achernar', 'Soft'],
|
||||
['Alnilam', 'Firm'],
|
||||
['Schedar', 'Even'],
|
||||
['Gacrux', 'Mature'],
|
||||
['Pulcherrima', 'Forward'],
|
||||
['Achird', 'Friendly'],
|
||||
['Zubenelgenubi', 'Casual'],
|
||||
['Vindemiatrix', 'Gentle'],
|
||||
['Sadachbia', 'Lively'],
|
||||
['Sadaltager', 'Knowledgeable'],
|
||||
['Sulafat', 'Warm'],
|
||||
]
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined) {
|
||||
const value = baseUrl?.trim() || DEFAULT_BASE_URL
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
function decodeBase64(base64: string) {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++)
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function createAudioFetch(apiKey: string, baseUrl: string) {
|
||||
return async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!init?.body || typeof init.body !== 'string')
|
||||
throw new Error('Invalid request body')
|
||||
|
||||
const body = JSON.parse(init.body) as {
|
||||
input?: string
|
||||
model?: string
|
||||
voice?: string
|
||||
temperature?: number
|
||||
}
|
||||
if (!body.input)
|
||||
throw new Error('Missing input text for Gemini TTS')
|
||||
if (!body.model)
|
||||
throw new Error('Missing model for Gemini TTS')
|
||||
|
||||
const response = await globalThis.fetch(new URL(`models/${body.model}:generateContent`, baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'x-goog-api-key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: body.input }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ['AUDIO'],
|
||||
speechConfig: {
|
||||
voiceConfig: { prebuiltVoiceConfig: { voiceName: body.voice || 'Kore' } },
|
||||
},
|
||||
...(body.temperature !== undefined ? { temperature: body.temperature } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(`Gemini TTS request failed: ${response.status} ${await response.text().catch(() => '')}`)
|
||||
|
||||
const data = await response.json() as {
|
||||
candidates?: Array<{ content?: { parts?: Array<{ inlineData?: { data?: string } }> } }>
|
||||
}
|
||||
const audio = data.candidates?.[0]?.content?.parts?.find(part => part.inlineData)?.inlineData?.data
|
||||
if (!audio)
|
||||
throw new Error('Gemini TTS response missing audio data')
|
||||
|
||||
return new Response(toWavFromPCM16(decodeBase64(audio), 24000), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const providerGoogleGeminiAudioSpeech = defineProvider<GoogleGeminiSpeechConfig>({
|
||||
id: 'google-gemini-audio-speech',
|
||||
name: 'Google Gemini',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.google-gemini-audio-speech.title'),
|
||||
description: 'aistudio.google.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.google-gemini-audio-speech.description'),
|
||||
tasks: ['text-to-speech', 'tts'],
|
||||
icon: 'i-lobe-icons:gemini',
|
||||
iconColor: 'i-lobe-icons:gemini-color',
|
||||
createProviderConfig: () => googleGeminiSpeechConfigSchema,
|
||||
createProvider(config) {
|
||||
const apiKey = config.apiKey.trim()
|
||||
const baseUrl = normalizeBaseUrl(config.baseUrl)
|
||||
return {
|
||||
speech: (model?: string, options?: Record<string, unknown>) => ({
|
||||
baseURL: baseUrl,
|
||||
fetch: createAudioFetch(apiKey, baseUrl),
|
||||
...options,
|
||||
model: model || (typeof options?.model === 'string' ? options.model : DEFAULT_MODEL),
|
||||
}),
|
||||
}
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim()),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'google-gemini-audio-speech:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const valid = Boolean(config.apiKey?.trim())
|
||||
return {
|
||||
errors: valid ? [] : [{ error: new Error('API Key is required.') }],
|
||||
reason: valid ? '' : 'API Key is required.',
|
||||
reasonKey: '',
|
||||
valid,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async () => googleGeminiTtsModels.map(id => ({
|
||||
id,
|
||||
name: id.split('-').map(word => `${word[0].toUpperCase()}${word.slice(1)}`).join(' '),
|
||||
provider: 'google-gemini-audio-speech',
|
||||
description: 'Gemini API text-to-speech model',
|
||||
capabilities: ['text-to-speech'],
|
||||
})),
|
||||
listVoices: async () => googleGeminiTtsVoices.map(([id, style]) => ({
|
||||
id,
|
||||
name: id,
|
||||
provider: 'google-gemini-audio-speech',
|
||||
description: style,
|
||||
languages: [{ code: 'auto', title: 'Auto' }],
|
||||
compatibleModels: [...googleGeminiTtsModels],
|
||||
})),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const indexTtsConfigSchema = z.object({
|
||||
baseUrl: z.string().default('http://localhost:11996/tts/'),
|
||||
model: z.string().default('IndexTTS-1.5'),
|
||||
})
|
||||
|
||||
type IndexTtsConfig = z.input<typeof indexTtsConfigSchema>
|
||||
|
||||
function voicesUrl(config: IndexTtsConfig) {
|
||||
return `${config.baseUrl ?? 'http://localhost:11996/tts/'}audio/voices`
|
||||
}
|
||||
|
||||
export const providerIndexTtsVllm = defineProvider<IndexTtsConfig>({
|
||||
id: 'index-tts-vllm',
|
||||
name: 'Index-TTS by Bilibili',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.index-tts-vllm.title'),
|
||||
description: 'index-tts.github.io',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.index-tts-vllm.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
iconColor: 'i-lobe-icons:bilibiliindex',
|
||||
createProviderConfig: () => indexTtsConfigSchema,
|
||||
createProvider(config) {
|
||||
return {
|
||||
speech: () => ({
|
||||
baseURL: config.baseUrl ?? 'http://localhost:11996/tts/',
|
||||
model: config.model || 'IndexTTS-1.5',
|
||||
}),
|
||||
}
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.baseUrl?.trim()),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'index-tts-vllm:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const baseUrl = config.baseUrl?.trim() ?? ''
|
||||
if (!baseUrl) {
|
||||
const reason = 'Base URL is required. Default to http://localhost:11996/tts/ for Index-TTS.'
|
||||
return { errors: [{ error: new Error(reason) }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const response = await fetch(voicesUrl(config), { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
const reason = `IndexTTS unreachable: HTTP ${response.status} ${response.statusText}`
|
||||
return { errors: [{ error: new Error(reason) }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const reason = `IndexTTS connection failed: ${errorMessageFrom(error) ?? 'Unknown error'}`
|
||||
return { errors: [{ error }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
return { errors: [], reason: '', reasonKey: '', valid: true }
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async () => [{
|
||||
id: 'IndexTTS-1.5',
|
||||
name: 'IndexTTS-1.5',
|
||||
provider: 'index-tts-vllm',
|
||||
description: 'Default model for Index-TTS vLLM deployment',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
}],
|
||||
listVoices: async (config) => {
|
||||
const response = await fetch(voicesUrl(config))
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch voices: ${response.statusText}`)
|
||||
|
||||
const voices = await response.json() as Record<string, unknown>
|
||||
return Object.keys(voices).map(voice => ({
|
||||
id: voice,
|
||||
name: voice,
|
||||
provider: 'index-tts-vllm',
|
||||
languages: [{ code: 'cn', title: 'Chinese' }, { code: 'en', title: 'English' }],
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,21 +1,30 @@
|
||||
import './amazon-bedrock'
|
||||
import './openai'
|
||||
import './openai-audio'
|
||||
import './aihubmix'
|
||||
import './aliyun-nls'
|
||||
import './lm-studio'
|
||||
import './local-audio'
|
||||
import './index-tts-vllm'
|
||||
import './kokoro-local'
|
||||
import './azure-openai'
|
||||
import './openai-compatible'
|
||||
import './atlascloud'
|
||||
import './volcengine-coding-plan'
|
||||
import './byteplus'
|
||||
import './byteplus-coding-plan'
|
||||
import './browser-web-speech-api'
|
||||
import './n1n'
|
||||
import './openpaths'
|
||||
import './openrouter-ai'
|
||||
import './openrouter-audio-speech'
|
||||
import './nvidia'
|
||||
import './groq'
|
||||
import './anthropic'
|
||||
import './google-generative-ai'
|
||||
import './google-gemini-audio-speech'
|
||||
import './deepseek'
|
||||
import './elevenlabs'
|
||||
import './302-ai'
|
||||
import './cerebras-ai'
|
||||
import './together-ai'
|
||||
@@ -26,15 +35,20 @@ import './fireworks-ai'
|
||||
import './featherless-ai'
|
||||
import './comet-api'
|
||||
import './perplexity-ai'
|
||||
import './player2-speech'
|
||||
import './minimax'
|
||||
import './minimax-speech'
|
||||
import './mistral-ai'
|
||||
import './moonshot-ai'
|
||||
import './modelscope'
|
||||
import './ollama'
|
||||
import './mimo'
|
||||
import './mimo-audio'
|
||||
import './cloudflare-workers-ai'
|
||||
import './azure-ai-foundry'
|
||||
import './official'
|
||||
import './speech-noop'
|
||||
import './unspeech'
|
||||
|
||||
export {
|
||||
getDefaultStreamingModel,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ProgressInfo } from '@xsai-transformers/shared/types'
|
||||
|
||||
import type { VoiceKey } from '../../../../workers/kokoro/types'
|
||||
|
||||
import { getCachedWebGPUCapabilities } from '@proj-airi/stage-shared/webgpu'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../../../../workers/kokoro/constants'
|
||||
import { getKokoroAdapter } from '../../../inference/adapters/kokoro'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
interface KokoroVoice {
|
||||
language: string
|
||||
name: string
|
||||
gender: string
|
||||
}
|
||||
|
||||
const languageByCode: Record<string, { code: string, title: string }> = {
|
||||
'en-us': { code: 'en-US', title: 'English (US)' },
|
||||
'en-gb': { code: 'en-GB', title: 'English (UK)' },
|
||||
'ja': { code: 'ja', title: 'Japanese' },
|
||||
'zh-cn': { code: 'zh-CN', title: 'Chinese (Mandarin)' },
|
||||
'es': { code: 'es', title: 'Spanish' },
|
||||
'fr': { code: 'fr', title: 'French' },
|
||||
'hi': { code: 'hi', title: 'Hindi' },
|
||||
'it': { code: 'it', title: 'Italian' },
|
||||
'pt-br': { code: 'pt-BR', title: 'Portuguese (Brazil)' },
|
||||
}
|
||||
|
||||
function getWebGpuState() {
|
||||
const capabilities = getCachedWebGPUCapabilities()
|
||||
return {
|
||||
supported: capabilities?.supported ?? (typeof navigator !== 'undefined' && Boolean(navigator.gpu)),
|
||||
fp16Supported: capabilities?.fp16Supported ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function getModel(modelId: string) {
|
||||
return KOKORO_MODELS.find(model => model.id === modelId)
|
||||
}
|
||||
|
||||
function assertModelSupported(modelId: string) {
|
||||
const model = getModel(modelId)
|
||||
if (!model)
|
||||
throw new Error(`Invalid model: ${modelId}. Must be one of: ${KOKORO_MODELS.map(item => item.id).join(', ')}`)
|
||||
|
||||
if (model.platform === 'webgpu' && !getWebGpuState().supported)
|
||||
throw new Error('WebGPU is required for this model but is not available in your browser')
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
function progressInfo(progress: { file?: string, percent: number, loaded?: number, total?: number }): ProgressInfo {
|
||||
return {
|
||||
name: progress.file ?? '',
|
||||
file: progress.file ?? '',
|
||||
progress: progress.percent >= 0 ? progress.percent : 0,
|
||||
status: 'progress',
|
||||
loaded: progress.loaded ?? 0,
|
||||
total: progress.total ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
let lastLoadedModelId: string | null = null
|
||||
|
||||
export const providerKokoroLocal = defineProvider({
|
||||
id: 'kokoro-local',
|
||||
name: 'Kokoro TTS',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.kokoro-local.title'),
|
||||
description: 'Local text-to-speech using Kokoro-82M.',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.kokoro-local.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:speaker',
|
||||
requiresCredentials: false,
|
||||
createProviderConfig: () => {
|
||||
const capabilities = getWebGpuState()
|
||||
return z.object({
|
||||
model: z.string().default(getDefaultKokoroModel(capabilities.supported, capabilities.fp16Supported)),
|
||||
voiceId: z.string().default(''),
|
||||
})
|
||||
},
|
||||
createProvider() {
|
||||
const adapterPromise = getKokoroAdapter()
|
||||
return {
|
||||
speech: () => ({
|
||||
baseURL: 'http://kokoro-local/v1/',
|
||||
model: 'kokoro-82m',
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!init?.body || typeof init.body !== 'string')
|
||||
throw new Error('Invalid request body')
|
||||
|
||||
const body = JSON.parse(init.body) as { input?: string, voice?: string }
|
||||
if (!body.voice)
|
||||
throw new Error('Voice parameter is required')
|
||||
|
||||
try {
|
||||
const adapter = await adapterPromise
|
||||
if (!(body.voice in adapter.getVoices()))
|
||||
throw new Error(`Unknown Kokoro voice: ${body.voice}`)
|
||||
const buffer = await adapter.generate(body.input ?? '', body.voice as VoiceKey)
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Kokoro TTS generation failed:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
validationRequiredWhen: () => false,
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'kokoro-local:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
if (!config.model) {
|
||||
return {
|
||||
errors: [{ error: new Error('No model selected') }],
|
||||
reason: 'Please select a model from the dropdown menu',
|
||||
reasonKey: '',
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
if (!getModel(config.model)) {
|
||||
const reason = `Invalid model. Must be one of: ${KOKORO_MODELS.map(model => model.id).join(', ')}`
|
||||
return { errors: [{ error: new Error(`Invalid model: ${config.model}`) }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
return { errors: [], reason: '', reasonKey: '', valid: true }
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async () => {
|
||||
const capabilities = getWebGpuState()
|
||||
return kokoroModelsToModelInfo(capabilities.supported, undefined, capabilities.fp16Supported)
|
||||
},
|
||||
loadModel: async (config, _provider, hooks) => {
|
||||
const model = assertModelSupported(config.model)
|
||||
try {
|
||||
const adapter = await getKokoroAdapter()
|
||||
await adapter.loadModel(model.quantization, model.platform, {
|
||||
onProgress: hooks?.onProgress ? progress => hooks.onProgress?.(progressInfo(progress)) : undefined,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load Kokoro model:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
listVoices: async (config) => {
|
||||
try {
|
||||
const adapter = await getKokoroAdapter()
|
||||
if (adapter.state !== 'ready' || config.model !== lastLoadedModelId) {
|
||||
const model = assertModelSupported(config.model)
|
||||
await adapter.loadModel(model.quantization, model.platform)
|
||||
lastLoadedModelId = config.model
|
||||
}
|
||||
|
||||
return Object.entries(adapter.getVoices() as Record<string, KokoroVoice>).map(([id, voice]) => {
|
||||
const languageCode = voice.language.toLowerCase()
|
||||
const language = languageByCode[languageCode] || { code: languageCode, title: voice.language }
|
||||
return {
|
||||
id,
|
||||
name: `${voice.name} (${voice.gender}, ${language.title.split('(')[0].trim()})`,
|
||||
provider: 'kokoro-local',
|
||||
languages: [language],
|
||||
gender: voice.gender.toLowerCase(),
|
||||
}
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch Kokoro voices:', error)
|
||||
// Voice discovery can run before model loading. An empty list is safe.
|
||||
return []
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { isWebGPUSupported } from '@proj-airi/stage-shared/webgpu'
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const localAudioConfigSchema = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
type LocalAudioConfig = z.input<typeof localAudioConfigSchema>
|
||||
|
||||
function createLocalAudioConfigSchema(t: ComposerTranslation) {
|
||||
return localAudioConfigSchema.extend({
|
||||
apiKey: localAudioConfigSchema.shape.apiKey.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'),
|
||||
type: 'password',
|
||||
}),
|
||||
baseUrl: localAudioConfigSchema.shape.baseUrl.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined) {
|
||||
const normalized = baseUrl?.trim() ?? ''
|
||||
return normalized && !normalized.endsWith('/') ? `${normalized}/` : normalized
|
||||
}
|
||||
|
||||
function createLocalAudioProvider(config: LocalAudioConfig) {
|
||||
return createOpenAI(config.apiKey?.trim() ?? '', normalizeBaseUrl(config.baseUrl))
|
||||
}
|
||||
|
||||
function createLocalTranscriptionProvider(config: LocalAudioConfig) {
|
||||
const provider = createLocalAudioProvider(config)
|
||||
const transcription = provider.transcription.bind(provider)
|
||||
provider.transcription = (model: string, extraOptions?: Record<string, unknown>) => ({
|
||||
...transcription(model),
|
||||
...extraOptions,
|
||||
})
|
||||
return provider
|
||||
}
|
||||
|
||||
function createLocalAudioValidators() {
|
||||
return {
|
||||
validateConfig: [
|
||||
({ t }: { t: ComposerTranslation }) => ({
|
||||
id: 'local-audio:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config: LocalAudioConfig) => {
|
||||
const valid = Boolean(config.baseUrl)
|
||||
const reason = valid
|
||||
? ''
|
||||
: 'Base URL is required. This is likely a bug, report to developers on https://github.com/moeru-ai/airi/issues.'
|
||||
return {
|
||||
errors: valid ? [] : [{ error: new Error('Base URL is required.') }],
|
||||
reason,
|
||||
reasonKey: '',
|
||||
valid,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function isBrowserAndMemoryEnough() {
|
||||
if (isStageTamagotchi())
|
||||
return false
|
||||
|
||||
if (await isWebGPUSupported())
|
||||
return true
|
||||
|
||||
if ('navigator' in globalThis && globalThis.navigator != null && 'deviceMemory' in globalThis.navigator && typeof globalThis.navigator.deviceMemory === 'number') {
|
||||
// The browser model needs at least 8 GB of system memory without WebGPU.
|
||||
return globalThis.navigator.deviceMemory >= 8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const providerAppLocalAudioSpeech = defineProvider<LocalAudioConfig>({
|
||||
id: 'app-local-audio-speech',
|
||||
name: 'App (Local)',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.app-local-audio-speech.title'),
|
||||
description: 'https://github.com/huggingface/candle',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.app-local-audio-speech.description'),
|
||||
tasks: ['text-to-speech', 'tts'],
|
||||
icon: 'i-lobe-icons:huggingface',
|
||||
isAvailableBy: isStageTamagotchi,
|
||||
createProviderConfig: ({ t }) => createLocalAudioConfigSchema(t),
|
||||
createProvider: createLocalAudioProvider,
|
||||
validators: createLocalAudioValidators(),
|
||||
})
|
||||
|
||||
export const providerAppLocalAudioTranscription = defineProvider<LocalAudioConfig>({
|
||||
id: 'app-local-audio-transcription',
|
||||
name: 'App (Local)',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.app-local-audio-transcription.title'),
|
||||
description: 'https://github.com/huggingface/candle',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.app-local-audio-transcription.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
icon: 'i-lobe-icons:huggingface',
|
||||
isAvailableBy: isStageTamagotchi,
|
||||
capabilities: {
|
||||
transcription: {
|
||||
protocol: 'http',
|
||||
generateOutput: true,
|
||||
streamOutput: false,
|
||||
streamInput: false,
|
||||
},
|
||||
},
|
||||
createProviderConfig: ({ t }) => createLocalAudioConfigSchema(t),
|
||||
createProvider: createLocalTranscriptionProvider,
|
||||
validators: createLocalAudioValidators(),
|
||||
})
|
||||
|
||||
export const providerBrowserLocalAudioSpeech = defineProvider<LocalAudioConfig>({
|
||||
id: 'browser-local-audio-speech',
|
||||
name: 'Browser (Local)',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.browser-local-audio-speech.title'),
|
||||
description: 'https://github.com/moeru-ai/xsai-transformers',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.browser-local-audio-speech.description'),
|
||||
tasks: ['text-to-speech', 'tts'],
|
||||
icon: 'i-lobe-icons:huggingface',
|
||||
isAvailableBy: isBrowserAndMemoryEnough,
|
||||
createProviderConfig: ({ t }) => createLocalAudioConfigSchema(t),
|
||||
createProvider: createLocalAudioProvider,
|
||||
validators: createLocalAudioValidators(),
|
||||
})
|
||||
|
||||
export const providerBrowserLocalAudioTranscription = defineProvider<LocalAudioConfig>({
|
||||
id: 'browser-local-audio-transcription',
|
||||
name: 'Browser (Local)',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.browser-local-audio-transcription.title'),
|
||||
description: 'https://github.com/moeru-ai/xsai-transformers',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.browser-local-audio-transcription.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
icon: 'i-lobe-icons:huggingface',
|
||||
isAvailableBy: isBrowserAndMemoryEnough,
|
||||
capabilities: {
|
||||
transcription: {
|
||||
protocol: 'http',
|
||||
generateOutput: true,
|
||||
streamOutput: false,
|
||||
streamInput: false,
|
||||
},
|
||||
},
|
||||
createProviderConfig: ({ t }) => createLocalAudioConfigSchema(t),
|
||||
createProvider: createLocalTranscriptionProvider,
|
||||
validators: createLocalAudioValidators(),
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const mimoSpeechConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default('https://api.xiaomimimo.com/v1/'),
|
||||
model: z.string().default('mimo-v2.5-tts'),
|
||||
voice: z.string().default('mimo_default'),
|
||||
format: z.string().default('wav'),
|
||||
stylePrompt: z.string().optional(),
|
||||
voiceSample: z.string().optional(),
|
||||
})
|
||||
|
||||
const mimoTranscriptionConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default('https://api.xiaomimimo.com/v1/'),
|
||||
model: z.string().default('mimo-v2-omni'),
|
||||
})
|
||||
|
||||
type MimoSpeechConfig = z.input<typeof mimoSpeechConfigSchema>
|
||||
type MimoTranscriptionConfig = z.input<typeof mimoTranscriptionConfigSchema>
|
||||
type MimoConfig = MimoSpeechConfig | MimoTranscriptionConfig
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined) {
|
||||
return `${(baseUrl || 'https://api.xiaomimimo.com/v1/').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
function createMimoValidators<TConfig extends MimoConfig>(id: string) {
|
||||
return {
|
||||
validateConfig: [
|
||||
({ t }: { t: (key: string) => string }) => ({
|
||||
id: `${id}:check-config`,
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config: TConfig) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
if (!config.apiKey?.trim())
|
||||
errors.push({ error: new Error('API key is required.') })
|
||||
if (!config.baseUrl?.trim())
|
||||
errors.push({ error: new Error('Base URL is required.') })
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.map(item => (item.error as Error).message).join(', '),
|
||||
reasonKey: '',
|
||||
valid: errors.length === 0,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createMimoSpeechProvider(config: MimoSpeechConfig) {
|
||||
const apiKey = config.apiKey?.trim() ?? ''
|
||||
const baseUrl = normalizeBaseUrl(config.baseUrl)
|
||||
const defaultModel = config.model || 'mimo-v2.5-tts'
|
||||
const defaultVoice = config.voice || 'mimo_default'
|
||||
const defaultFormat = config.format || 'wav'
|
||||
|
||||
return {
|
||||
speech: () => ({
|
||||
baseURL: baseUrl,
|
||||
model: defaultModel,
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!init?.body || typeof init.body !== 'string')
|
||||
throw new Error('Invalid request body')
|
||||
|
||||
const body = JSON.parse(init.body) as {
|
||||
input?: string
|
||||
model?: string
|
||||
response_format?: string
|
||||
style_prompt?: string
|
||||
voice_sample?: string
|
||||
voice?: string
|
||||
}
|
||||
const model = body.model || defaultModel
|
||||
const format = body.response_format || defaultFormat
|
||||
const stylePrompt = body.style_prompt?.trim() || config.stylePrompt?.trim() || ''
|
||||
const voiceSample = body.voice_sample?.trim() || config.voiceSample?.trim() || ''
|
||||
const userPrompt = model === 'mimo-v2.5-tts-voiceclone'
|
||||
? stylePrompt
|
||||
: stylePrompt || 'Use a natural, clear speaking style.'
|
||||
|
||||
const audio: Record<string, string> = { format }
|
||||
if (model === 'mimo-v2.5-tts-voiceclone') {
|
||||
if (!voiceSample)
|
||||
throw new Error('MiMo voice clone requires a base64 audio sample in data URI format.')
|
||||
audio.voice = voiceSample
|
||||
}
|
||||
else if (model === 'mimo-v2.5-tts') {
|
||||
audio.voice = body.voice || defaultVoice
|
||||
}
|
||||
|
||||
if (model === 'mimo-v2.5-tts-voicedesign' && !stylePrompt)
|
||||
throw new Error('MiMo voice design requires a style prompt in the user message.')
|
||||
|
||||
const response = await fetch(new URL('chat/completions', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'user', content: userPrompt },
|
||||
{ role: 'assistant', content: body.input ?? '' },
|
||||
],
|
||||
audio,
|
||||
}),
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`MiMo TTS request failed: ${response.status} ${response.statusText}`)
|
||||
|
||||
const data = await response.json() as {
|
||||
choices?: Array<{ message?: { audio?: { data?: string } } }>
|
||||
}
|
||||
const audioBase64 = data.choices?.[0]?.message?.audio?.data
|
||||
if (!audioBase64)
|
||||
throw new Error('MiMo TTS response missing audio data')
|
||||
|
||||
const binary = atob(audioBase64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++)
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
|
||||
let contentType = `audio/${format}`
|
||||
if (format === 'wav')
|
||||
contentType = 'audio/wav'
|
||||
else if (format === 'mp3')
|
||||
contentType = 'audio/mpeg'
|
||||
|
||||
return new Response(bytes.buffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function audioFormatFromDataUri(dataUri: string) {
|
||||
const mimeType = dataUri.split(';')[0]?.split(':')[1] || 'audio/wav'
|
||||
const format = mimeType.split('/')[1] || 'wav'
|
||||
if (format === 'webm' || format === 'mp4')
|
||||
return format
|
||||
if (format === 'mpeg' || format === 'mp3')
|
||||
return 'mp3'
|
||||
return 'wav'
|
||||
}
|
||||
|
||||
function readBlobAsDataUri(file: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new Error('Failed to read audio file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function createMimoTranscriptionProvider(config: MimoTranscriptionConfig) {
|
||||
const apiKey = config.apiKey?.trim() ?? ''
|
||||
const baseUrl = normalizeBaseUrl(config.baseUrl)
|
||||
const defaultModel = config.model || 'mimo-v2-omni'
|
||||
|
||||
return {
|
||||
transcription: (model: string) => ({
|
||||
baseURL: baseUrl,
|
||||
model: model || defaultModel,
|
||||
headers: {},
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!(init?.body instanceof FormData))
|
||||
throw new Error('No audio file provided for transcription.')
|
||||
|
||||
const file = init.body.get('file')
|
||||
if (!(file instanceof Blob))
|
||||
throw new Error('No audio file provided for transcription.')
|
||||
|
||||
const modelName = String(init.body.get('model') || defaultModel)
|
||||
const dataUri = await readBlobAsDataUri(file)
|
||||
const base64Data = dataUri.split(',')[1]
|
||||
const response = await fetch(new URL('chat/completions', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Transcribe the audio content.' },
|
||||
{ type: 'input_audio', input_audio: { data: base64Data, format: audioFormatFromDataUri(dataUri) } },
|
||||
],
|
||||
}],
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '')
|
||||
throw new Error(`MiMo transcription failed: ${response.status} ${response.statusText}${errorBody ? ` — ${errorBody}` : ''}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||
return new Response(JSON.stringify({ text: data.choices?.[0]?.message?.content || '' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const providerMimoAudioSpeech = defineProvider<MimoSpeechConfig>({
|
||||
id: 'mimo-audio-speech',
|
||||
name: 'Xiaomi MiMo',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.title'),
|
||||
description: 'api.xiaomimimo.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-simple-icons:xiaomi',
|
||||
createProviderConfig: () => mimoSpeechConfigSchema,
|
||||
createProvider: createMimoSpeechProvider,
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createMimoValidators<MimoSpeechConfig>('mimo-audio-speech'),
|
||||
extraMethods: {
|
||||
listModels: async () => [
|
||||
{ id: 'mimo-v2.5-tts', name: 'MiMo v2.5 TTS', provider: 'mimo-audio-speech', description: 'Preset voice synthesis with the built-in MiMo voice list', deprecated: false },
|
||||
{ id: 'mimo-v2.5-tts-voicedesign', name: 'MiMo v2.5 TTS Voice Design', provider: 'mimo-audio-speech', description: 'Design a new voice from a natural language description', deprecated: false },
|
||||
{ id: 'mimo-v2.5-tts-voiceclone', name: 'MiMo v2.5 TTS Voice Clone', provider: 'mimo-audio-speech', description: 'Clone a voice from a base64-encoded audio sample', deprecated: false },
|
||||
],
|
||||
listVoices: async () => [
|
||||
{ id: 'mimo_default', name: 'MiMo-默认', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }, { code: 'zh', title: 'Chinese' }] },
|
||||
{ id: '冰糖', name: '冰糖', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: '茉莉', name: '茉莉', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: '苏打', name: '苏打', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: '白桦', name: '白桦', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: 'Mia', name: 'Mia', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'Chloe', name: 'Chloe', provider: 'mimo-audio-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'Milo', name: 'Milo', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'Dean', name: 'Dean', provider: 'mimo-audio-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
export const providerMimoAudioTranscription = defineProvider<MimoTranscriptionConfig>({
|
||||
id: 'mimo-audio-transcription',
|
||||
name: 'Xiaomi MiMo',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.title'),
|
||||
description: 'api.xiaomimimo.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
icon: 'i-simple-icons:xiaomi',
|
||||
capabilities: {
|
||||
transcription: { protocol: 'http', generateOutput: true, streamOutput: false, streamInput: false },
|
||||
},
|
||||
createProviderConfig: () => mimoTranscriptionConfigSchema,
|
||||
createProvider: createMimoTranscriptionProvider,
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createMimoValidators<MimoTranscriptionConfig>('mimo-audio-transcription'),
|
||||
extraMethods: {
|
||||
listModels: async () => [
|
||||
{ id: 'mimo-v2-omni', name: 'MiMo V2 Omni', provider: 'mimo-audio-transcription', description: 'Omni-modal model with native audio understanding and speech-to-text', contextLength: 256000, deprecated: false },
|
||||
{ id: 'mimo-v2.5', name: 'MiMo V2.5', provider: 'mimo-audio-transcription', description: 'Latest omni-modal model with audio understanding, 1M context', contextLength: 1_000_000, deprecated: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const minimaxSpeechConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default('https://api.minimax.io'),
|
||||
})
|
||||
|
||||
type MinimaxSpeechConfig = z.input<typeof minimaxSpeechConfigSchema>
|
||||
|
||||
export const providerMinimaxSpeech = defineProvider<MinimaxSpeechConfig>({
|
||||
id: 'minimax-speech',
|
||||
name: 'MiniMax Speech',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.minimax-speech.title'),
|
||||
description: 'minimax.io',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.minimax-speech.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:minimax',
|
||||
iconColor: 'i-lobe-icons:minimax-color',
|
||||
createProviderConfig: () => minimaxSpeechConfigSchema,
|
||||
createProvider(config) {
|
||||
const apiKey = config.apiKey.trim()
|
||||
const baseUrl = (config.baseUrl || 'https://api.minimax.io').replace(/\/$/, '')
|
||||
|
||||
return {
|
||||
speech: () => ({
|
||||
baseURL: `${baseUrl}/v1/`,
|
||||
model: 'speech-2.8-hd',
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!init?.body || typeof init.body !== 'string')
|
||||
throw new Error('Invalid request body')
|
||||
|
||||
const body = JSON.parse(init.body) as { input?: string, voice?: string, model?: string }
|
||||
const response = await fetch(`${baseUrl}/v1/t2a_v2`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: body.model || 'speech-2.8-hd',
|
||||
text: body.input ?? '',
|
||||
stream: true,
|
||||
voice_setting: {
|
||||
voice_id: body.voice || 'English_Graceful_Lady',
|
||||
speed: 1,
|
||||
vol: 1,
|
||||
pitch: 0,
|
||||
},
|
||||
audio_setting: {
|
||||
sample_rate: 32000,
|
||||
bitrate: 128000,
|
||||
format: 'mp3',
|
||||
channel: 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`MiniMax TTS request failed: ${response.status} ${response.statusText}`)
|
||||
|
||||
// MiniMax streams SSE events that contain hex-encoded audio chunks.
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const audioChunks: Uint8Array[] = []
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:'))
|
||||
continue
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === '[DONE]')
|
||||
continue
|
||||
|
||||
try {
|
||||
const event = JSON.parse(json) as { data?: { audio?: string, status?: number } }
|
||||
// Status 2 is the final summary. Its audio duplicates prior chunks.
|
||||
if (event.data?.audio && event.data.status !== 2) {
|
||||
const bytes = new Uint8Array(event.data.audio.length / 2)
|
||||
for (let index = 0; index < event.data.audio.length; index += 2)
|
||||
bytes[index / 2] = Number.parseInt(event.data.audio.slice(index, index + 2), 16)
|
||||
audioChunks.push(bytes)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// A malformed SSE event does not invalidate earlier audio chunks.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(audioChunks.reduce((sum, chunk) => sum + chunk.length, 0))
|
||||
let offset = 0
|
||||
for (const chunk of audioChunks) {
|
||||
combined.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return new Response(combined.buffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'audio/mpeg' },
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim()),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'minimax-speech:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const valid = Boolean(config.apiKey?.trim())
|
||||
return {
|
||||
errors: valid ? [] : [{ error: new Error('API key is required.') }],
|
||||
reason: valid ? '' : 'API key is required.',
|
||||
reasonKey: '',
|
||||
valid,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async () => [
|
||||
{ id: 'speech-2.8-hd', name: 'Speech 2.8 HD', provider: 'minimax-speech', description: 'High-definition TTS model with natural prosody', deprecated: false },
|
||||
{ id: 'speech-2.8-turbo', name: 'Speech 2.8 Turbo', provider: 'minimax-speech', description: 'Fast TTS model for low-latency scenarios', deprecated: false },
|
||||
],
|
||||
listVoices: async () => [
|
||||
{ id: 'English_Graceful_Lady', name: 'Graceful Lady', provider: 'minimax-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'English_Insightful_Speaker', name: 'Insightful Speaker', provider: 'minimax-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'English_radiant_girl', name: 'Radiant Girl', provider: 'minimax-speech', gender: 'female', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'English_Persuasive_Man', name: 'Persuasive Man', provider: 'minimax-speech', gender: 'male', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'English_Lucky_Robot', name: 'Lucky Robot', provider: 'minimax-speech', gender: 'neutral', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'English_expressive_narrator', name: 'Expressive Narrator', provider: 'minimax-speech', gender: 'neutral', languages: [{ code: 'en', title: 'English' }] },
|
||||
{ id: 'Mandarin_Gentle_Woman', name: 'Gentle Woman', provider: 'minimax-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: 'Mandarin_Steadfast_Man', name: 'Steadfast Man', provider: 'minimax-speech', gender: 'male', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: 'Mandarin_Sweet_Girl', name: 'Sweet Girl', provider: 'minimax-speech', gender: 'female', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
{ id: 'Mandarin_Magnetic_Gentleman', name: 'Magnetic Gentleman', provider: 'minimax-speech', gender: 'male', languages: [{ code: 'zh', title: 'Chinese' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { listModels } from '@xsai/model'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const OPENAI_BASE_URL = 'https://api.openai.com/v1/'
|
||||
|
||||
const openAIAudioConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default(OPENAI_BASE_URL),
|
||||
})
|
||||
|
||||
const openAICompatibleAudioConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default(''),
|
||||
})
|
||||
|
||||
type OpenAIAudioConfig = z.input<typeof openAIAudioConfigSchema>
|
||||
type OpenAICompatibleAudioConfig = z.input<typeof openAICompatibleAudioConfigSchema>
|
||||
type AudioConfig = OpenAIAudioConfig | OpenAICompatibleAudioConfig
|
||||
|
||||
function createAudioConfigSchema<T extends typeof openAIAudioConfigSchema | typeof openAICompatibleAudioConfigSchema>(schema: T, t: ComposerTranslation) {
|
||||
return schema.extend({
|
||||
apiKey: schema.shape.apiKey.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'),
|
||||
type: 'password',
|
||||
}),
|
||||
baseUrl: schema.shape.baseUrl.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined) {
|
||||
const value = baseUrl?.trim() ?? ''
|
||||
return value && !value.endsWith('/') ? `${value}/` : value
|
||||
}
|
||||
|
||||
function createAudioProvider(config: AudioConfig) {
|
||||
return createOpenAI(config.apiKey.trim(), normalizeBaseUrl(config.baseUrl))
|
||||
}
|
||||
|
||||
function createTranscriptionProvider(config: AudioConfig) {
|
||||
const provider = createAudioProvider(config)
|
||||
const transcription = provider.transcription.bind(provider)
|
||||
provider.transcription = (model: string, extraOptions?: Record<string, unknown>) => ({
|
||||
...transcription(model),
|
||||
...extraOptions,
|
||||
})
|
||||
return provider
|
||||
}
|
||||
|
||||
function createAudioValidators<TConfig extends AudioConfig>() {
|
||||
return {
|
||||
validateConfig: [
|
||||
({ t }: { t: ComposerTranslation }) => ({
|
||||
id: 'openai-audio:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config: TConfig) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
const apiKey = config.apiKey?.trim() ?? ''
|
||||
const baseUrl = config.baseUrl?.trim() ?? ''
|
||||
|
||||
if (!apiKey)
|
||||
errors.push({ error: new Error('API Key is required') })
|
||||
if (!baseUrl)
|
||||
errors.push({ error: new Error('Base URL is required.') })
|
||||
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (!url.host)
|
||||
errors.push({ error: new Error('Base URL is not absolute. Try to include a scheme (http:// or https://).') })
|
||||
else if (!baseUrl.endsWith('/'))
|
||||
errors.push({ error: new Error('Base URL must end with a trailing slash (/).') })
|
||||
}
|
||||
catch {
|
||||
errors.push({ error: new Error('Base URL is not absolute. Try to include a scheme (http:// or https://).') })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.map(item => (item.error as Error).message).join(', '),
|
||||
reasonKey: '',
|
||||
valid: errors.length === 0,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const openAISpeechModels = [
|
||||
{
|
||||
id: 'tts-1',
|
||||
name: 'TTS-1',
|
||||
provider: 'openai-audio-speech',
|
||||
description: 'Optimized for real-time text-to-speech tasks',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
},
|
||||
{
|
||||
id: 'tts-1-hd',
|
||||
name: 'TTS-1-HD',
|
||||
provider: 'openai-audio-speech',
|
||||
description: 'Higher fidelity audio output',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini-tts',
|
||||
name: 'GPT-4o Mini TTS',
|
||||
provider: 'openai-audio-speech',
|
||||
description: 'GPT-4o Mini optimized for text-to-speech',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini-tts-2025-12-15',
|
||||
name: 'GPT-4o Mini TTS (2025-12-15)',
|
||||
provider: 'openai-audio-speech',
|
||||
description: 'GPT-4o Mini TTS snapshot from 2025-12-15',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
},
|
||||
]
|
||||
|
||||
// OpenAI does not provide a voice-list endpoint. This list follows the
|
||||
// create-speech API. The TTS-1 models support nine voices. GPT-4o Mini TTS
|
||||
// also supports ballad, verse, marin, and cedar.
|
||||
const openAISpeechVoices = [
|
||||
{ id: 'alloy', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'ash', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'ballad', models: ['gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'coral', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'echo', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'fable', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'onyx', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'nova', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'sage', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'shimmer', models: ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'verse', models: ['gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'marin', models: ['gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
{ id: 'cedar', models: ['gpt-4o-mini-tts', 'gpt-4o-mini-tts-2025-12-15'] },
|
||||
].map(voice => ({
|
||||
id: voice.id,
|
||||
name: `${voice.id[0].toUpperCase()}${voice.id.slice(1)}`,
|
||||
provider: 'openai-audio-speech',
|
||||
languages: [],
|
||||
compatibleModels: voice.models,
|
||||
}))
|
||||
|
||||
const openAITranscriptionModels = [
|
||||
['gpt-4o-transcribe', 'GPT-4o Transcribe', 'High-quality transcription model'],
|
||||
['gpt-4o-mini-transcribe', 'GPT-4o Mini Transcribe', 'Faster, cost-effective transcription model'],
|
||||
['gpt-4o-mini-transcribe-2025-12-15', 'GPT-4o Mini Transcribe (2025-12-15)', 'GPT-4o Mini Transcribe snapshot from 2025-12-15'],
|
||||
['whisper-1', 'Whisper-1', 'Powered by our open source Whisper V2 model'],
|
||||
['gpt-4o-transcribe-diarize', 'GPT-4o Transcribe Diarize', 'Transcription with speaker diarization'],
|
||||
].map(([id, name, description]) => ({
|
||||
id,
|
||||
name,
|
||||
provider: 'openai-audio-transcription',
|
||||
description,
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
}))
|
||||
|
||||
export const providerOpenAIAudioSpeech = defineProvider<OpenAIAudioConfig>({
|
||||
id: 'openai-audio-speech',
|
||||
name: 'OpenAI',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.openai.title'),
|
||||
description: 'openai.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openai.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:openai',
|
||||
createProviderConfig: ({ t }) => createAudioConfigSchema(openAIAudioConfigSchema, t),
|
||||
createProvider: createAudioProvider,
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createAudioValidators<OpenAIAudioConfig>(),
|
||||
extraMethods: {
|
||||
listModels: async () => openAISpeechModels,
|
||||
listVoices: async () => openAISpeechVoices,
|
||||
},
|
||||
})
|
||||
|
||||
export const providerOpenAICompatibleAudioSpeech = defineProvider<OpenAICompatibleAudioConfig>({
|
||||
id: 'openai-compatible-audio-speech',
|
||||
name: 'OpenAI Compatible',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.openai-compatible.title'),
|
||||
description: 'Connect to any API that follows the OpenAI specification.',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openai-compatible.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:openai',
|
||||
createProviderConfig: ({ t }) => createAudioConfigSchema(openAICompatibleAudioConfigSchema, t),
|
||||
createProvider: createAudioProvider,
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createAudioValidators<OpenAICompatibleAudioConfig>(),
|
||||
extraMethods: {
|
||||
listVoices: async () => [],
|
||||
listModels: async (config) => {
|
||||
const apiKey = config.apiKey?.trim() ?? ''
|
||||
const baseUrl = normalizeBaseUrl(config.baseUrl)
|
||||
if (!apiKey || !baseUrl)
|
||||
return []
|
||||
|
||||
const models = await listModels({ apiKey, baseURL: baseUrl })
|
||||
return models
|
||||
.filter(model => model.id.toLowerCase().includes('tts'))
|
||||
.map(model => ({
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
provider: 'openai-compatible-audio-speech',
|
||||
description: '',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const providerOpenAIAudioTranscription = defineProvider<OpenAIAudioConfig>({
|
||||
id: 'openai-audio-transcription',
|
||||
name: 'OpenAI',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.openai.title'),
|
||||
description: 'openai.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openai.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
icon: 'i-lobe-icons:openai',
|
||||
capabilities: {
|
||||
transcription: { protocol: 'http', generateOutput: true, streamOutput: false, streamInput: false },
|
||||
},
|
||||
createProviderConfig: ({ t }) => createAudioConfigSchema(openAIAudioConfigSchema, t),
|
||||
createProvider: createTranscriptionProvider,
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createAudioValidators<OpenAIAudioConfig>(),
|
||||
extraMethods: {
|
||||
listModels: async () => openAITranscriptionModels,
|
||||
},
|
||||
})
|
||||
|
||||
export const providerOpenAICompatibleAudioTranscription = defineProvider<OpenAICompatibleAudioConfig>({
|
||||
id: 'openai-compatible-audio-transcription',
|
||||
name: 'OpenAI Compatible',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.openai-compatible.title'),
|
||||
description: 'Connect to any API that follows the OpenAI specification.',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openai-compatible.description'),
|
||||
tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt'],
|
||||
icon: 'i-lobe-icons:openai',
|
||||
capabilities: {
|
||||
transcription: { protocol: 'http', generateOutput: true, streamOutput: false, streamInput: false },
|
||||
},
|
||||
createProviderConfig: ({ t }) => createAudioConfigSchema(openAICompatibleAudioConfigSchema, t),
|
||||
createProvider: createTranscriptionProvider,
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createAudioValidators<OpenAICompatibleAudioConfig>(),
|
||||
// Transcription model names are not reliably available from /v1/models.
|
||||
extraMethods: { listModels: async () => [] },
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { toWavFromPCM16 } from '@proj-airi/audio/encoding'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { OPENROUTER_ATTRIBUTION_HEADERS } from '../openrouter-ai'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1/'
|
||||
const DEFAULT_MODEL = 'openai/gpt-audio-mini'
|
||||
|
||||
const openRouterAudioConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default(DEFAULT_BASE_URL),
|
||||
})
|
||||
|
||||
type OpenRouterAudioConfig = z.input<typeof openRouterAudioConfigSchema>
|
||||
|
||||
const openAIVoices = [
|
||||
'alloy',
|
||||
'ash',
|
||||
'ballad',
|
||||
'coral',
|
||||
'echo',
|
||||
'fable',
|
||||
'onyx',
|
||||
'nova',
|
||||
'sage',
|
||||
'shimmer',
|
||||
'verse',
|
||||
'marin',
|
||||
'cedar',
|
||||
] as const
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined) {
|
||||
const value = baseUrl?.trim() || DEFAULT_BASE_URL
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
function ttsPrompt(input: string) {
|
||||
return `Read this text aloud exactly as written, without any commentary or extra words:\n\n${input}`
|
||||
}
|
||||
|
||||
async function collectAudioChunks(body: ReadableStream<Uint8Array>) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const chunks: 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 event = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { audio?: { data?: string } } }>
|
||||
}
|
||||
const audio = event.choices?.[0]?.delta?.audio?.data
|
||||
if (audio)
|
||||
chunks.push(audio)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Skipping malformed SSE chunk from OpenRouter audio stream:', data, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function decodeBase64Pcm(chunks: string[]) {
|
||||
const binary = atob(chunks.join(''))
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++)
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function createAudioFetch(apiKey: string, baseUrl: string, model: string) {
|
||||
return async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!init?.body || typeof init.body !== 'string')
|
||||
throw new Error('Invalid request body')
|
||||
|
||||
const body = JSON.parse(init.body) as { input?: string, voice?: string }
|
||||
const response = await globalThis.fetch(new URL('chat/completions', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...OPENROUTER_ATTRIBUTION_HEADERS,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: ttsPrompt(body.input ?? '') }],
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: body.voice, format: 'pcm16' },
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(`OpenRouter audio request failed: ${response.status} ${await response.text()}`)
|
||||
if (!response.body)
|
||||
throw new Error('OpenRouter audio response has no body')
|
||||
|
||||
const wav = toWavFromPCM16(decodeBase64Pcm(await collectAudioChunks(response.body)), 24000)
|
||||
return new Response(new Blob([wav], { type: 'audio/wav' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const providerOpenRouterAudioSpeech = defineProvider<OpenRouterAudioConfig>({
|
||||
id: 'openrouter-audio-speech',
|
||||
name: 'OpenRouter',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter-audio-speech.title'),
|
||||
description: 'openrouter.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter-audio-speech.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:openrouter',
|
||||
createProviderConfig: () => openRouterAudioConfigSchema,
|
||||
createProvider(config) {
|
||||
const apiKey = config.apiKey.trim()
|
||||
const baseUrl = normalizeBaseUrl(config.baseUrl)
|
||||
return {
|
||||
speech: (model?: string) => {
|
||||
const resolvedModel = model || DEFAULT_MODEL
|
||||
return {
|
||||
baseURL: baseUrl,
|
||||
model: resolvedModel,
|
||||
fetch: createAudioFetch(apiKey, baseUrl, resolvedModel),
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim()),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'openrouter-audio-speech:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const valid = Boolean(config.apiKey?.trim())
|
||||
return {
|
||||
errors: valid ? [] : [{ error: new Error('API Key is required.') }],
|
||||
reason: valid ? '' : 'API Key is required.',
|
||||
reasonKey: '',
|
||||
valid,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async (config) => {
|
||||
try {
|
||||
const response = await fetch(new URL('models?output_modality=audio', normalizeBaseUrl(config.baseUrl)), {
|
||||
headers: OPENROUTER_ATTRIBUTION_HEADERS,
|
||||
})
|
||||
if (!response.ok)
|
||||
return []
|
||||
|
||||
const data = await response.json() as {
|
||||
data?: Array<{ id: string, name?: string, description?: string, context_length?: number }>
|
||||
}
|
||||
return (data.data ?? []).map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
provider: 'openrouter-audio-speech',
|
||||
description: model.description || '',
|
||||
contextLength: model.context_length || 0,
|
||||
deprecated: false,
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch OpenRouter audio models:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
listVoices: async () => openAIVoices.map(id => ({
|
||||
id,
|
||||
name: `${id[0].toUpperCase()}${id.slice(1)}`,
|
||||
provider: 'openrouter-audio-speech',
|
||||
languages: [],
|
||||
})),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { createPlayer2 } from '@xsai-ext/providers/special/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const player2ConfigSchema = z.object({
|
||||
baseUrl: z.string().default('http://localhost:4315/v1/'),
|
||||
})
|
||||
|
||||
type Player2Config = z.input<typeof player2ConfigSchema>
|
||||
type Player2VoiceLanguage = keyof typeof player2VoiceLanguages
|
||||
|
||||
const player2VoiceLanguages = {
|
||||
american_english: { code: 'en', title: 'English' },
|
||||
british_english: { code: 'en', title: 'English' },
|
||||
japanese: { code: 'ja', title: 'Japanese' },
|
||||
mandarin_chinese: { code: 'zh', title: 'Chinese' },
|
||||
spanish: { code: 'es', title: 'Spanish' },
|
||||
french: { code: 'fr', title: 'French' },
|
||||
hindi: { code: 'hi', title: 'Hindi' },
|
||||
italian: { code: 'it', title: 'Italian' },
|
||||
brazilian_portuguese: { code: 'pt', title: 'Portuguese' },
|
||||
} as const
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | undefined) {
|
||||
const value = baseUrl?.trim() ?? 'http://localhost:4315/v1/'
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
export const providerPlayer2Speech = defineProvider<Player2Config>({
|
||||
id: 'player2-speech',
|
||||
name: 'Player2 Speech',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.player2.title'),
|
||||
description: 'player2.game',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.player2.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-lobe-icons:player2',
|
||||
createProviderConfig: () => player2ConfigSchema,
|
||||
createProvider: config => createPlayer2(normalizeBaseUrl(config.baseUrl), 'airi'),
|
||||
validationRequiredWhen: config => Boolean(config.baseUrl?.trim()),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
({ t }) => ({
|
||||
id: 'player2-speech:check-config',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config) => {
|
||||
const valid = Boolean(config.baseUrl?.trim())
|
||||
const reason = valid ? '' : 'Base URL is required. Default to http://localhost:4315/v1/'
|
||||
return { errors: valid ? [] : [{ error: new Error(reason) }], reason, reasonKey: '', valid }
|
||||
},
|
||||
}),
|
||||
],
|
||||
validateProvider: [
|
||||
({ t }) => ({
|
||||
id: 'player2-speech:check-connectivity',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
|
||||
validator: async (config) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const response = await fetch(new URL('health', normalizeBaseUrl(config.baseUrl)), {
|
||||
headers: { 'player2-game-key': 'airi' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const reason = `Player2 speech unreachable: HTTP ${response.status} ${response.statusText}`
|
||||
return { errors: [{ error: new Error(reason) }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const reason = `Player2 speech connection failed: ${errorMessageFrom(error) ?? 'Unknown error'}`
|
||||
return { errors: [{ error }], reason, reasonKey: '', valid: false }
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
return { errors: [], reason: '', reasonKey: '', valid: true }
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
extraMethods: {
|
||||
listModels: async () => [{
|
||||
id: 'player2-tts',
|
||||
name: 'Player2 Speech',
|
||||
provider: 'player2-speech',
|
||||
description: 'Default model for Player2 speech endpoint',
|
||||
contextLength: 0,
|
||||
deprecated: false,
|
||||
}],
|
||||
listVoices: async (config) => {
|
||||
const response = await fetch(new URL('tts/voices', normalizeBaseUrl(config.baseUrl)))
|
||||
const data = await response.json() as {
|
||||
voices?: Array<{ id: string, language: Player2VoiceLanguage, name: string, gender: string }>
|
||||
}
|
||||
return (data.voices ?? []).map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
provider: 'player2-speech',
|
||||
gender: voice.gender,
|
||||
languages: [player2VoiceLanguages[voice.language]],
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { providerBrowserWebSpeechApi } from './browser-web-speech-api'
|
||||
import { providerElevenLabs } from './elevenlabs'
|
||||
import {
|
||||
providerAppLocalAudioSpeech,
|
||||
providerAppLocalAudioTranscription,
|
||||
providerBrowserLocalAudioSpeech,
|
||||
providerBrowserLocalAudioTranscription,
|
||||
} from './local-audio'
|
||||
import { getDefinedProvider } from './registry'
|
||||
import { providerSpeechNoop } from './speech-noop'
|
||||
|
||||
import './index'
|
||||
|
||||
const translate = ((key: string) => key) as unknown as ComposerTranslation
|
||||
|
||||
describe('migrated provider definitions', () => {
|
||||
it('registers every provider that moved out of the legacy store', () => {
|
||||
const providerIds = [
|
||||
'speech-noop',
|
||||
'app-local-audio-speech',
|
||||
'app-local-audio-transcription',
|
||||
'browser-local-audio-speech',
|
||||
'browser-local-audio-transcription',
|
||||
'openai-audio-speech',
|
||||
'openai-compatible-audio-speech',
|
||||
'openai-audio-transcription',
|
||||
'openai-compatible-audio-transcription',
|
||||
'aliyun-nls-transcription',
|
||||
'browser-web-speech-api',
|
||||
'elevenlabs',
|
||||
'deepgram-tts',
|
||||
'microsoft-speech',
|
||||
'index-tts-vllm',
|
||||
'alibaba-cloud-model-studio',
|
||||
'volcengine',
|
||||
'minimax-speech',
|
||||
'openrouter-audio-speech',
|
||||
'mimo-audio-speech',
|
||||
'comet-api-speech',
|
||||
'comet-api-transcription',
|
||||
'mimo-audio-transcription',
|
||||
'player2-speech',
|
||||
'kokoro-local',
|
||||
'google-gemini-audio-speech',
|
||||
]
|
||||
|
||||
for (const providerId of providerIds)
|
||||
expect(getDefinedProvider(providerId), providerId).toBeDefined()
|
||||
})
|
||||
|
||||
it('creates the no-op speech provider through ProviderDefinition', () => {
|
||||
const provider = providerSpeechNoop.createProvider({})
|
||||
|
||||
expect(provider).toHaveProperty('speech')
|
||||
expect('speech' in provider && provider.speech('unused')).toMatchObject({
|
||||
baseURL: 'http://speech-noop.invalid/v1/',
|
||||
model: 'noop',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps local audio providers split by inference task', () => {
|
||||
expect(providerAppLocalAudioSpeech.tasks).toContain('text-to-speech')
|
||||
expect(providerBrowserLocalAudioSpeech.tasks).toContain('text-to-speech')
|
||||
expect(providerAppLocalAudioTranscription.tasks).toContain('speech-to-text')
|
||||
expect(providerBrowserLocalAudioTranscription.tasks).toContain('speech-to-text')
|
||||
expect(providerAppLocalAudioTranscription.capabilities?.transcription).toEqual({
|
||||
protocol: 'http',
|
||||
generateOutput: true,
|
||||
streamOutput: false,
|
||||
streamInput: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the local audio base URL validation in the definition', async () => {
|
||||
const validator = providerAppLocalAudioSpeech.validators?.validateConfig?.[0]({ t: translate })
|
||||
|
||||
const missing = await validator?.validator({}, { t: translate })
|
||||
const configured = await validator?.validator({ baseUrl: 'http://localhost:1234/v1/' }, { t: translate })
|
||||
|
||||
expect(missing?.valid).toBe(false)
|
||||
expect(missing?.reason).toContain('Base URL is required.')
|
||||
expect(configured?.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('describes Web Speech API streaming support without runtime state', async () => {
|
||||
const defaults = z.parse(providerBrowserWebSpeechApi.createProviderConfig({ t: translate }), {})
|
||||
|
||||
expect(defaults).toEqual({
|
||||
language: 'en-US',
|
||||
continuous: true,
|
||||
interimResults: true,
|
||||
maxAlternatives: 1,
|
||||
})
|
||||
expect(providerBrowserWebSpeechApi.capabilities?.transcription).toEqual({
|
||||
protocol: 'http',
|
||||
generateOutput: false,
|
||||
streamOutput: true,
|
||||
streamInput: true,
|
||||
})
|
||||
expect(await providerBrowserWebSpeechApi.isAvailableBy?.()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps ElevenLabs configuration and model discovery in the definition', async () => {
|
||||
const defaults = z.parse(providerElevenLabs.createProviderConfig({ t: translate }), { apiKey: 'test' })
|
||||
const models = await providerElevenLabs.extraMethods?.listModels?.(defaults, providerElevenLabs.createProvider(defaults))
|
||||
|
||||
expect(defaults).toMatchObject({
|
||||
baseUrl: 'https://unspeech.hyp3r.link/v1/',
|
||||
voiceSettings: {
|
||||
similarityBoost: 0.75,
|
||||
stability: 0.5,
|
||||
},
|
||||
})
|
||||
expect(models?.length).toBeGreaterThan(0)
|
||||
expect(models?.every(model => model.provider === 'elevenlabs')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const speechNoopConfigSchema = z.object({})
|
||||
|
||||
export const providerSpeechNoop = defineProvider({
|
||||
id: 'speech-noop',
|
||||
name: 'None',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.speech-noop.title'),
|
||||
description: 'No speech output.',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.speech-noop.description'),
|
||||
tasks: ['text-to-speech', 'tts'],
|
||||
icon: 'i-solar:volume-cross-bold-duotone',
|
||||
requiresCredentials: false,
|
||||
|
||||
createProviderConfig: () => speechNoopConfigSchema,
|
||||
createProvider() {
|
||||
return {
|
||||
speech: () => ({
|
||||
baseURL: 'http://speech-noop.invalid/v1/',
|
||||
model: 'noop',
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen: () => false,
|
||||
extraMethods: {
|
||||
listModels: async () => [],
|
||||
listVoices: async () => [],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
ListVoicesOptions,
|
||||
UnAlibabaCloudOptions,
|
||||
UnDeepgramOptions,
|
||||
UnMicrosoftOptions,
|
||||
UnVolcengineOptions,
|
||||
VoiceProviderWithExtraOptions,
|
||||
} from 'unspeech'
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
createUnAlibabaCloud,
|
||||
createUnDeepgram,
|
||||
createUnMicrosoft,
|
||||
createUnVolcengine,
|
||||
listVoices,
|
||||
} from 'unspeech'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const unspeechConfigSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().default('https://unspeech.hyp3r.link/v1/'),
|
||||
})
|
||||
|
||||
const microsoftSpeechConfigSchema = unspeechConfigSchema.extend({
|
||||
region: z.string().optional(),
|
||||
})
|
||||
|
||||
const volcengineSpeechConfigSchema = unspeechConfigSchema.extend({
|
||||
app: z.object({ appId: z.string() }),
|
||||
})
|
||||
|
||||
type UnspeechConfig = z.input<typeof unspeechConfigSchema>
|
||||
type MicrosoftSpeechConfig = z.input<typeof microsoftSpeechConfigSchema>
|
||||
type VolcengineSpeechConfig = z.input<typeof volcengineSpeechConfigSchema>
|
||||
|
||||
function createUnspeechConfigSchema(schema: typeof volcengineSpeechConfigSchema, t: ComposerTranslation): typeof volcengineSpeechConfigSchema
|
||||
function createUnspeechConfigSchema(schema: typeof microsoftSpeechConfigSchema, t: ComposerTranslation): typeof microsoftSpeechConfigSchema
|
||||
function createUnspeechConfigSchema(schema: typeof unspeechConfigSchema, t: ComposerTranslation): typeof unspeechConfigSchema
|
||||
function createUnspeechConfigSchema(
|
||||
schema: typeof unspeechConfigSchema | typeof microsoftSpeechConfigSchema | typeof volcengineSpeechConfigSchema,
|
||||
t: ComposerTranslation,
|
||||
) {
|
||||
return schema.extend({
|
||||
apiKey: schema.shape.apiKey.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'),
|
||||
type: 'password',
|
||||
}),
|
||||
baseUrl: schema.shape.baseUrl.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function toListVoicesOptions<T>(provider: VoiceProviderWithExtraOptions<T>, options?: T): ListVoicesOptions {
|
||||
const { fetch: _fetch, ...voiceOptions } = provider.voice(options)
|
||||
return voiceOptions
|
||||
}
|
||||
|
||||
function validateUnspeechConfig(config: UnspeechConfig, requireAppId = false) {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
const apiKey = config.apiKey?.trim() ?? ''
|
||||
const baseUrl = config.baseUrl?.trim() ?? ''
|
||||
|
||||
if (!apiKey)
|
||||
errors.push({ error: new Error('API key is required.') })
|
||||
if (!baseUrl)
|
||||
errors.push({ error: new Error('Base URL is required.') })
|
||||
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (!url.host)
|
||||
errors.push({ error: new Error('Base URL is not absolute. Try to include a scheme (http:// or https://).') })
|
||||
else if (!baseUrl.endsWith('/'))
|
||||
errors.push({ error: new Error('Base URL must end with a trailing slash (/).') })
|
||||
}
|
||||
catch {
|
||||
errors.push({ error: new Error('Base URL is not absolute. Try to include a scheme (http:// or https://).') })
|
||||
}
|
||||
}
|
||||
|
||||
if (requireAppId) {
|
||||
const appId = 'app' in config && config.app && typeof config.app === 'object' && 'appId' in config.app
|
||||
? String(config.app.appId).trim()
|
||||
: ''
|
||||
if (!appId)
|
||||
errors.push({ error: new Error('App ID is required.') })
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
reason: errors.map(item => (item.error as Error).message).join(', '),
|
||||
reasonKey: '',
|
||||
valid: errors.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
function createUnspeechValidators<TConfig extends UnspeechConfig>(id: string, requireAppId = false) {
|
||||
return {
|
||||
validateConfig: [
|
||||
({ t }: { t: ComposerTranslation }) => ({
|
||||
id: `${id}:check-config`,
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
||||
validator: async (config: TConfig) => validateUnspeechConfig(config, requireAppId),
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const providerDeepgramTts = defineProvider<UnspeechConfig>({
|
||||
id: 'deepgram-tts',
|
||||
name: 'Deepgram',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.deepgram-tts.title'),
|
||||
description: 'deepgram.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.deepgram-tts.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
icon: 'i-simple-icons:deepgram',
|
||||
createProviderConfig: ({ t }) => createUnspeechConfigSchema(unspeechConfigSchema, t),
|
||||
createProvider: config => createUnDeepgram(config.apiKey.trim(), config.baseUrl?.trim() ?? ''),
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createUnspeechValidators('deepgram-tts'),
|
||||
extraMethods: {
|
||||
listModels: async () => [
|
||||
{ id: 'aura-2', name: 'Aura 2', provider: 'deepgram-tts', description: 'Latest generation Aura model', deprecated: false },
|
||||
{ id: 'aura-1', name: 'Aura 1', provider: 'deepgram-tts', description: 'First generation Aura model', deprecated: false },
|
||||
{ id: 'aura', name: 'Aura (Legacy)', provider: 'deepgram-tts', description: 'Original Aura model', deprecated: true },
|
||||
],
|
||||
listVoices: async (config) => {
|
||||
const provider = createUnDeepgram(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions<UnDeepgramOptions>
|
||||
const voices = await listVoices(toListVoicesOptions(provider))
|
||||
return voices.map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
provider: 'deepgram-tts',
|
||||
description: voice.description,
|
||||
languages: voice.languages,
|
||||
gender: voice.labels?.gender,
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const providerMicrosoftSpeech = defineProvider<MicrosoftSpeechConfig>({
|
||||
id: 'microsoft-speech',
|
||||
name: 'Microsoft / Azure Speech',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.microsoft-speech.title'),
|
||||
description: 'speech.microsoft.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.microsoft-speech.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
iconColor: 'i-lobe-icons:microsoft',
|
||||
createProviderConfig: ({ t }) => createUnspeechConfigSchema(microsoftSpeechConfigSchema, t),
|
||||
createProvider: config => createUnMicrosoft(config.apiKey.trim(), config.baseUrl?.trim() ?? ''),
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createUnspeechValidators('microsoft-speech'),
|
||||
extraMethods: {
|
||||
listModels: async () => [{ id: 'v1', name: 'v1', provider: 'microsoft-speech', description: '', deprecated: false }],
|
||||
listVoices: async (config) => {
|
||||
const provider = createUnMicrosoft(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions<UnMicrosoftOptions>
|
||||
const voices = await listVoices(toListVoicesOptions(provider, { region: config.region ?? '' }))
|
||||
return voices.map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
provider: 'microsoft-speech',
|
||||
previewURL: voice.preview_audio_url,
|
||||
languages: voice.languages,
|
||||
gender: voice.labels?.gender,
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const providerAlibabaCloudModelStudio = defineProvider<UnspeechConfig>({
|
||||
id: 'alibaba-cloud-model-studio',
|
||||
name: 'Alibaba Cloud Model Studio',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.alibaba-cloud-model-studio.title'),
|
||||
description: 'bailian.console.aliyun.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.alibaba-cloud-model-studio.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
iconColor: 'i-lobe-icons:alibabacloud',
|
||||
createProviderConfig: ({ t }) => createUnspeechConfigSchema(unspeechConfigSchema, t),
|
||||
createProvider: config => createUnAlibabaCloud(config.apiKey.trim(), config.baseUrl?.trim() ?? ''),
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim()),
|
||||
validators: createUnspeechValidators('alibaba-cloud-model-studio'),
|
||||
extraMethods: {
|
||||
listModels: async () => [
|
||||
{ id: 'cosyvoice-v1', name: 'CosyVoice', provider: 'alibaba-cloud-model-studio', description: '', deprecated: false },
|
||||
{ id: 'cosyvoice-v2', name: 'CosyVoice (New)', provider: 'alibaba-cloud-model-studio', description: '', deprecated: false },
|
||||
],
|
||||
listVoices: async (config) => {
|
||||
const provider = createUnAlibabaCloud(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions<UnAlibabaCloudOptions>
|
||||
const voices = await listVoices(toListVoicesOptions(provider))
|
||||
return voices.map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
provider: 'alibaba-cloud-model-studio',
|
||||
compatibleModels: voice.compatible_models,
|
||||
previewURL: voice.preview_audio_url,
|
||||
languages: voice.languages,
|
||||
gender: voice.labels?.gender,
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const providerVolcengineSpeech = defineProvider<VolcengineSpeechConfig>({
|
||||
id: 'volcengine',
|
||||
name: 'Volcengine',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.volcengine.title'),
|
||||
description: 'volcengine.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.volcengine.description'),
|
||||
tasks: ['text-to-speech'],
|
||||
iconColor: 'i-lobe-icons:volcengine',
|
||||
createProviderConfig: ({ t }) => createUnspeechConfigSchema(volcengineSpeechConfigSchema, t),
|
||||
createProvider: config => createUnVolcengine(config.apiKey.trim(), config.baseUrl?.trim() ?? ''),
|
||||
validationRequiredWhen: config => Boolean(config.apiKey?.trim() && config.baseUrl?.trim() && config.app?.appId.trim()),
|
||||
validators: createUnspeechValidators<VolcengineSpeechConfig>('volcengine', true),
|
||||
extraMethods: {
|
||||
listModels: async () => [{ id: 'v1', name: 'v1', provider: 'volcano-engine', description: '', deprecated: false }],
|
||||
listVoices: async (config) => {
|
||||
const provider = createUnVolcengine(config.apiKey.trim(), config.baseUrl?.trim() ?? '') as VoiceProviderWithExtraOptions<UnVolcengineOptions>
|
||||
const voices = await listVoices(toListVoicesOptions(provider))
|
||||
return voices.map(voice => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
provider: 'volcano-engine',
|
||||
previewURL: voice.preview_audio_url,
|
||||
languages: voice.languages,
|
||||
gender: voice.labels?.gender,
|
||||
}))
|
||||
},
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user