diff --git a/.env b/.env index 68e13bd..41cdc83 100644 --- a/.env +++ b/.env @@ -85,6 +85,11 @@ NEXT_PUBLIC_OPENAI_TTS_SPEED="" AZURE_TTS_KEY="" AZURE_TTS_ENDPOINT="" +# NijiVoice +NIJIVOICE_API_KEY= +NEXT_PUBLIC_NIJIVOICE_ACTOR_ID= +NEXT_PUBLIC_NIJIVOICE_SPEED= + # Youtube NEXT_PUBLIC_YOUTUBE_API_KEY="" NEXT_PUBLIC_YOUTUBE_LIVE_ID="" diff --git a/.env.example b/.env.example index 72c5e97..6da7acb 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,11 @@ NEXT_PUBLIC_OPENAI_TTS_SPEED="" AZURE_TTS_KEY="" AZURE_TTS_ENDPOINT="" +# NijiVoice +NIJIVOICE_API_KEY= +NEXT_PUBLIC_NIJIVOICE_ACTOR_ID= +NEXT_PUBLIC_NIJIVOICE_SPEED= + # Youtube NEXT_PUBLIC_YOUTUBE_API_KEY="" NEXT_PUBLIC_YOUTUBE_LIVE_ID="" diff --git a/locales/en/translation.json b/locales/en/translation.json index 3b44497..6e118c6 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -64,6 +64,11 @@ "AivisSpeechSpeed": "Speed", "AivisSpeechPitch": "Pitch", "AivisSpeechIntonation": "Intonation", + "UsingNijiVoice": "NijiVoice", + "NijiVoiceInfo": "NijiVoice API is used. It supports only Japanese. API key can be obtained from the URL below.", + "NijiVoiceApiKey": "NijiVoice API Key", + "NijiVoiceActorId": "NijiVoice Actor ID", + "NijiVoiceSpeed": "NijiVoice Speed", "UpdateSpeakerList": "Update Speaker List", "UsingGoogleTTS": "Google TTS", "UsingStyleBertVITS2": "Style-Bert-VITS2", diff --git a/locales/ja/translation.json b/locales/ja/translation.json index f73668b..958c9f1 100644 --- a/locales/ja/translation.json +++ b/locales/ja/translation.json @@ -64,6 +64,11 @@ "AivisSpeechSpeed": "話速", "AivisSpeechPitch": "音高", "AivisSpeechIntonation": "抑揚", + "UsingNijiVoice": "にじボイスを使用する", + "NijiVoiceInfo": "にじボイス APIを使用しています。日本語のみに対応しています。APIキーを下記のURLから取得してください。", + "NijiVoiceApiKey": "にじボイス API キー", + "NijiVoiceActorId": "にじボイス 話者ID", + "NijiVoiceSpeed": "にじボイス 話速", "UpdateSpeakerList": "話者リストを更新", "UsingGoogleTTS": "Google TTSを使用する", "UsingStyleBertVITS2": "Style-Bert-VITS2を使用する", diff --git a/locales/ko/translation.json b/locales/ko/translation.json index 75e1f1e..039a1ac 100644 --- a/locales/ko/translation.json +++ b/locales/ko/translation.json @@ -64,6 +64,11 @@ "AivisSpeechSpeed": "말 속도", "AivisSpeechPitch": "음높이", "AivisSpeechIntonation": "억양", + "UsingNijiVoice": "NijiVoice", + "NijiVoiceInfo": "NijiVoice API를 사용하고 있습니다. 일본어만 지원됩니다. API 키는 아래 URL에서 얻을 수 있습니다.", + "NijiVoiceApiKey": "NijiVoice API 키", + "NijiVoiceActorId": "NijiVoice 화자 ID", + "NijiVoiceSpeed": "NijiVoice 말 속도", "UpdateSpeakerList": "보이스 타입 업데이트", "UsingGoogleTTS": "Google TTS 사용", "UsingStyleBertVITS2": "Style-Bert-VITS2 사용", diff --git a/locales/zh/translation.json b/locales/zh/translation.json index cc5fa4b..8de358f 100644 --- a/locales/zh/translation.json +++ b/locales/zh/translation.json @@ -64,6 +64,11 @@ "AivisSpeechSpeed": "語速", "AivisSpeechPitch": "音調", "AivisSpeechIntonation": "語調", + "UsingNijiVoice": "使用 NijiVoice", + "NijiVoiceInfo": "NijiVoice API 使用中。僅支援日語。API 金鑰可以從下方的 URL 取得。", + "NijiVoiceApiKey": "NijiVoice API 金鑰", + "NijiVoiceActorId": "NijiVoice 話者ID", + "NijiVoiceSpeed": "NijiVoice 話速", "UpdateSpeakerList": "更新語音角色", "UsingGoogleTTS": "使用 Google TTS", "UsingStyleBertVITS2": "使用 Style-Bert-VITS2", diff --git a/src/components/settings/based.tsx b/src/components/settings/based.tsx index 86a77be..e60c595 100644 --- a/src/components/settings/based.tsx +++ b/src/components/settings/based.tsx @@ -63,7 +63,8 @@ const Based = () => { const jaVoiceSelected = ss.selectVoice === 'voicevox' || ss.selectVoice === 'koeiromap' || - ss.selectVoice === 'aivis_speech' + ss.selectVoice === 'aivis_speech' || + ss.selectVoice === 'nijivoice' switch (newLanguage) { case 'ja': diff --git a/src/components/settings/index.tsx b/src/components/settings/index.tsx index c3df97b..f67c4e2 100644 --- a/src/components/settings/index.tsx +++ b/src/components/settings/index.tsx @@ -126,7 +126,7 @@ const Main = () => { const Footer = () => { return ( ) } diff --git a/src/components/settings/voice.tsx b/src/components/settings/voice.tsx index 7d6dcdc..1897c27 100644 --- a/src/components/settings/voice.tsx +++ b/src/components/settings/voice.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next' +import { useState, useEffect } from 'react' import { PRESET_A, @@ -53,8 +54,37 @@ const Voice = () => { const openaiTTSSpeed = settingsStore((s) => s.openaiTTSSpeed) const azureTTSKey = settingsStore((s) => s.azureTTSKey) const azureTTSEndpoint = settingsStore((s) => s.azureTTSEndpoint) + const nijivoiceApiKey = settingsStore((s) => s.nijivoiceApiKey) + const nijivoiceActorId = settingsStore((s) => s.nijivoiceActorId) + const nijivoiceSpeed = settingsStore((s) => s.nijivoiceSpeed) const { t } = useTranslation() + const [nijivoiceSpeakers, setNijivoiceSpeakers] = useState>([]) + + // にじボイスの話者一覧を取得する関数 + const fetchNijivoiceSpeakers = async () => { + try { + const response = await fetch( + `/api/get-nijivoice-actors?apiKey=${nijivoiceApiKey}` + ) + const data = await response.json() + if (data.voiceActors) { + const sortedActors = data.voiceActors.sort( + (a: any, b: any) => a.id - b.id + ) + setNijivoiceSpeakers(sortedActors) + } + } catch (error) { + console.error('Failed to fetch nijivoice speakers:', error) + } + } + + // コンポーネントマウント時またはにじボイス選択時に話者一覧を取得 + useEffect(() => { + if (selectVoice === 'nijivoice') { + fetchNijivoiceSpeakers() + } + }, [selectVoice, nijivoiceApiKey]) return (
@@ -78,11 +108,12 @@ const Voice = () => { - {/* 追加 */} + +
-
+
{t('VoiceAdjustment')}
{(() => { @@ -791,6 +822,65 @@ const Voice = () => { /> ) + } else if (selectVoice === 'nijivoice') { + return ( + <> +
{t('NijiVoiceInfo')}
+ +
{t('NijiVoiceApiKey')}
+
+ + settingsStore.setState({ + nijivoiceApiKey: e.target.value, + }) + } + /> +
+
{t('NijiVoiceActorId')}
+
+ +
+
+ {t('NijiVoiceSpeed')}: {nijivoiceSpeed} +
+ { + settingsStore.setState({ + nijivoiceSpeed: Number(e.target.value), + }) + }} + /> + + ) } })()}
diff --git a/src/features/constants/settings.ts b/src/features/constants/settings.ts index f1b2871..fa8ce4f 100644 --- a/src/features/constants/settings.ts +++ b/src/features/constants/settings.ts @@ -35,11 +35,11 @@ export type AIVoice = | 'voicevox' | 'stylebertvits2' | 'aivis_speech' + | 'nijivoice' | 'gsvitts' | 'elevenlabs' | 'openai' | 'azure' - export type Language = 'en' | 'ja' | 'ko' | 'zh' // ISO 639-1 export const LANGUAGES: Language[] = ['en', 'ja', 'ko', 'zh'] diff --git a/src/features/messages/speakCharacter.ts b/src/features/messages/speakCharacter.ts index 1b78762..d931df0 100644 --- a/src/features/messages/speakCharacter.ts +++ b/src/features/messages/speakCharacter.ts @@ -15,6 +15,7 @@ import { synthesizeVoiceAzureOpenAIApi } from './synthesizeVoiceAzureOpenAI' import toastStore from '@/features/stores/toast' import i18next from 'i18next' import { SpeakQueue } from './speakQueue' +import { synthesizeVoiceNijivoiceApi } from './synthesizeVoiceNijivoice' interface EnglishToJapanese { [key: string]: string @@ -121,6 +122,13 @@ const createSpeakCharacter = () => { ss.openaiTTSVoice, ss.openaiTTSSpeed ) + } else if (ss.selectVoice == 'nijivoice') { + buffer = await synthesizeVoiceNijivoiceApi( + talk, + ss.nijivoiceApiKey, + ss.nijivoiceActorId, + ss.nijivoiceSpeed + ) } } catch (error) { handleTTSError(error, ss.selectVoice) diff --git a/src/features/messages/synthesizeVoiceNijivoice.ts b/src/features/messages/synthesizeVoiceNijivoice.ts new file mode 100644 index 0000000..fc4599a --- /dev/null +++ b/src/features/messages/synthesizeVoiceNijivoice.ts @@ -0,0 +1,37 @@ +import { Talk } from './messages' + +export async function synthesizeVoiceNijivoiceApi( + talk: Talk, + apiKey: string, + voiceActorId: string, + speed: number +) { + try { + const res = await fetch('/api/tts-nijivoice', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + script: talk.message, + speed, + voiceActorId, + apiKey, + }), + }) + + if (!res.ok) { + throw new Error( + `Nijivoice APIからの応答が異常です。ステータスコード: ${res.status}` + ) + } + + return await res.arrayBuffer() + } catch (error) { + if (error instanceof Error) { + throw new Error(`Nijivoiceでエラーが発生しました: ${error.message}`) + } else { + throw new Error('Nijivoiceで不明なエラーが発生しました') + } + } +} diff --git a/src/features/stores/settings.ts b/src/features/stores/settings.ts index 69b6b13..57e969c 100644 --- a/src/features/stores/settings.ts +++ b/src/features/stores/settings.ts @@ -71,6 +71,9 @@ interface ModelProvider { openaiTTSVoice: OpenAITTSVoice openaiTTSModel: OpenAITTSModel openaiTTSSpeed: number + nijivoiceApiKey: string + nijivoiceActorId: string + nijivoiceSpeed: number } interface Integrations { @@ -251,6 +254,12 @@ const settingsStore = create()( slideMode: process.env.NEXT_PUBLIC_SLIDE_MODE === 'true', messageReceiverEnabled: false, clientId: '', + + // NijiVoice settings + nijivoiceApiKey: '', + nijivoiceActorId: process.env.NEXT_PUBLIC_NIJIVOICE_ACTOR_ID || '', + nijivoiceSpeed: + parseFloat(process.env.NEXT_PUBLIC_NIJIVOICE_SPEED || '1.0') || 1.0, }), { name: 'aitube-kit-settings', @@ -320,6 +329,9 @@ const settingsStore = create()( azureTTSKey: state.azureTTSKey, azureTTSEndpoint: state.azureTTSEndpoint, selectedVrmPath: state.selectedVrmPath, + nijivoiceApiKey: state.nijivoiceApiKey, + nijivoiceActorId: state.nijivoiceActorId, + nijivoiceSpeed: state.nijivoiceSpeed, }), } ) diff --git a/src/pages/api/get-nijivoice-actors.ts b/src/pages/api/get-nijivoice-actors.ts new file mode 100644 index 0000000..b2f79e7 --- /dev/null +++ b/src/pages/api/get-nijivoice-actors.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { apiKey } = req.query + + const nijivoiceApiKey = apiKey || process.env.NIJIVOICE_API_KEY + if (!nijivoiceApiKey) { + return res.status(400).json({ error: 'API key is required' }) + } + + try { + const response = await fetch( + 'https://api.nijivoice.com/api/platform/v1/voice-actors', + { + headers: { + 'x-api-key': nijivoiceApiKey as string, + }, + } + ) + const data = await response.json() + return res.status(200).json(data) + } catch (error) { + console.error('Failed to fetch voice actors:', error) + return res.status(500).json({ error: 'Failed to fetch voice actors' }) + } +} diff --git a/src/pages/api/tts-nijivoice.ts b/src/pages/api/tts-nijivoice.ts new file mode 100644 index 0000000..bead945 --- /dev/null +++ b/src/pages/api/tts-nijivoice.ts @@ -0,0 +1,50 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import axios from 'axios' + +type Data = { + audio?: ArrayBuffer + error?: string +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { script, speed, voiceActorId, apiKey } = req.body + + const nijivoiceApiKey = apiKey || process.env.NIJIVOICE_API_KEY + if (!nijivoiceApiKey) { + return res.status(400).json({ error: 'API key is required' }) + } + + try { + const response = await axios.post( + `https://api.nijivoice.com/api/platform/v1/voice-actors/${voiceActorId}/generate-voice`, + { + script, + speed: speed.toString(), + format: 'wav', + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': nijivoiceApiKey, + }, + timeout: 30000, + } + ) + + const audioUrl = response.data.generatedVoice.audioFileUrl + + const audioResponse = await axios.get(audioUrl, { + responseType: 'stream', + timeout: 30000, + }) + + res.setHeader('Content-Type', 'audio/mpeg') + audioResponse.data.pipe(res) + } catch (error) { + console.error('Error in Nijivoice TTS:', error) + res.status(500).json({ error: 'Internal Server Error' }) + } +}