Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/stage-tamagotchi/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { setupServerChannel } from './services/airi/channel-server'
import { setupMcpStdioManager } from './services/airi/mcp-servers'
import { setupPluginHost } from './services/airi/plugins'
import { setupAutoUpdater } from './services/electron/auto-updater'
import { createFetchService } from './services/electron/fetch'
import { setupTray } from './tray'
import { setupAboutWindowReusable } from './windows/about'
import { setupBeatSync } from './windows/beat-sync'
Expand Down Expand Up @@ -97,6 +98,8 @@ app.whenReady().then(async () => {
void fileLogger.appendLog(formatted)
})

createFetchService()

injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))

const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig())
Expand Down
65 changes: 65 additions & 0 deletions apps/stage-tamagotchi/src/main/services/electron/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ipcMain } from 'electron'

export const ELECTRON_FETCH_IPC_CHANNEL = 'airi:electron:fetch'

export interface ElectronFetchPayload {
url: string
method?: string
headers?: Record<string, string>
body?: string
}

export interface ElectronFetchResult {
ok: boolean
status: number
statusText: string
headers: Record<string, string>
body: string
bodyBase64?: string
}

let fetchServiceRegistered = false

export function createFetchService() {
if (fetchServiceRegistered)
return

fetchServiceRegistered = true

ipcMain.handle(ELECTRON_FETCH_IPC_CHANNEL, async (_event, payload: ElectronFetchPayload): Promise<ElectronFetchResult> => {
const response = await fetch(payload.url, {
method: payload.method ?? 'GET',
headers: payload.headers,
body: payload.body,
})

const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})

const contentType = headers['content-type'] || ''
const isBinary = contentType.includes('audio/') || contentType.includes('image/') || contentType.includes('video/') || contentType.includes('application/octet-stream')

if (isBinary) {
const arrayBuffer = await response.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers,
body: '',
bodyBase64: base64,
}
}

return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers,
body: await response.text(),
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../
import { createAuthService } from '../../../services/airi/auth'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
import { createAutoUpdaterService } from '../../../services/electron/auto-updater'
import { setupBaseWindowElectronInvokes } from '../../shared/window'

export async function setupSettingsWindowInvokes(params: {
Expand Down
1 change: 0 additions & 1 deletion nix/assets-hash.txt
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
sha256-69tCpJaxRUnyR9CrHlmWJWEWLDpYzyzska7ui9++QoY=
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore non-empty Nix asset hash

This change clears nix/assets-hash.txt, but the Nix package definition reads this file as outputHash (checked in nix/common.nix). outputHash must be a valid SRI hash, so an empty string causes Nix builds/evaluation for this derivation to fail, breaking packaging/release flows that use the Nix build path.

Useful? React with 👍 / 👎.

28 changes: 28 additions & 0 deletions packages/i18n/src/locales/en/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,34 @@ pages:
description: Speech Service region
label: Region
title: Microsoft / Azure Speech
gpt-sovits:
description: github.com/RVC-Boss/GPT-SoVITS
title: GPT-SoVITS
callout_no_model_title: No model field support
callout_model_switch: GPT-SoVITS does not use the standard TTS model field. To switch models, fill in the GPT / SoVITS weight paths below and AIRI will call the api_v2.py switching endpoints.
callout_no_api_key: GPT-SoVITS does not require an API key. The field above can be left blank.
fields:
baseUrl:
description: Base URL of the GPT-SoVITS api_v2.py server
label: Base URL
refAudioPath:
description: Absolute path to reference audio file on the server
label: Reference Audio Path
promptText:
description: The text spoken in the reference audio, optional
label: Reference Audio Text
promptLang:
description: Language of the reference audio (zh / en / ja / ko / yue)
label: Reference Audio Language
textLang:
description: Language of the text to synthesize (zh / en / ja / ko / yue)
label: Synthesis Language
gptWeightsPath:
description: GPT weights path. If filled, AIRI will call /set_gpt_weights.
label: GPT Weights Path
sovitsWeightsPath:
description: SoVITS weights path. If filled, AIRI will call /set_sovits_weights.
label: SoVITS Weights Path
index-tts-vllm:
description: https://index-tts.github.io/
title: Bilibili / IndexTTS
Expand Down
28 changes: 28 additions & 0 deletions packages/i18n/src/locales/zh-Hans/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,34 @@ pages:
description: 服务 Endpoint 地区(比如亚太 eastasia 区域)
label: Endpoint 地区
title: Microsoft / Azure 语音服务
gpt-sovits:
description: github.com/RVC-Boss/GPT-SoVITS
title: GPT-SoVITS
callout_no_model_title: 不支持请求内模型字段
callout_model_switch: GPT-SoVITS 不使用标准 TTS 的 model 字段;如需切换模型,请填写下方 GPT / SoVITS 权重路径,AIRI 会调用 api_v2.py 的切换接口。
callout_no_api_key: GPT-SoVITS 不需要 API Key,上方字段留空即可。
fields:
baseUrl:
description: GPT-SoVITS api_v2.py 服务器的 Base URL
label: Base URL
refAudioPath:
description: 服务器上参考音频文件的绝对路径
label: 参考音频路径
promptText:
description: 参考音频中说的文字内容,可留空
label: 参考音频文本
promptLang:
description: 参考音频的语言(zh / en / ja / ko / yue)
label: 参考音频语言
textLang:
description: 需要合成的文本语言(zh / en / ja / ko / yue)
label: 合成语言
gptWeightsPath:
description: GPT 权重路径;填写后会调用 /set_gpt_weights 切换
label: GPT 权重路径
sovitsWeightsPath:
description: SoVITS 权重路径;填写后会调用 /set_sovits_weights 切换
label: SoVITS 权重路径
index-tts-vllm:
description: https://index-tts.github.io/
title: Bilibili / IndexTTS
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { SpeechProvider } 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 { FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()

const providerId = 'gpt-sovits'
const defaultModel = 'gpt-sovits'

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

const refAudioPath = computed({
get: () => providers.value[providerId]?.refAudioPath as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].refAudioPath = value
},
})

const promptText = computed({
get: () => providers.value[providerId]?.promptText as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].promptText = value
},
})

