[Next.js] 최종 팀프로젝트 - (17) OpenAI TextToSpeech(TTS) 기술을 사용하여 단어 발음 들려주기

안셩·2024년 11월 11일
1

프로젝트

목록 보기
36/36
post-thumbnail

단어 오답노트에서 단어의 발음을 들려줄 수 있도록 OpenAI API의 기능 중 TTS(TextToSpeech)를 사용했다.

📌 OpenAI의 TTS 서비스를 선택한 이유

문장을 음성으로 들려주는 것이 아닌 단어이기 때문에 문맥이나 감정의 영향을 받지 않는 부분이라 생각했으며,
이미 다른 페이지에서 OpenAI API를 활용하고 있기 때문에 비용적인 측면에서도 다른 서비스와 비교했을 때 부담되지 않기에 선택하였다.

다른 TTS 기능이 있는 서비스에는 Google Cloud Text-to-Speech, Amazon Polly, Microsoft Azure Speech가 있다.

서비스주요 장점주요 단점
OpenAI TTS문맥 기반 자연스러운 음성 합성제한된 음성 모델 및 설정 옵션
Google TTS고품질 음성, 감정 조절 가능설정의 복잡함, 높은 비용
Amazon Polly실시간 음성 합성, SSML로 세밀한 조정음질이 상대적으로 떨어짐
Microsoft TTSNeural TTS로 고품질 합성, 감정 조절 가능설정의 복잡함, 높은 비용

📌 Open AI TTS 사용방법

1. route handlers 생성

Route Handlers의 주요 장점은 백엔드 API 개발의 유연성과 성능 향상이다. 완성도 높은 API 서버를 손쉽게 구축하는 데 매우 유용하다.

// src>app>api>textToSpeech>route.ts

import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";

// API KEY
const TTS_API_KEY = process.env.OPEN_AI_KEY as string;

// open AI 통신연결
const openai = new OpenAI({
  apiKey: TTS_API_KEY
});

export const POST = async (reqeust: NextRequest) => {
  const { text } = await reqeust.json();

  const mp3 = await openai.audio.speech.create({
    model: "tts-1", // 사용할 TTS 모델을 지정
    voice: "nova", // 음성 모델을 지정
    input: text
  });

  // mp3 데이터를 ArrayBuffer 형식으로 가져와, Buffer 객체로 변환
  const buffer = Buffer.from(await mp3.arrayBuffer());

  //   // 변환된 Buffer 데이터를 "speech.mp3" 파일에 저장
  //   await fs.promises.writeFile(speechFile, buffer);
  return NextResponse.json({ buffer: buffer.toString("base64") });
};

2. API 호출(통신연결)

fetch("/api/textToSpeech", {...})로 백엔드에 있는 /api/textToSpeech 엔드포인트를 호출하여, OpenAI API의 TTS(Text-To-Speech) 기능을 사용해 데이터를 가져오는 형태다.
즉, 백엔드 API 경로를 통해 OpenAI API의 기능을 프론트엔드에서 사용할 수 있도록 구현한 것이다.

//src>api>openAI>tts.ts

export const convertTextToSpeech = async (text: string) => {
  if (!text) return "";
  const response = await fetch("/api/textToSpeech", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ text })
  });
  const { buffer } = await response.json();
  return buffer;
};

3. 'convertTextToSpeech' 함수를 실행하고 mp3 파일을 음성으로 변환

API 호출하는 ‘convertTextToSpeech’ 함수 실행 및 Base64로 인코딩된 MP3 데이터를 음성 파일로 변환하는 코드를 작성했다.

// src>components>wrongAnswer>WordList.tsx

const [playingQuestionId, setPlayingQuestionId] = useState<number | null>(null);

