Skip to content

Commit

Permalink
🎨 #21 にじボイスで音声を生成する処理を追加
Browse files Browse the repository at this point in the history
  • Loading branch information
keitakn committed Jan 29, 2025
1 parent a70ef3c commit afe9ba8
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 3 deletions.
92 changes: 92 additions & 0 deletions frontend/src/app/api/voices/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import z from 'zod';

function getAcceptableScriptLength() {
return 2000;
}

function isSurrogatePear(upper: number, lower: number): boolean {
return upper >= 0xD800 && upper <= 0xDBFF && lower >= 0xDC00 && lower <= 0xDFFF;
}

function mbStrLen(str: string): number {
let ret = 0;

for (let i = 0; i < str.length; i++, ret++) {
const upper = str.charCodeAt(i);
const lower = str.length > i + 1 ? str.charCodeAt(i + 1) : 0;

if (isSurrogatePear(upper, lower)) {
i++;
}
}

return ret;
}
function isAcceptableScript(script: unknown): boolean {
if (typeof script !== 'string') {
return false;
}

return mbStrLen(script) <= getAcceptableScriptLength();
}

const generateVoiceRequestSchema = z.object({
script: z
.string({ message: '文章は必須です。' })
.refine(value => isAcceptableScript(value), {
message: `文章は2000文字まで入力が可能です。`,
}),
});

const nijivoiceGeneratedVoiceSchema = z.object({
base64Audio: z.string().base64(),
duration: z.number().nonnegative(),
remainingCredits: z.number().nonnegative(),
});

const nijivoiceGenerateVoiceResponseBodySchema = z.object({
generatedVoice: nijivoiceGeneratedVoiceSchema,
});

type NijivoiceGenerateVoiceResponseBody = z.infer<typeof nijivoiceGenerateVoiceResponseBodySchema>;

function isNijivoiceGenerateVoiceResponseBody(value: unknown): value is NijivoiceGenerateVoiceResponseBody {
const result = nijivoiceGenerateVoiceResponseBodySchema.safeParse(value);

return result.success;
}

export const runtime = 'edge';

export async function POST(request: Request) {
const requestBody = await request.json();

generateVoiceRequestSchema.parse(requestBody);

// https://app.nijivoice.com/characters/16e979a8-cd0f-49d4-a4c4-7a25aa42e184 を利用
const url = 'https://api.nijivoice.com/api/platform/v1/voice-actors/16e979a8-cd0f-49d4-a4c4-7a25aa42e184/generate-encoded-voice';
const options = {
method: 'POST',
headers: {
'x-api-key': String(process.env.NIJIVOICE_API_KEY),
'accept': 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
script: requestBody.script,
format: 'wav',
// 「ぽの」の推奨スピードは0.8なので0.8に設定
// https://app.nijivoice.com/characters/16e979a8-cd0f-49d4-a4c4-7a25aa42e184
speed: '0.8',
}),
} as const;

const response = await fetch(url, options);

const responseBody = await response.json();
if (isNijivoiceGenerateVoiceResponseBody(responseBody)) {
return Response.json({ base64Audio: responseBody.generatedVoice.base64Audio }, { status: 201 });
}

return Response.json({ requestBody });
}
36 changes: 33 additions & 3 deletions frontend/src/app/voice-chat/_components/VoiceChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export function VoiceChatForm() {

// Update getEphemeralToken to use the backend endpoint
const getEphemeralToken = async () => {
const response = await fetch('http://localhost:8000/realtime-apis/voice-chat', {
const response = await fetch(String(process.env.NEXT_PUBLIC_EPHEMERAL_TOKEN_ENDPOINT), {
method: 'POST',
});

Expand All @@ -365,7 +365,7 @@ export function VoiceChatForm() {
let newResponseMessage = '';

// Handle incoming messages from the data channel
const handleDataChannelMessage = (event: MessageEvent) => {
const handleDataChannelMessage = async (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
// log.debug('Received message:', msg);
Expand All @@ -388,11 +388,41 @@ export function VoiceChatForm() {
case 'response.text.done':
if (newResponseMessage !== '') {
const lastAssistantMessage = newResponseMessage;
// TODO: lastAssistantMessageを使って音声を再生する

// メッセージを追加
setMessages(prev => [...prev, {
role: 'assistant',
message: lastAssistantMessage,
}]);

newResponseMessage = '';
setStreamingMessage('');

// 音声を生成して再生
try {
const response = await fetch('/api/voices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
script: lastAssistantMessage,
}),
});

if (!response.ok) {
throw new Error('音声生成に失敗しました');
}

const audioData = await response.json();
if (audioData.base64Audio) {
await playAudio(audioData.base64Audio);
}
}
catch (error) {
log.error('音声生成エラー:', error);
}

newResponseMessage = '';
setStreamingMessage('');
}
Expand Down

0 comments on commit afe9ba8

Please sign in to comment.