const promptLang = computed({
get: () => providers.value[providerId]?.promptLang as string | undefined || 'zh',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].promptLang = value
},
})

const textLang = computed({
get: () => providers.value[providerId]?.textLang as string | undefined || 'zh',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].textLang = value
},
})

const gptWeightsPath = computed({
get: () => providers.value[providerId]?.gptWeightsPath as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].gptWeightsPath = value
},
})

const sovitsWeightsPath = computed({
get: () => providers.value[providerId]?.sovitsWeightsPath as string | undefined || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].sovitsWeightsPath = value
},
})

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

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

onMounted(async () => {
await speechStore.loadVoicesForProvider(providerId)
})

watch([apiKeyConfigured], async () => {
await speechStore.loadVoicesForProvider(providerId)
})

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

const providerConfig = providersStore.getProviderConfig(providerId)

return await speechStore.speech(provider, defaultModel, input, voiceId, providerConfig)
}
</script>

<template>
<SpeechProviderSettings :provider-id="providerId" :default-model="defaultModel">
<template #basic-settings>
<p :class="['text-sm', 'text-blue-500', 'dark:text-blue-400', 'px-1']">
ⓘ {{ t('settings.pages.providers.provider.gpt-sovits.callout_model_switch') }}
</p>
<p :class="['text-sm', 'text-neutral-400', 'dark:text-neutral-500', 'px-1']">
ⓘ {{ t('settings.pages.providers.provider.gpt-sovits.callout_no_api_key') }}
</p>
<FieldInput
v-model="refAudioPath"
:label="t('settings.pages.providers.provider.gpt-sovits.fields.refAudioPath.label')"
:description="t('settings.pages.providers.provider.gpt-sovits.fields.refAudioPath.description')"
placeholder="/path/to/reference.wav"
required
type="text"
/>
<FieldInput
v-model="promptText"
:label="t('settings.pages.providers.provider.gpt-sovits.fields.promptText.label')"
:description="t('settings.pages.providers.provider.gpt-sovits.fields.promptText.description')"
placeholder="参考音频中说的文字(可选)"
type="text"
/>
<FieldInput
v-model="gptWeightsPath"
:label="t('settings.pages.providers.provider.gpt-sovits.fields.gptWeightsPath.label')"
:description="t('settings.pages.providers.provider.gpt-sovits.fields.gptWeightsPath.description')"
placeholder="/path/to/gpt.ckpt"
type="text"
/>
<FieldInput
v-model="sovitsWeightsPath"
:label="t('settings.pages.providers.provider.gpt-sovits.fields.sovitsWeightsPath.label')"
:description="t('settings.pages.providers.provider.gpt-sovits.fields.sovitsWeightsPath.description')"
placeholder="/path/to/sovits.pth"
type="text"
/>
</template>

<template #voice-settings>
<FieldInput
v-model="promptLang"
:label="t('settings.pages.providers.provider.gpt-sovits.fields.promptLang.label')"
:description="t('settings.pages.providers.provider.gpt-sovits.fields.promptLang.description')"
placeholder="zh"
type="text"
/>
<FieldInput
v-model="textLang"
:label="t('settings.pages.providers.provider.gpt-sovits.fields.textLang.label')"
:description="t('settings.pages.providers.provider.gpt-sovits.fields.textLang.description')"
placeholder="zh"
type="text"
/>
</template>

<template #playground>
<SpeechPlayground
:available-voices="availableVoices"
:generate-speech="handleGenerateSpeech"
:api-key-configured="apiKeyConfigured"
:use-ssml="false"
default-text="你好,我是 AIRI,很高兴认识你!"
/>
</template>
</SpeechProviderSettings>
</template>

<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
Loading