diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 1dcc975a1d..4ba7f06c5e 100644 --- a/apps/stage-pocket/vite.config.ts +++ b/apps/stage-pocket/vite.config.ts @@ -34,6 +34,7 @@ function isEnvTruthy(value: string | undefined | null): boolean { const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) +const FISH_AUDIO_PROXY_RE = /^\/fish-audio-api/ export default defineConfig({ optimizeDeps: { @@ -83,6 +84,18 @@ export default defineConfig({ // See: https://vite.dev/config/server-options#server-fs-strict strict: false, }, + // NOTICE: Fish Audio's API is server-to-server only and doesn't send + // Access-Control-Allow-Origin headers for browser origins, so direct + // fetch() calls from the browser are blocked by CORS. We proxy them + // through the local Vite dev server so they appear same-origin. + // See packages/stage-ui/src/stores/providers.ts for the matching client-side usage. + proxy: { + '/fish-audio-api': { + target: 'https://api.fish.audio', + changeOrigin: true, + rewrite: (path: string) => path.replace(FISH_AUDIO_PROXY_RE, ''), + }, + }, warmup: { clientFiles: [ `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, diff --git a/apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts b/apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts new file mode 100644 index 0000000000..1cb72d1013 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts @@ -0,0 +1,43 @@ +import type { createContext } from '@moeru/eventa/adapters/electron/main' + +import { defineInvokeHandler } from '@moeru/eventa' +import { electronFishAudioTTS } from '@proj-airi/stage-ui/stores/providers/fish-audio/ipc' +import { net } from 'electron' + +/** + * Register the Fish Audio TTS IPC handler. + * + * The renderer cannot call https://api.fish.audio directly because the CDN + * does not send CORS headers for browser origins. By routing the request + * through the main process with net.fetch, we bypass the renderer's CORS + * enforcement entirely. + */ +export function createFishAudioService(params: { + context: ReturnType['context'] +}): void { + defineInvokeHandler(params.context, electronFishAudioTTS, async (payload) => { + const response = await net.fetch(`${payload.baseUrl}/v1/tts`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${payload.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: payload.text, + reference_id: payload.referenceId ?? null, + format: 'mp3', + }), + }) + + if (!response.ok) { + throw new Error(`Fish Audio TTS failed: ${response.status} ${response.statusText}`) + } + + const buffer = await response.arrayBuffer() + return { + data: new Uint8Array(buffer), + contentType: response.headers.get('content-type') ?? 'audio/mpeg', + status: response.status, + } + }) +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 6b58098a74..b3a244e342 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -1,5 +1,6 @@ export * from './app' export * from './auto-updater' +export * from './fish-audio' export * from './powerMonitor' export * from './screen' export * from './window' diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.ts index e8b92058a8..aa20485497 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/window.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/window.ts @@ -10,7 +10,7 @@ import { isMacOS } from 'std-env' import { createServerChannelService } from '../../services/airi/channel-server' import { createI18nService } from '../../services/airi/i18n' -import { createAppService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron' +import { createAppService, createFishAudioService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron' export function toggleWindowShow(window?: BrowserWindow | null): void { if (!window) { @@ -100,6 +100,7 @@ export async function setupBaseWindowElectronInvokes(params: { createWindowService({ context: params.context, window: params.window }) createAppService({ context: params.context, window: params.window }) createPowerMonitorService({ context: params.context, window: params.window }) + createFishAudioService({ context: params.context }) await createI18nService({ context: params.context, window: params.window, i18n: params.i18n }) diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 97a4668dd2..f47ac127e5 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -283,6 +283,9 @@ export interface ElectronAuthTokens { expiresIn: number } export const electronAuthStartLogin = defineInvokeEventa('eventa:invoke:electron:auth:start-login') + +export { electron } from '@proj-airi/electron-eventa' +export * from '@proj-airi/electron-eventa/electron-updater' export const electronAuthCallback = defineEventa('eventa:event:electron:auth:callback') export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa:event:electron:auth:callback-error') export const electronAuthLogout = defineInvokeEventa('eventa:invoke:electron:auth:logout') @@ -290,5 +293,7 @@ export const electronAuthLogout = defineInvokeEventa('eventa:invoke:electr export const i18nSetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:set-locale') export const i18nGetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:get-locale') -export { electron } from '@proj-airi/electron-eventa' -export * from '@proj-airi/electron-eventa/electron-updater' +// Fish Audio TTS IPC contract lives in packages/stage-ui so it can be imported +// by the renderer without creating an app → package dependency inversion. +export { electronFishAudioTTS } from '@proj-airi/stage-ui/stores/providers/fish-audio/ipc' +export type { ElectronFishAudioTTSPayload, ElectronFishAudioTTSResult } from '@proj-airi/stage-ui/stores/providers/fish-audio/ipc' diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index 9ef172faad..5836b789fe 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -25,6 +25,7 @@ import { VitePWA } from 'vite-plugin-pwa' const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) +const FISH_AUDIO_PROXY_RE = /^\/fish-audio-api/ function hasFlagEnableMkcert(): boolean { if (process.argv.includes('--mkcert')) { @@ -83,6 +84,18 @@ export default defineConfig({ // See: https://vite.dev/config/server-options#server-fs-strict strict: false, }, + // NOTICE: Fish Audio's API is server-to-server only and doesn't send + // Access-Control-Allow-Origin headers for browser origins, so direct + // fetch() calls from the browser are blocked by CORS. We proxy them + // through the local Vite dev server so they appear same-origin. + // See packages/stage-ui/src/stores/providers.ts for the matching client-side usage. + proxy: { + '/fish-audio-api': { + target: 'https://api.fish.audio', + changeOrigin: true, + rewrite: (path: string) => path.replace(FISH_AUDIO_PROXY_RE, ''), + }, + }, warmup: { clientFiles: [ `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index d847c34ac8..2758990a45 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -827,6 +827,9 @@ pages: default-text: Hello! This is a test of the Kokoro text-to-speech system. title: Voice Playground title: Kokoro TTS (Local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/es/settings.yaml b/packages/i18n/src/locales/es/settings.yaml index 3b375ea0ee..de66a3217c 100644 --- a/packages/i18n/src/locales/es/settings.yaml +++ b/packages/i18n/src/locales/es/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: '¡Hola! Esta es una prueba del sistema de texto a voz de Kokoro.' title: Campo de voz title: TTS de Kokoro (local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/fr/settings.yaml b/packages/i18n/src/locales/fr/settings.yaml index a43b7b703e..11caaea59b 100644 --- a/packages/i18n/src/locales/fr/settings.yaml +++ b/packages/i18n/src/locales/fr/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: Bonjour ! Ceci est un test du système de synthèse vocale Kokoro. title: Zone d'essai vocale title: Kokoro TTS (Local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/ja/settings.yaml b/packages/i18n/src/locales/ja/settings.yaml index 98eacfc09b..7839a859af 100644 --- a/packages/i18n/src/locales/ja/settings.yaml +++ b/packages/i18n/src/locales/ja/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: こんにちは!これは音声合成システム Kokoro のテストです。 title: 音声合成実験場 title: Kokoro TTS (ローカル) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/ko/settings.yaml b/packages/i18n/src/locales/ko/settings.yaml index 87a0c023e3..a3ba0906d6 100644 --- a/packages/i18n/src/locales/ko/settings.yaml +++ b/packages/i18n/src/locales/ko/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: 안녕하세요! 이것은 Kokoro 문자 음성 변환 시스템의 테스트입니다. title: 음성 플레이그라운드 title: Kokoro TTS (로컬) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/ru/settings.yaml b/packages/i18n/src/locales/ru/settings.yaml index 973ecec9f4..9d665bc17f 100644 --- a/packages/i18n/src/locales/ru/settings.yaml +++ b/packages/i18n/src/locales/ru/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: Привет! Это тест-системы синтеза речи. title: Голосовая платформа title: Kokoro TTS (Local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: Fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/vi/settings.yaml b/packages/i18n/src/locales/vi/settings.yaml index fdc131f002..6885a50fa2 100644 --- a/packages/i18n/src/locales/vi/settings.yaml +++ b/packages/i18n/src/locales/vi/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: Xin chào! Đây là bản thử nghiệm hệ thống giọng nói Kokoro. title: Thử nghiệm giọng nói title: Kokoro TTS (Cục bộ) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 40dc75dd95..112cc3e6f9 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -776,6 +776,9 @@ pages: default-text: 您好!这是 Kokoro 文本转语音(TTS)系统的测试。 title: 实验平台 title: Kokoro TTS (本地) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: Fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/zh-Hant/settings.yaml b/packages/i18n/src/locales/zh-Hant/settings.yaml index 0c430d72df..15599339c9 100644 --- a/packages/i18n/src/locales/zh-Hant/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/settings.yaml @@ -774,6 +774,9 @@ pages: default-text: 您好!這是 Kokoro 文字轉語音系統的測試。 title: 語音測試場 title: Kokoro TTS (本地) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: Fireworks.ai title: Fireworks.ai diff --git a/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue new file mode 100644 index 0000000000..9151358163 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue @@ -0,0 +1,84 @@ + + + + + + meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue index d679c4dbd7..2eb8f9f6c1 100644 --- a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue +++ b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue @@ -33,6 +33,7 @@ defineSlots<{ 'advanced-settings': (props: any) => any 'playground': (props: any) => any }>() + const { t } = useI18n() const router = useRouter() const providersStore = useProvidersStore() diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 0fb3e65de8..41c7b4189e 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -314,7 +314,13 @@ const speechPipeline = createSpeechPipeline({ const audioBuffer = await audioContext.decodeAudioData(res) return audioBuffer } - catch { + catch (error) { + console.error('[Speech Pipeline] TTS failed:', { + provider: activeSpeechProvider.value, + model: activeSpeechModel.value, + voice: activeSpeechVoice.value?.id, + error, + }) return null } }, diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 52012fcb62..795ef04d75 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -20,7 +20,7 @@ import type { import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription' -import { isStageTamagotchi, isUrl } from '@proj-airi/stage-shared' +import { isElectronWindow, isStageCapacitor, isStageTamagotchi, isStageWeb, isUrl } from '@proj-airi/stage-shared' import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core' import { createOpenAI, @@ -58,6 +58,8 @@ import { buildOpenAICompatibleProvider } from './providers/openai-compatible-bui import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech' import { createWebSpeechAPIProvider } from './providers/web-speech-api' +const TRAILING_SLASH_RE = /\/$/ + const ALIYUN_NLS_REGIONS = [ 'cn-shanghai', 'cn-shanghai-internal', @@ -1540,6 +1542,182 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, + 'fish-audio': { + id: 'fish-audio', + category: 'speech', + tasks: ['text-to-speech'], + nameKey: 'settings.pages.providers.provider.fish-audio.title', + name: 'Fish Audio', + descriptionKey: 'settings.pages.providers.provider.fish-audio.description', + description: 'fish.audio', + icon: 'i-lobe-icons:fishaudio', + defaultOptions: () => ({ + baseUrl: 'https://api.fish.audio', + }), + createProvider: async (config) => { + const apiKey = (config.apiKey as string ?? '').trim() + const baseUrl = ((config.baseUrl as string) || 'https://api.fish.audio').replace(TRAILING_SLASH_RE, '') + // NOTICE: Fish Audio's API is server-to-server only and does not send CORS + // headers for browser origins. Depending on the runtime, we choose one of + // three strategies to avoid CORS failures: + // + // 1. Electron — delegate to the main process via Eventa IPC. The main + // process uses net.fetch which runs in Node and is never subject to + // browser CORS enforcement. The IPC contract is defined in + // apps/stage-tamagotchi/src/shared/eventa.ts (electronFishAudioTTS). + // + // 2. Browser DEV (stage-web / stage-pocket) — route through the local + // Vite dev-server proxy (/fish-audio-api → https://api.fish.audio) so + // the request appears same-origin. See vite.config.ts in each app for + // the matching server.proxy entry. + // + // 3. Everything else (custom base URL, Capacitor native) — use the URL + // as-is. Capacitor native builds use the OS HTTP stack and are not + // subject to browser CORS. A custom base URL means the user has their + // own same-origin proxy and needs no rewrite. + const electronWindow = isStageTamagotchi() ? isElectronWindow(window) && window : false + const effectiveBase = (!electronWindow && import.meta.env.DEV && baseUrl === 'https://api.fish.audio') + ? '/fish-audio-api' + : baseUrl + const provider: SpeechProvider = { + speech: (model: string) => ({ + // NOTICE: baseURL must be an absolute URL — @xsai/generate-speech calls + // `new URL('audio/speech', baseURL)` internally. Our custom fetch below + // ignores the URL argument entirely and builds its own, so the value here + // only needs to be valid; we always keep the original absolute baseUrl. + baseURL: `${baseUrl}/`, + model, + // NOTICE: Fish Audio does not use the OpenAI /audio/speech endpoint format. + // We intercept the xsai generateSpeech request and translate it to + // Fish Audio's POST /v1/tts format (text/reference_id in body). + // + // NOTICE: The Fish Audio API accepts the model via a `model` request header, + // but sending custom headers from the browser triggers a CORS preflight that + // Fish Audio's CDN does not allow (Access-Control-Allow-Headers does not + // include `model`). The Fish Audio JS/Python SDKs also never send `model` as + // a header — they rely on the server default (`s2-pro`). We do the same here. + fetch: async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse((init?.body as string) ?? '{}') as { + input?: string + voice?: string + } + if (electronWindow) { + // Delegate to main process to bypass renderer CORS. + // defineInvoke returns a typed invoker function; call it with the payload. + const { defineInvoke } = await import('@moeru/eventa') + const { createContext } = await import('@moeru/eventa/adapters/electron/renderer') + const { electronFishAudioTTS } = await import('./providers/fish-audio/ipc') + const { context } = createContext(electronWindow.electron.ipcRenderer) + // NOTICE: AbortSignal is not serializable over IPC and cannot be + // forwarded to the main process. Cancellation of in-flight IPC + // requests is not yet supported for this path. + const invokeTTS = defineInvoke(context, electronFishAudioTTS) + const result = await invokeTTS({ apiKey, baseUrl, text: body.input ?? '', referenceId: body.voice || null }) + return new Response(result.data.buffer as ArrayBuffer, { + status: result.status, + headers: { 'Content-Type': result.contentType }, + }) + } + return fetch(`${effectiveBase}/v1/tts`, { + method: 'POST', + // Forward the AbortSignal so the HTTP request is cancelled when + // the TTS pipeline is aborted (e.g. user interrupts playback). + signal: init?.signal, + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: body.input ?? '', + reference_id: body.voice || null, + format: 'mp3', + }), + }) + }, + }), + } + return provider + }, + capabilities: { + // NOTICE: The Fish Audio API selects the model via a `model` HTTP header. + // Sending that custom header from a browser causes CORS preflight failures, + // so we cannot forward model selection to the API. The server defaults to + // s2-pro when no header is present. Listing models here is informational only. + listModels: async () => [ + { + id: 's2-pro', + name: 'S2 Pro', + provider: 'fish-audio', + description: 'Latest generation model (server default)', + contextLength: 0, + deprecated: false, + }, + ], + listVoices: async (config) => { + const { listFishAudioVoices } = await import('./providers/fish-audio/list-voices') + const rawBase = ((config.baseUrl as string) || 'https://api.fish.audio').replace(TRAILING_SLASH_RE, '') + const isElectronRenderer = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') + const effectiveVoiceBase = (import.meta.env.DEV && !isElectronRenderer && rawBase === 'https://api.fish.audio') + ? '/fish-audio-api' + : rawBase + return listFishAudioVoices( + ((config.apiKey as string) ?? '').trim(), + effectiveVoiceBase, + ) + }, + }, + validators: { + chatPingCheckAvailable: false, + validateProviderConfig: (config) => { + const errors: Error[] = [] + + if (!config.apiKey) { + errors.push(new Error('API key is required.')) + } + + // NOTICE: We intentionally skip the shared baseUrlValidator here because + // it requires a trailing slash for standard URL composition. Fish Audio's + // custom fetch override ignores the passed URL and constructs its own, so + // trailing-slash strictness is irrelevant. We only verify it's a valid + // absolute URL when one is explicitly provided. + if (config.baseUrl) { + try { + const parsed = new URL(config.baseUrl as string) + if (!parsed.host) { + errors.push(new Error('Base URL must have a valid host.')) + } + } + catch { + errors.push(new Error('Base URL is not a valid absolute URL.')) + } + } + + // Gate the default Fish Audio URL in contexts where browser CORS will + // block the request and no proxy/IPC path exists. + // + // - Electron: handled via IPC (electronFishAudioTTS) — no gate needed. + // - Capacitor native (iOS/Android): OS HTTP stack bypasses CORS — no gate. + // - Web/Capacitor production: Vite dev-server proxy is absent; users must + // point baseUrl at a same-origin CORS proxy they control. + // Normalize before comparison: strip trailing slashes the same way + // createProvider does, so 'https://api.fish.audio/' is caught too. + const normalizedConfigUrl = ((config.baseUrl as string) || '').replace(TRAILING_SLASH_RE, '') + const usingDefaultUrl = !config.baseUrl || normalizedConfigUrl === 'https://api.fish.audio' + if (usingDefaultUrl && (isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { + errors.push(new Error( + 'Fish Audio requires a custom base URL (a CORS proxy) in production builds. ' + + 'The dev-server proxy is only available during local development.', + )) + } + + return { + errors, + reason: errors.map(e => e.message).join(', '), + valid: errors.length === 0, + } + }, + }, + }, 'kokoro-local': { id: 'kokoro-local', category: 'speech', diff --git a/packages/stage-ui/src/stores/providers/fish-audio/ipc.ts b/packages/stage-ui/src/stores/providers/fish-audio/ipc.ts new file mode 100644 index 0000000000..8a94de43a1 --- /dev/null +++ b/packages/stage-ui/src/stores/providers/fish-audio/ipc.ts @@ -0,0 +1,32 @@ +import { defineInvokeEventa } from '@moeru/eventa' + +/** + * Payload sent from the Electron renderer to the main process for a Fish Audio + * TTS request. AbortSignal is intentionally omitted — it is not serializable + * across IPC and cancellation must be handled at a higher level if needed. + */ +export interface ElectronFishAudioTTSPayload { + apiKey: string + baseUrl: string + text: string + referenceId?: string | null +} + +/** Response returned by the main process after proxying the Fish Audio request. */ +export interface ElectronFishAudioTTSResult { + /** Raw audio bytes as a structured-clone-safe Uint8Array. */ + data: Uint8Array + contentType: string + status: number +} + +/** + * Eventa IPC contract for proxying Fish Audio TTS through the Electron main + * process. The main process uses net.fetch which runs in Node and is therefore + * not subject to browser CORS enforcement. + * + * Handler: apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts + */ +export const electronFishAudioTTS = defineInvokeEventa( + 'eventa:invoke:electron:providers:fish-audio:tts', +) diff --git a/packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts b/packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts new file mode 100644 index 0000000000..85a76aa438 --- /dev/null +++ b/packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts @@ -0,0 +1,66 @@ +import type { VoiceInfo } from '../../providers' + +export interface FishAudioModelEntity { + _id: string + type: 'svc' | 'tts' + title: string + description: string + state: 'created' | 'training' | 'trained' | 'failed' + samples: Array<{ audio: string }> + languages: string[] +} + +interface FishAudioModelListResponse { + items: FishAudioModelEntity[] +} + +const TRAILING_SLASH_RE = /\/$/ + +function mapModelToVoiceInfo(model: FishAudioModelEntity): VoiceInfo { + return { + id: model._id, + name: model.title, + provider: 'fish-audio', + description: model.description || undefined, + previewURL: model.samples?.[0]?.audio || undefined, + languages: model.languages.length > 0 + ? model.languages.map(lang => ({ code: lang, title: lang })) + : [{ code: 'en', title: 'English' }], + } +} + +async function fetchModels(url: string, headers: HeadersInit): Promise { + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error(`Failed to fetch Fish Audio voices: ${response.status} ${response.statusText}`) + } + const data = await response.json() as FishAudioModelListResponse + return data.items.filter(model => model.state === 'trained' && model.type === 'tts') +} + +/** + * Fetches voice models from Fish Audio and maps them to VoiceInfo. + * + * Fetches both the user's own models (GET /model?self=true) and the top + * community/public models (GET /model?sort_by=task_count), merges them + * deduped by ID, with own models listed first. + */ +export async function listFishAudioVoices(apiKey: string, baseUrl: string): Promise { + const base = baseUrl.replace(TRAILING_SLASH_RE, '') + const headers: HeadersInit = { Authorization: `Bearer ${apiKey}` } + + // Fetch own models and popular public models in parallel + const [ownModels, publicModels] = await Promise.all([ + fetchModels(`${base}/model?self=true&page_size=100`, headers), + fetchModels(`${base}/model?sort_by=task_count&page_size=100`, headers), + ]) + + // Merge: own models first, then public models not already in the own list + const ownIds = new Set(ownModels.map(m => m._id)) + const merged = [ + ...ownModels, + ...publicModels.filter(m => !ownIds.has(m._id)), + ] + + return merged.map(mapModelToVoiceInfo) +}