feat: route Fish Audio TTS through Electron main process via IPC#1604
feat: route Fish Audio TTS through Electron main process via IPC#1604xuan0x0 wants to merge 12 commits intomoeru-ai:mainfrom
Conversation
- 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
…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
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
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.
….com/xuan0x0/airi into xuanpan/feat/fish-audio-tts-provider
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
Adds an Eventa IPC path so the Electron renderer never makes a direct cross-origin request to api.fish.audio (which would be blocked by CORS): - packages/stage-ui/src/stores/providers/fish-audio/ipc.ts New file. Defines the electronFishAudioTTS Eventa contract and its payload/result types. Lives in the package so both the renderer and the main-process service can import it without an app→package inversion. - apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts New file. Main-process handler that uses net.fetch (Node, no CORS) to forward the TTS request to Fish Audio and returns the raw audio bytes as a structured-clone-safe Uint8Array. - apps/stage-tamagotchi/src/main/windows/shared/window.ts Registers createFishAudioService in setupBaseWindowElectronInvokes so the handler is available to all renderer windows. - apps/stage-tamagotchi/src/shared/eventa.ts Re-exports the contract from packages/stage-ui to keep the eventa contract centrally discoverable. - packages/stage-ui/src/stores/providers.ts In Electron, the custom fetch override now dynamically imports the Eventa adapter and delegates to the main process. The previous validateProviderConfig gate for Electron is removed since the IPC path is now in place. The DEV Vite proxy path is preserved for web/pocket browser development. Made-with: Cursor
⏳ Approval required for deploying to Cloudflare Workers (Preview) for stage-web.
Hey, @nekomeowww, @sumimakito, @luoling8192, @LemonNekoGH, kindly take some time to review and approve this deployment when you are available. Thank you! 🙏 |
Made-with: Cursor
There was a problem hiding this comment.
Code Review
This pull request integrates Fish Audio TTS support, addressing CORS constraints through environment-specific solutions such as an Electron IPC bridge and Vite dev-server proxies. It also adds a dedicated settings page and updates the provider store with validation and voice-listing logic. The review feedback suggests optimizing the Electron IPC invoker initialization to avoid redundant dynamic imports, using safer methods for handling binary audio data in responses, and correcting a validation check that unnecessarily restricts Capacitor production builds.
| 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) |
There was a problem hiding this comment.
Dynamic imports and IPC setup are performed on every fetch call, which is inefficient for repeated TTS requests. Since createProvider is already an asynchronous function, these should be moved to the provider initialization phase (around lines 1578-1581) and the resulting invoker should be used inside the fetch closure.
| // 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, { |
There was a problem hiding this comment.
It is safer to pass the Uint8Array directly to the Response constructor rather than accessing its underlying .buffer. If the array is a view (e.g., has an offset), using .buffer will return the entire buffer, potentially including unrelated data.
| return new Response(result.data.buffer as ArrayBuffer, { | |
| return new Response(result.data, { |
| // - Web/Capacitor production: Vite dev-server proxy is absent; users must | ||
| // point baseUrl at a same-origin CORS proxy they control. | ||
| const usingDefaultUrl = !config.baseUrl || config.baseUrl === 'https://api.fish.audio' | ||
| if (usingDefaultUrl && (isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { |
There was a problem hiding this comment.
There is a contradiction between the PR description and this validation logic. The description states that Capacitor native builds bypass CORS via the OS HTTP stack, but this code blocks the default Fish Audio URL for isStageCapacitor() in production. If Capacitor native indeed works without a proxy, it should be removed from this gate.
| if (usingDefaultUrl && (isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { | |
| if (usingDefaultUrl && isStageWeb() && !import.meta.env.DEV) { |
Summary
Follow-up to #1526. Resolves the P1 Codex review finding that the
Electron renderer makes a direct cross-origin request to
api.fish.audio,which is blocked by browser CORS — Fish Audio's CDN does not send
Access-Control-Allow-Originfor browser origins.The fix routes the request through the Electron main process via Eventa IPC.
net.fetchin the main process runs in Node and is not subject to browserCORS enforcement.
Changes
New:
packages/stage-ui/src/stores/providers/fish-audio/ipc.tsDefines the
electronFishAudioTTSEventa IPC contract and its typedpayload/result interfaces. Placed in
packages/stage-ui(not in the app)so both the renderer and the main-process service can import it without
creating an app → package dependency inversion.
New:
apps/stage-tamagotchi/src/main/services/electron/fish-audio.tsMain-process
defineInvokeHandlerthat forwards a TTS request to FishAudio using
net.fetch, then returns the raw audio as a structured-clone-safeUint8Arrayalongside the response status and content-type.apps/stage-tamagotchi/src/main/windows/shared/window.tsRegisters
createFishAudioServiceinsidesetupBaseWindowElectronInvokesso the handler is available to every renderer window (main, settings, chat…).
apps/stage-tamagotchi/src/shared/eventa.tsRe-exports the contract from
packages/stage-uifor central discoverabilityalongside the other Eventa contracts.
packages/stage-ui/src/stores/providers.tsfetchoverride now dynamically imports@moeru/eventa/adapters/electron/rendererand delegates to the mainprocess via
defineInvoke.isElectronWindownarrowswindowbeforeaccessing
window.electron.ipcRenderer.validateProviderConfig(introduced in the parent PR)is removed — the IPC path makes it unnecessary.
/fish-audio-api) is preserved for browser-basedruntimes (stage-web, stage-pocket browser).
AbortSignalis not forwarded over IPC (not serializable); a// NOTICE:comment documents this known limitation.
Runtime coverage after this PR
net.fetchin main process (no CORS)/fish-audio-api/fish-audio-apivalidateProviderConfigsurfaces a clear error directing users to set a custom proxy URLTest plan
/fish-audio-api)