// 텍스트를 음성으로 변환하는 함수
const handleTextToSpeech = async (text: string, questionId: number) => {
  // 변환할 텍스트가 없거나, 현재 재생 중인 질문과 같은 질문이면 실행하지 않음
  if (!text || playingQuestionId === questionId) return;

  try {
    // 이전에 재생 중이던 오디오가 있다면 중지 및 초기화
    if (playingQuestionId) {
      const prevAudio = document.getElementById(`audio-${playingQuestionId}`) as HTMLAudioElement;
      if (prevAudio) {
        prevAudio.pause(); // 재생 중지
        prevAudio.currentTime = 0; // 재생 시간을 처음으로 리셋
      }
    }

    // (1) 서버로부터 텍스트의 음성 데이터를 Base64 형식으로 가져옴
    const base64Audio = await convertTextToSpeech(text);

    // Base64 문자열을 Blob 객체로 변환하기 위해 먼저 바이너리 데이터로 디코딩
    const byteCharacters = atob(base64Audio); // (2) Base64 문자열을 바이너리로 디코딩
    const byteNumbers = new Array(byteCharacters.length); // 바이트 배열 생성

    // (3) 디코딩한 바이너리 데이터를 Uint8Array로 변환
    for (let i = 0; i < byteCharacters.length; i++) {
      byteNumbers[i] = byteCharacters.charCodeAt(i); // 각 문자의 UTF-16 코드 값 저장
    }
    const byteArray = new Uint8Array(byteNumbers);

    // (4) Uint8Array로 Blob 객체 생성 (MP3 형식 지정)
    const blob = new Blob([byteArray], { type: "audio/mp3" });

    // 기존에 동일한 질문 ID로 생성된 오디오 엘리먼트가 있으면 제거 (중복 방지)
    const existingAudio = document.getElementById(`audio-${questionId}`);
    if (existingAudio) {
      existingAudio.remove(); // 기존 오디오 엘리먼트 삭제
    }

    // (5) 새 오디오 엘리먼트 생성 및 설정
    const audio = new Audio(URL.createObjectURL(blob)); // Blob을 오디오 소스로 설정
    audio.id = `audio-${questionId}`; // 질문 ID로 오디오 엘리먼트 식별

    // 오디오가 재생되면 현재 재생 중인 질문 ID로 상태 업데이트
    audio.onplay = () => setPlayingQuestionId(questionId);

    // (6) 오디오 재생이 종료되면 상태 초기화 및 URL 해제
    audio.onended = () => {
      setPlayingQuestionId(null); // 재생 중인 질문 ID를 null로 설정
      URL.revokeObjectURL(audio.src); // 메모리 절약을 위해 Blob URL 해제
    };

    // 새로 생성된 오디오 엘리먼트를 DOM에 추가 후 재생 시작
    document.body.appendChild(audio);
    await audio.play(); // 오디오 재생
  } catch (error) {
    console.error("텍스트 변환 오류:", error); // 변환 오류 발생 시 콘솔에 표시
    setPlayingQuestionId(null); // 오류 시 재생 중인 질문 ID를 초기화
  }
};

비동기 작업과 음성 재생의 동작 방식, Blob URL 사용 및 해제에 관한 중요한 부분을 주석으로 설명함.

(1) Base64 형식으로 음성 데이터 가져오기

: convertTextToSpeech 함수 호출로 서버에서 Base64 형식의 음성 데이터를 가져온다.

const base64Audio = await convertTextToSpeech(text);

(2) Base64 문자열을 바이너리로 디코딩

: atob 함수로 Base64 문자열을 바이너리 문자열로 변환

const byteCharacters = atob(base64Audio);

(3) Uint8Array로 변환

: 바이너리 문자열을 순회하여 각 문자의 UTF-16 코드 값을 Uint8Array 배열에 저장

for (let i = 0; i < byteCharacters.length; i++) {
	byteNumbers[i] = byteCharacters.charCodeAt(i); // 각 문자의 UTF-16 코드 값 저장
}
const byteArray = new Uint8Array(byteNumbers);

(4) Blob 객체 생성

: Uint8Array를 Blob으로 변환해 MP3 형식의 Blob 객체를 생성

const blob = new Blob([byteArray], { type: "audio/mp3" });

(5) Blob URL 생성 및 오디오 엘리먼트 설정

: Blob URL을 생성해 오디오 엘리먼트를 생성하고, 이를 재생

const audio = new Audio(URL.createObjectURL(blob)); // Blob을 오디오 소스로 설정
audio.id = `audio-${questionId}`; // 질문 ID로 오디오 엘리먼트 식별

(6) 재생 종료 시 메모리 해제

: 오디오 재생이 종료되면 URL.revokeObjectURL로 Blob URL을 해제해 메모리 사용을 최적화

audio.onended = () => {
      setPlayingQuestionId(null); // 재생 중인 질문 ID를 null로 설정
      URL.revokeObjectURL(audio.src); // 메모리 절약을 위해 Blob URL 해제
};

이외에 아래 3개의 로직은 위에 코드에 주석으로 설명되어있는 부분 참고할 것.

  • 이전에 재생 중이던 오디오가 있다면 중지 및 초기화
  • 기존에 동일한 질문 ID로 생성된 오디오 엘리먼트가 있으면 제거 (중복 방지)
  • 새로 생성된 오디오 엘리먼트를 DOM에 추가 후 재생 시작

4. 버튼 태그에 onClick의 함수로 실행

<button
  onClick={() => handleTextToSpeech(question?.content || "", question!.id)}
></button>;

[참고]
공식문서 OpenAI Platform - Text to speech
블로그 [Next.js] 1시간만에 OpenAI로 영어 회화 도우미 만들기
ㄴ 여기 블로그 도움을 많이 받았다.

profile
24.07.15 프론트엔드 개발 첫 걸음

2개의 댓글

comment-user-thumbnail
2024년 11월 16일

더 주세요 ..

1개의 답글