
단어 오답노트에서 단어의 발음을 들려줄 수 있도록 OpenAI API의 기능 중
TTS(TextToSpeech)를 사용했다.
문장을 음성으로 들려주는 것이 아닌 단어이기 때문에 문맥이나 감정의 영향을 받지 않는 부분이라 생각했으며,
이미 다른 페이지에서 OpenAI API를 활용하고 있기 때문에 비용적인 측면에서도 다른 서비스와 비교했을 때 부담되지 않기에 선택하였다.
다른 TTS 기능이 있는 서비스에는 Google Cloud Text-to-Speech, Amazon Polly, Microsoft Azure Speech가 있다.
| 서비스 | 주요 장점 | 주요 단점 |
|---|---|---|
| OpenAI TTS | 문맥 기반 자연스러운 음성 합성 | 제한된 음성 모델 및 설정 옵션 |
| Google TTS | 고품질 음성, 감정 조절 가능 | 설정의 복잡함, 높은 비용 |
| Amazon Polly | 실시간 음성 합성, SSML로 세밀한 조정 | 음질이 상대적으로 떨어짐 |
| Microsoft TTS | Neural TTS로 고품질 합성, 감정 조절 가능 | 설정의 복잡함, 높은 비용 |
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") });
};
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;
};
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 사용 및 해제에 관한 중요한 부분을 주석으로 설명함.
: convertTextToSpeech 함수 호출로 서버에서 Base64 형식의 음성 데이터를 가져온다.
const base64Audio = await convertTextToSpeech(text);
: atob 함수로 Base64 문자열을 바이너리 문자열로 변환
const byteCharacters = atob(base64Audio);
: 바이너리 문자열을 순회하여 각 문자의 UTF-16 코드 값을 Uint8Array 배열에 저장
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i); // 각 문자의 UTF-16 코드 값 저장
}
const byteArray = new Uint8Array(byteNumbers);
: Uint8Array를 Blob으로 변환해 MP3 형식의 Blob 객체를 생성
const blob = new Blob([byteArray], { type: "audio/mp3" });
: Blob URL을 생성해 오디오 엘리먼트를 생성하고, 이를 재생
const audio = new Audio(URL.createObjectURL(blob)); // Blob을 오디오 소스로 설정
audio.id = `audio-${questionId}`; // 질문 ID로 오디오 엘리먼트 식별
: 오디오 재생이 종료되면 URL.revokeObjectURL로 Blob URL을 해제해 메모리 사용을 최적화
audio.onended = () => {
setPlayingQuestionId(null); // 재생 중인 질문 ID를 null로 설정
URL.revokeObjectURL(audio.src); // 메모리 절약을 위해 Blob URL 해제
};
이외에 아래 3개의 로직은 위에 코드에 주석으로 설명되어있는 부분 참고할 것.
- 이전에 재생 중이던 오디오가 있다면 중지 및 초기화
- 기존에 동일한 질문 ID로 생성된 오디오 엘리먼트가 있으면 제거 (중복 방지)
- 새로 생성된 오디오 엘리먼트를 DOM에 추가 후 재생 시작
<button
onClick={() => handleTextToSpeech(question?.content || "", question!.id)}
></button>;
[참고]
공식문서 OpenAI Platform - Text to speech
블로그 [Next.js] 1시간만에 OpenAI로 영어 회화 도우미 만들기
ㄴ 여기 블로그 도움을 많이 받았다.
더 주세요 ..