From 71bd789da9c91269ee779fac1eae70a667ff825d Mon Sep 17 00:00:00 2001 From: xuan pan Date: Mon, 30 Mar 2026 14:38:42 -0400 Subject: [PATCH 1/7] feat: add Fish Audio as native TTS provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fish-audio provider entry with a custom fetch override that translates xsai generateSpeech requests to Fish Audio's POST /v1/tts format (text + reference_id in body) - Add Vite dev proxy (/fish-audio-api → https://api.fish.audio) to bypass browser CORS — Fish Audio's API is server-to-server only and does not send Access-Control-Allow-Origin for browser origins - Add listFishAudioVoices helper that fetches own models and top public models in parallel, merges and deduplicates them with own models first - Add fish-audio settings page with SpeechPlayground integration - Add i18n strings for all 9 supported locales - Improve TTS error logging in Stage.vue (was silently swallowing errors) Made-with: Cursor --- apps/stage-web/vite.config.ts | 13 ++ packages/i18n/src/locales/en/settings.yaml | 3 + packages/i18n/src/locales/es/settings.yaml | 3 + packages/i18n/src/locales/fr/settings.yaml | 3 + packages/i18n/src/locales/ja/settings.yaml | 3 + packages/i18n/src/locales/ko/settings.yaml | 3 + packages/i18n/src/locales/ru/settings.yaml | 3 + packages/i18n/src/locales/vi/settings.yaml | 3 + .../i18n/src/locales/zh-Hans/settings.yaml | 3 + .../i18n/src/locales/zh-Hant/settings.yaml | 3 + .../settings/providers/speech/fish-audio.vue | 79 +++++++++++ .../providers/speech-provider-settings.vue | 1 + .../stage-ui/src/components/scenes/Stage.vue | 8 +- packages/stage-ui/src/stores/providers.ts | 126 ++++++++++++++++++ .../providers/fish-audio/list-voices.ts | 66 +++++++++ 15 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue create mode 100644 packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index 925b00af96..e1207c1485 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -21,6 +21,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/ export default defineConfig({ optimizeDeps: { @@ -68,6 +69,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 1e48dbf047..78d689457b 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -780,6 +780,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 af69b6e632..22fa956b7f 100644 --- a/packages/i18n/src/locales/es/settings.yaml +++ b/packages/i18n/src/locales/es/settings.yaml @@ -745,6 +745,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 f9acbe0398..0a7c990f14 100644 --- a/packages/i18n/src/locales/fr/settings.yaml +++ b/packages/i18n/src/locales/fr/settings.yaml @@ -745,6 +745,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 41ceacf1af..eefa073531 100644 --- a/packages/i18n/src/locales/ja/settings.yaml +++ b/packages/i18n/src/locales/ja/settings.yaml @@ -745,6 +745,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 83164fbd11..8516200e0b 100644 --- a/packages/i18n/src/locales/ko/settings.yaml +++ b/packages/i18n/src/locales/ko/settings.yaml @@ -745,6 +745,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 b753b11551..a4b60fac52 100644 --- a/packages/i18n/src/locales/ru/settings.yaml +++ b/packages/i18n/src/locales/ru/settings.yaml @@ -745,6 +745,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 549aefe658..94c5ec690e 100644 --- a/packages/i18n/src/locales/vi/settings.yaml +++ b/packages/i18n/src/locales/vi/settings.yaml @@ -745,6 +745,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 57ebc40dbd..7ea215c1f1 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -745,6 +745,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 96b03dd3d3..33c35b98a1 100644 --- a/packages/i18n/src/locales/zh-Hant/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/settings.yaml @@ -745,6 +745,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..fbbfdb8243 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue @@ -0,0 +1,79 @@ + + + + + + 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 d6b53c812e..08041d6b74 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -312,7 +312,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..a2a5e236da 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -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,130 @@ 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. In Vite dev mode we route through the local + // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request + // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own + // proxy) bypass this logic and are used as-is. + // See apps/stage-web/vite.config.ts for the matching server.proxy entry. + const effectiveBase = (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 + } + return fetch(`${effectiveBase}/v1/tts`, { + method: 'POST', + 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 effectiveVoiceBase = (import.meta.env.DEV && 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.')) + } + } + + 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/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) +} From 76daef5ab2e99038ad6b6ebeab9ce8bb21f96237 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Mon, 30 Mar 2026 15:08:08 -0400 Subject: [PATCH 2/7] fix(fish-audio): forward AbortSignal, guard Electron proxy, debounce voice reload - Forward init.signal to the custom Fish Audio fetch so the HTTP request is cancelled when the TTS pipeline is aborted (e.g. user interrupts playback) - Guard the Vite dev-server proxy rewrite with a userAgent Electron check; Electron's renderer has no matching proxy route so the rewrite caused 404s - Debounce the apiKey/baseUrl watcher in fish-audio.vue (800 ms) to avoid firing repeated requests with partial credentials on every keystroke Made-with: Cursor --- .../pages/settings/providers/speech/fish-audio.vue | 7 ++++++- packages/stage-ui/src/stores/providers.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) 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 index fbbfdb8243..9151358163 100644 --- a/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue +++ b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue @@ -7,6 +7,7 @@ import { } from '@proj-airi/stage-ui/components' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' +import { useDebounceFn } from '@vueuse/core' import { storeToRefs } from 'pinia' import { computed, onMounted, watch } from 'vue' @@ -45,12 +46,16 @@ async function tryLoadVoices() { } } +// Debounced so rapid keystrokes while editing API key / base URL don't fire +// repeated requests with partial/invalid credentials. +const debouncedLoadVoices = useDebounceFn(tryLoadVoices, 800) + onMounted(tryLoadVoices) // Reload voices whenever the API key or base URL changes watch( () => [providers.value[providerId]?.apiKey, providers.value[providerId]?.baseUrl], - tryLoadVoices, + debouncedLoadVoices, ) diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index a2a5e236da..def26d7028 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1563,7 +1563,12 @@ export const useProvidersStore = defineStore('providers', () => { // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own // proxy) bypass this logic and are used as-is. // See apps/stage-web/vite.config.ts for the matching server.proxy entry. - const effectiveBase = (import.meta.env.DEV && baseUrl === 'https://api.fish.audio') + // + // NOTICE: The proxy only exists in the stage-web Vite dev server. + // Electron's renderer dev server has no matching proxy route, so we must + // skip the rewrite when running inside Electron to avoid 404s. + const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') + const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' : baseUrl const provider: SpeechProvider = { @@ -1590,6 +1595,9 @@ export const useProvidersStore = defineStore('providers', () => { } 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', @@ -1623,7 +1631,8 @@ export const useProvidersStore = defineStore('providers', () => { 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 effectiveVoiceBase = (import.meta.env.DEV && rawBase === 'https://api.fish.audio') + 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( From 8b2d2fe15e3cd093e7ef44f467e0c23d09ce7520 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 20:20:29 -0400 Subject: [PATCH 3/7] fix: gate Fish Audio dev proxy to all browser-based Vite runtimes Add /fish-audio-api proxy to apps/stage-pocket/vite.config.ts so that stage-pocket DEV builds don't 404 when providers.ts rewrites the base URL. Previously the rewrite was only guarded against Electron, causing 404s in Capacitor dev mode where no matching proxy existed. Update the NOTICE comment in providers.ts to reference both vite configs. Made-with: Cursor --- apps/stage-pocket/vite.config.ts | 13 +++++++++++++ packages/stage-ui/src/stores/providers.ts | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 05f25ea36a..f62ef96ffb 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/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index def26d7028..7edd6610ed 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1562,11 +1562,12 @@ export const useProvidersStore = defineStore('providers', () => { // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own // proxy) bypass this logic and are used as-is. - // See apps/stage-web/vite.config.ts for the matching server.proxy entry. + // See apps/stage-web/vite.config.ts and apps/stage-pocket/vite.config.ts for the + // matching server.proxy entries. // - // NOTICE: The proxy only exists in the stage-web Vite dev server. - // Electron's renderer dev server has no matching proxy route, so we must - // skip the rewrite when running inside Electron to avoid 404s. + // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, + // stage-pocket). Electron's renderer dev server has no matching proxy route, so + // we must skip the rewrite when running inside Electron to avoid 404s. const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' From 6b6f78e4a6f8581ef79c958f862c06d11f3171e0 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 20:20:29 -0400 Subject: [PATCH 4/7] fix: gate Fish Audio dev proxy to all browser-based Vite runtimes Add /fish-audio-api proxy to apps/stage-pocket/vite.config.ts so that stage-pocket DEV builds don't 404 when providers.ts rewrites the base URL. Previously the rewrite was only guarded against Electron, causing 404s in Capacitor dev mode where no matching proxy existed. Update the NOTICE comment in providers.ts to reference both vite configs. --- apps/stage-pocket/vite.config.ts | 13 +++++++++++++ packages/stage-ui/src/stores/providers.ts | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 05f25ea36a..f62ef96ffb 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/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index def26d7028..7edd6610ed 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1562,11 +1562,12 @@ export const useProvidersStore = defineStore('providers', () => { // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own // proxy) bypass this logic and are used as-is. - // See apps/stage-web/vite.config.ts for the matching server.proxy entry. + // See apps/stage-web/vite.config.ts and apps/stage-pocket/vite.config.ts for the + // matching server.proxy entries. // - // NOTICE: The proxy only exists in the stage-web Vite dev server. - // Electron's renderer dev server has no matching proxy route, so we must - // skip the rewrite when running inside Electron to avoid 404s. + // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, + // stage-pocket). Electron's renderer dev server has no matching proxy route, so + // we must skip the rewrite when running inside Electron to avoid 404s. const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' From 540df09ba773f63f573fdb8da53f6335c9aecc06 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 22:07:11 -0400 Subject: [PATCH 5/7] fix: gate Fish Audio default URL in non-DEV and Electron contexts validateProviderConfig now surfaces a clear error when the default api.fish.audio URL is used in contexts where browser CORS will block the request and no proxy/IPC path exists: - Electron (all modes): renderer enforces CORS; IPC path not yet built - Web/Capacitor production: Vite dev-server proxy is absent Capacitor native builds (iOS/Android) are exempt because the OS HTTP stack is not subject to browser CORS. Users with a custom base URL (self-hosted CORS proxy) are unaffected. Adds a TODO comment marking the Electron IPC path as follow-up work. Made-with: Cursor --- packages/stage-ui/src/stores/providers.ts | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 7edd6610ed..713dc9b503 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 { isStageCapacitor, isStageTamagotchi, isStageWeb, isUrl } from '@proj-airi/stage-shared' import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core' import { createOpenAI, @@ -1568,6 +1568,11 @@ export const useProvidersStore = defineStore('providers', () => { // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, // stage-pocket). Electron's renderer dev server has no matching proxy route, so // we must skip the rewrite when running inside Electron to avoid 404s. + // + // TODO: Add an Eventa IPC handler (electronFetchFishAudio) so the Electron main + // process can perform the request via net.fetch (no CORS restrictions in Node). + // Until that path exists, Fish Audio with the default URL is gated out of + // Electron via validateProviderConfig below. const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' @@ -1668,6 +1673,31 @@ export const useProvidersStore = defineStore('providers', () => { } } + // Gate the default Fish Audio URL in contexts where browser CORS will block + // the request and no local proxy or IPC path exists yet. + // + // - Electron (all modes): renderer enforces CORS; main-process IPC path not yet + // implemented. TODO: remove this gate once electronFetchFishAudio IPC exists. + // - Web/Capacitor production: Vite dev-server proxy is absent. Users who need + // production access must point baseUrl at a same-origin CORS proxy they control. + // - Capacitor native (iOS/Android): exempt — Capacitor uses the OS HTTP stack + // which is not subject to browser CORS restrictions. + const usingDefaultUrl = !config.baseUrl || config.baseUrl === 'https://api.fish.audio' + if (usingDefaultUrl) { + if (isStageTamagotchi()) { + errors.push(new Error( + 'Fish Audio requires a custom base URL (a CORS proxy) when running in the desktop app. ' + + 'Native IPC support is not yet available.', + )) + } + else if ((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(', '), From b9bedaee1c41fc56f94cb97617341825dbe6c1ce Mon Sep 17 00:00:00 2001 From: xuan pan Date: Sun, 19 Apr 2026 12:12:17 -0400 Subject: [PATCH 6/7] Update providers.ts --- packages/stage-ui/src/stores/providers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index b0b890e49a..96537c379d 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 { isStageCapacitor, isStageTamagotchi, isStageWeb, isUrl } from '@proj-airi/stage-shared' import { getCachedWebGPUCapabilities, isWebGPUSupported } from '@proj-airi/stage-shared/webgpu' import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core' import { @@ -1881,7 +1881,7 @@ export const useProvidersStore = defineStore('providers', () => { defaultOptions: () => { const capabilities = getCachedWebGPUCapabilities() - const hasWebGPU = capabilities?.supported ?? (typeof navigator !== 'undefined' && !!navigator.gpu) + const hasWebGPU = capabilities?.supported ?? (typeof navigator !== 'undefined' && 'gpu' in navigator) const fp16Supported = capabilities?.fp16Supported ?? false const model = getDefaultKokoroModel(hasWebGPU, fp16Supported) return { @@ -1938,7 +1938,7 @@ export const useProvidersStore = defineStore('providers', () => { capabilities: { listModels: async (_config: Record) => { const caps = getCachedWebGPUCapabilities() - const hasWebGPU = caps?.supported ?? (typeof navigator !== 'undefined' && !!navigator.gpu) + const hasWebGPU = caps?.supported ?? (typeof navigator !== 'undefined' && 'gpu' in navigator) const fp16Supported = caps?.fp16Supported ?? false return kokoroModelsToModelInfo(hasWebGPU, t, fp16Supported) }, @@ -1957,7 +1957,7 @@ export const useProvidersStore = defineStore('providers', () => { // Validate platform requirements if (modelDef.platform === 'webgpu') { - const hasWebGPU = getCachedWebGPUCapabilities()?.supported ?? (typeof navigator !== 'undefined' && !!navigator.gpu) + const hasWebGPU = getCachedWebGPUCapabilities()?.supported ?? (typeof navigator !== 'undefined' && 'gpu' in navigator) if (!hasWebGPU) { throw new Error('WebGPU is required for this model but is not available in your browser') } @@ -2001,7 +2001,7 @@ export const useProvidersStore = defineStore('providers', () => { const modelDef = KOKORO_MODELS.find(m => m.id === modelId) if (modelDef) { if (modelDef.platform === 'webgpu') { - const hasWebGPU = getCachedWebGPUCapabilities()?.supported ?? (typeof navigator !== 'undefined' && !!navigator.gpu) + const hasWebGPU = getCachedWebGPUCapabilities()?.supported ?? (typeof navigator !== 'undefined' && 'gpu' in navigator) if (!hasWebGPU) { throw new Error('WebGPU is required for this model but is not available in your browser') } From 50aa33ca34b313440ec36f79c77e40387e2d8077 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Sun, 19 Apr 2026 12:30:48 -0400 Subject: [PATCH 7/7] fix(typecheck): replace navigator.gpu with type-safe 'gpu' in navigator guard `navigator.gpu` is a WebGPU-only property not present in the standard TypeScript DOM lib, causing TS2339 errors in packages whose tsconfigs do not include WebGPU types. Replace all live usages of `!!navigator.gpu` with `'gpu' in navigator` (boolean presence check, no extra lib needed), and for call sites that also invoke `navigator.gpu.requestAdapter()` use a minimal structural cast after the `in` guard confirms existence. Made-with: Cursor --- .../pages/settings/providers/speech/kokoro-local.vue | 2 +- packages/stage-ui/src/libs/workers/worker.ts | 11 +++++++++-- .../stage-ui/src/workers/background-removal/worker.ts | 11 +++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/stage-pages/src/pages/settings/providers/speech/kokoro-local.vue b/packages/stage-pages/src/pages/settings/providers/speech/kokoro-local.vue index 44f3cfce1b..47871c3ad3 100644 --- a/packages/stage-pages/src/pages/settings/providers/speech/kokoro-local.vue +++ b/packages/stage-pages/src/pages/settings/providers/speech/kokoro-local.vue @@ -104,7 +104,7 @@ onMounted(async () => { // NOTICE: Uses synchronous check for initial render. The cached result from // detectWebGPU() is populated by the providers store during initialization. const capabilities = getCachedWebGPUCapabilities() - hasWebGPU.value = capabilities?.supported ?? (typeof navigator !== 'undefined' && !!navigator.gpu) + hasWebGPU.value = capabilities?.supported ?? (typeof navigator !== 'undefined' && 'gpu' in navigator) fp16Supported.value = capabilities?.fp16Supported ?? false try { diff --git a/packages/stage-ui/src/libs/workers/worker.ts b/packages/stage-ui/src/libs/workers/worker.ts index 0ee1720c56..2f285396b4 100644 --- a/packages/stage-ui/src/libs/workers/worker.ts +++ b/packages/stage-ui/src/libs/workers/worker.ts @@ -75,9 +75,16 @@ const MODEL_ID = MODEL_IDS.WHISPER */ async function detectWebGPUInWorker(): Promise { try { - if (typeof navigator === 'undefined' || !navigator.gpu) + if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false - const adapter = await navigator.gpu.requestAdapter() + // NOTICE: + // `navigator.gpu` is a WebGPU API not present in the standard TypeScript DOM lib. + // The `'gpu' in navigator` guard above confirms presence at runtime. + // Casting to a minimal structural type avoids requiring @webgpu/types or the + // webgpu lib (which TypeScript does not ship) across all tsconfigs that include + // this file via path aliases. + const gpu = (navigator as Navigator & { gpu: { requestAdapter: () => Promise } }).gpu + const adapter = await gpu.requestAdapter() return adapter != null } catch { diff --git a/packages/stage-ui/src/workers/background-removal/worker.ts b/packages/stage-ui/src/workers/background-removal/worker.ts index 66a59b1522..980aa0775d 100644 --- a/packages/stage-ui/src/workers/background-removal/worker.ts +++ b/packages/stage-ui/src/workers/background-removal/worker.ts @@ -80,9 +80,16 @@ function sendError(requestId: string, error: unknown, phase?: 'load' | 'inferenc */ async function detectWebGPUInWorker(): Promise { try { - if (typeof navigator === 'undefined' || !navigator.gpu) + if (typeof navigator === 'undefined' || !('gpu' in navigator)) return false - const adapter = await navigator.gpu.requestAdapter() + // NOTICE: + // `navigator.gpu` is a WebGPU API not present in the standard TypeScript DOM lib. + // The `'gpu' in navigator` guard above confirms presence at runtime. + // Casting to a minimal structural type avoids requiring @webgpu/types or the + // webgpu lib (which TypeScript does not ship) across all tsconfigs that include + // this file via path aliases. + const gpu = (navigator as Navigator & { gpu: { requestAdapter: () => Promise } }).gpu + const adapter = await gpu.requestAdapter() return adapter != null } catch {