Skip to content

feat: route Fish Audio TTS through Electron main process via IPC#1604

Draft
xuan0x0 wants to merge 12 commits intomoeru-ai:mainfrom
xuan0x0:xuanpan/feat/fish-audio-electron-ipc
Draft

feat: route Fish Audio TTS through Electron main process via IPC#1604
xuan0x0 wants to merge 12 commits intomoeru-ai:mainfrom
xuan0x0:xuanpan/feat/fish-audio-electron-ipc

Conversation

@xuan0x0
Copy link
Copy Markdown
Contributor

@xuan0x0 xuan0x0 commented Apr 8, 2026

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-Origin for browser origins.

The fix routes the request through the Electron main process via Eventa IPC.
net.fetch in the main process runs in Node and is not subject to browser
CORS enforcement.

Changes

New: packages/stage-ui/src/stores/providers/fish-audio/ipc.ts

Defines the electronFishAudioTTS Eventa IPC contract and its typed
payload/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.ts

Main-process defineInvokeHandler that forwards a TTS request to Fish
Audio using net.fetch, then returns the raw audio as a structured-clone-safe
Uint8Array alongside the response status and content-type.

apps/stage-tamagotchi/src/main/windows/shared/window.ts

Registers createFishAudioService inside setupBaseWindowElectronInvokes
so the handler is available to every renderer window (main, settings, chat…).

apps/stage-tamagotchi/src/shared/eventa.ts

Re-exports the contract from packages/stage-ui for central discoverability
alongside the other Eventa contracts.

packages/stage-ui/src/stores/providers.ts

  • In Electron, the custom fetch override now dynamically imports
    @moeru/eventa/adapters/electron/renderer and delegates to the main
    process via defineInvoke. isElectronWindow narrows window before
    accessing window.electron.ipcRenderer.
  • The Electron gate in validateProviderConfig (introduced in the parent PR)
    is removed — the IPC path makes it unnecessary.
  • The DEV Vite-proxy path (/fish-audio-api) is preserved for browser-based
    runtimes (stage-web, stage-pocket browser).
  • AbortSignal is not forwarded over IPC (not serializable); a // NOTICE:
    comment documents this known limitation.

Runtime coverage after this PR

Runtime Strategy
Electron (all modes) ✅ IPC → net.fetch in main process (no CORS)
stage-web DEV ✅ Vite proxy /fish-audio-api
stage-pocket browser DEV ✅ Vite proxy /fish-audio-api
stage-pocket native iOS/Android ✅ Capacitor OS HTTP stack (no CORS)
Web/pocket production + custom base URL ✅ user-controlled CORS proxy
Web/pocket production + default URL ❌ blocked — validateProviderConfig surfaces a clear error directing users to set a custom proxy URL

Test plan

  • stage-tamagotchi dev — configure Fish Audio with a valid API key, trigger TTS → request proxied via IPC, audio plays without CORS error
  • stage-web dev — same config → request goes through Vite proxy (/fish-audio-api)
  • stage-web production build + default URL → validator surfaces error about needing a custom proxy URL
  • stage-pocket native build — TTS works via Capacitor OS HTTP

xuan0x0 and others added 11 commits March 30, 2026 14:38
- 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.
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
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 8, 2026

⏳ Approval required for deploying to Cloudflare Workers (Preview) for stage-web.

Name Link
🔭 Waiting for approval For maintainers, approve here

Hey, @nekomeowww, @sumimakito, @luoling8192, @LemonNekoGH, kindly take some time to review and approve this deployment when you are available. Thank you! 🙏

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1607 to +1614
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
if (usingDefaultUrl && (isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) {
if (usingDefaultUrl && isStageWeb() && !import.meta.env.DEV) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant