Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/stage-pocket/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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`,
Expand Down
43 changes: 43 additions & 0 deletions apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createContext>['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,
}
})
}
1 change: 1 addition & 0 deletions apps/stage-tamagotchi/src/main/services/electron/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './app'
export * from './auto-updater'
export * from './fish-audio'
export * from './powerMonitor'
export * from './screen'
export * from './window'
3 changes: 2 additions & 1 deletion apps/stage-tamagotchi/src/main/windows/shared/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })

Expand Down
9 changes: 7 additions & 2 deletions apps/stage-tamagotchi/src/shared/eventa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,17 @@ export interface ElectronAuthTokens {
expiresIn: number
}
export const electronAuthStartLogin = defineInvokeEventa<void>('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<ElectronAuthTokens>('eventa:event:electron:auth:callback')
export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa:event:electron:auth:callback-error')
export const electronAuthLogout = defineInvokeEventa<void>('eventa:invoke:electron:auth:logout')

export const i18nSetLocale = defineInvokeEventa<void, Locale>('eventa:invoke:electron:i18n:set-locale')
export const i18nGetLocale = defineInvokeEventa<Locale>('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'
13 changes: 13 additions & 0 deletions apps/stage-web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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`,
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/es/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/fr/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ja/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ko/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ru/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/vi/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/zh-Hans/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/zh-Hant/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'

import {
SpeechPlayground,
SpeechProviderSettings,
} 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'

const providerId = 'fish-audio'
const defaultModel = 's2-pro'

const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)

const apiKeyConfigured = computed(() => !!providers.value[providerId]?.apiKey)

const availableVoices = computed(() => speechStore.availableVoices[providerId] || [])

async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
const provider = await providersStore.getProviderInstance(providerId) as SpeechProviderWithExtraOptions<string>
if (!provider) {
throw new Error('Failed to initialize speech provider')
}

const providerConfig = providersStore.getProviderConfig(providerId)
const model = providerConfig.model as string | undefined || defaultModel

return await speechStore.speech(
provider,
model,
input,
voiceId,
{ ...providerConfig },
)
}

async function tryLoadVoices() {
if (apiKeyConfigured.value) {
await speechStore.loadVoicesForProvider(providerId)
}
}

// 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],
debouncedLoadVoices,
)
</script>

<template>
<SpeechProviderSettings
:provider-id="providerId"
:default-model="defaultModel"
>
<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
:voices-loading="speechStore.isLoadingSpeechProviderVoices"
default-text="Hello! This is a test of the Fish Audio voice synthesis."
/>
</template>
</SpeechProviderSettings>
</template>

<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ defineSlots<{
'advanced-settings': (props: any) => any
'playground': (props: any) => any
}>()

const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
Expand Down
8 changes: 7 additions & 1 deletion packages/stage-ui/src/components/scenes/Stage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,13 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({
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
}
},
Expand Down
Loading
Loading