타입캐스트 vs 일레븐랩스 vs 수퍼톤 vs Hailuo AI
유튜브에서 다른 tts들과 비교하는 영상을 봤는데
1) 개발자를 위한 api가 있으면서
2) 한국어로 일상 대화가 가장 자연스럽게 표현 되면서
3) 아이를 대상으로 하는 우리 서비스에 적절한 음성이 있는
4) 게다가 저렴한
MiniMax사의 Hailuo tts를 적용하기로 했다.
근데 구글링 해도 공식 문서조차 바로바로 보기 힘들었고 한국어로 된 정보나 사용 후기를 찾아보기 힘들어서 이 글을 작성한다.
API 사용을 위해서는 아래 링크로 접속해야한다.
MiniMax-Intelligence with everyone
MiniMax is a leading global technology company and one of the pioneers of large language models (LLMs) in Asia. Our mission is to build a world where intelligence thrives with everyone.
www.minimax.io
사용 모델과 가격 정책
모델
이 MiniMax API가 제공하는 모델은
1. 텍스트 모델
2. 스피치 모델
3. 비디오모델
이렇게 총 세가지가 있는데 우리는 tts 시에 필요하므로 speech model(Speech-02)을 사용한다.
(Speech-01, Speech-02... 이렇게 모델이 발전하고 있는데, 현 가장 최신 모델은 Speech-02 모델이다.)
speech 모델 안에도 빠른 응답 중심의 speech-02-turbo 모델, 고음질의 speech-02-hd 모델이 있는데
실시간 대화를 하며 반응 속도가 중요하면 speech-02-turbo 모델을,
감정 표현을 더 세심하게 하고 싶으면 speech-02-hd 모델을 사용하면 된다.
따로 명시하지 않으면 보통 speech-02-turbo가 기본값이므로 일단 speech-02-turbo 모델을 사용해보기로했다.
가격 정책
speech-02-turbo 모델은 만 character당 60달러 speech-02-hd 모델은 만 character당 100달러이다.
내 서비스에 사용하면 얼마가 들까?
speech-02-turbo 모델을 사용한다고 생각하고 계산을 시작했다.
1 character 당 $0.00006인데
내 서비스에서 한번 호출할 때
짧은 문장은 "누군가에게 마음을 표현할 때 어떻게 해?" -> 총 28 character
긴 문장은 "제일 좋아하는걸 주는 예쁜 마음이었구나. 사탕 말고도 누군가에게 마음을 표현하고 싶을 때 또 어떤 걸 해줄 수 있을까?" -> 총 66character
이고, 한번 대화를 시작하면 4번정도 호출하므로
문장당 평균 글자수 60 x 4회 = 총 240 character
240 characters x $0.00006 = $0.0144 ≒ 약 1.4센트
대략 20원 정도 비용이 발생한다.
결제 방법
상단 Account 탭-> 좌측 Billing의 Balance를 누르고 가운데 Recharge 버튼을 누르면
최소 충전 금액이 $25부터라 $25를 충전했다. (아 최소 충전 금액 이건 몰랐지;)
API key 발급받기
회원가입을 하고 상단에 Account탭 -> 좌측 API keys를 누르면 다음과 같은 창이 뜬다.
여느 API 사용법처럼 api secret key를 발급받아야하기 때문에 파란색 "Create new secret key" 버튼을 눌러 키를 발급받는다.
원하는 이름을 정하고 "create" 버튼을 누르면
다시 볼 수 없으니 복사해놓으라는 문구가 뜨고 secret key를 발급받을 수 있다.
이 발급 받은 secret key는 개발할 때 필요한 키이니 어딘가에 복사를 해놓으면 된다.(잊어버리면 다시 그냥 발급받으면 됨.)
코드에 적용하기
기본 제공되는 tts를 사용했던 내 코드의 한 부분이다.
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'ko-KR';
utterance.pitch = 1.4;
utterance.rate = 0.8;
window.speechSynthesis.speak(utterance);
이제 이 기본제공 tts를 Hailou로 바꾸자.
가장 먼저 할 일은 환경 변수 세팅하기
MINIMAX_API_KEY=mmx-... # MiniMax API 키(위에서 발급받은 api 키)
MINIMAX_GROUP_ID=your_group_id # your profile 탭의 GroupId
MINIMAX_TTS_MODEL=speech-02-turbo # 원하는 모델(ex. speech-2.5-hd-preview)로 교체
서버에서 MiniMax 호출하기. 우리 프로젝트에서는 app route를 사용중이므로
app/api/tts/route.ts
이 경로에 파일을 만들었다.
여기서 model, speed, pitch, emotion등을 정한다.
// app/api/tts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const BASE_URLS = [
'https://api.minimax.io/v1/t2a_v2',
'https://api.minimaxi.chat/v1/t2a_v2',
];
export async function POST(req: NextRequest) {
try {
const {
text,
speed = 1.0,
pitch = 0,
emotion = 'neutral',
} = await req.json();
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'text is required' }, { status: 400 });
}
const apiKey = process.env.MINIMAX_API_KEY;
const groupId = process.env.MINIMAX_GROUP_ID;
const model = process.env.MINIMAX_TTS_MODEL || 'speech-02-turbo';
const voice = 'Exuberant_Girl';
if (!apiKey || !groupId) {
console.error('❌ Missing env', {
hasApiKey: !!apiKey,
hasGroupId: !!groupId,
});
return NextResponse.json(
{ error: 'Server not configured' },
{ status: 500 },
);
}
// MiniMax HTTP Non-streaming: body 스키마 & GroupId 쿼리
const body = {
text,
model,
voice_setting: {
voice_id: voice,
speed, // 0.5~2.0
pitch, // -12~12 (정수 권장)
emotion, // 'neutral' 등
},
// language_boost: 'Korean', // 필요 시 사용
// stream: false, // 명시해도 OK
};
let lastError = '';
for (const base of BASE_URLS) {
const url = `${base}?GroupId=${encodeURIComponent(groupId)}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
});
const ct = res.headers.get('content-type') || '';
const st = res.status;
if (!res.ok) {
const err = await res.text();
console.error(`❌ MiniMax fail @${base} [${st}]`, err.slice(0, 500));
lastError = `status=${st}, body=${err.slice(0, 500)}`;
continue; // 다음 도메인 시도
}
// 응답은 JSON에 hex 오디오가 들어오는 경우가 일반적
if (ct.includes('application/json')) {
const json = (await res.json()) as any;
// MiniMax 표준: base_resp.status_code === 0 이면 성공
if (json?.base_resp?.status_code !== 0) {
lastError = `api_error=${json?.base_resp?.status_msg || 'unknown'}`;
console.error('❌ MiniMax API error:', json?.base_resp);
continue;
}
const hex =
json?.data?.audio ||
json?.audio ||
json?.data?.audio_hex ||
json?.audio_hex;
if (!hex || typeof hex !== 'string') {
lastError = 'No audio(hex) field in JSON';
console.error('❌ No audio(hex) in response:', json);
continue;
}
const buf = Buffer.from(hex, 'hex'); // 🔑 HEX → 바이너리
return new NextResponse(buf, {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' }, // 기본 mp3
});
}
// 혹시 바이너리로 오는 계정/플랜 대비
const buf = await res.arrayBuffer();
return new NextResponse(buf, {
status: 200,
headers: { 'Content-Type': ct || 'audio/mpeg' },
});
} catch (err: any) {
console.error(`❌ fetch error @${base}:`, err);
lastError = String(err?.message || err);
// 다음 도메인 계속 시도
}
}
return NextResponse.json(
{ error: 'MiniMax TTS failed on all endpoints', detail: lastError },
{ status: 502 },
);
} catch (e: any) {
console.error('❌ /api/tts handler error:', e);
return NextResponse.json(
{ error: e?.message ?? 'Unknown error' },
{ status: 500 },
);
}
}
다음은 내부 API를 fetch로 호출하기.
tts를 위한 훅을 만들었다.
hooks/useMinimaxTTS.ts
// hooks/useMinimaxTTS.ts
import { useRef, useState, useCallback } from 'react';
export function useMinimaxTTS() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isSpeaking, setIsSpeaking] = useState(false);
const speak = useCallback(
async (text: string, opts?: { speed?: number; pitch?: number }) => {
if (!text) return;
try {
console.log('🔊 MiniMax TTS 요청:', { text, opts });
setIsSpeaking(true);
const res = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
speed: opts?.speed ?? 1.0,
pitch: opts?.pitch ?? 1.0,
}),
});
if (!res.ok) {
console.warn('⚠️ MiniMax TTS 실패 → Web Speech로 폴백');
const utt = new SpeechSynthesisUtterance(text);
utt.lang = 'ko-KR';
utt.pitch = 1.4;
utt.rate = 0.8;
utt.onend = () => setIsSpeaking(false);
window.speechSynthesis.speak(utt);
return;
}
const blob = await res.blob();
console.log(
'🎧 /api/tts Content-Type:',
res.headers.get('content-type'),
);
const url = URL.createObjectURL(blob);
// 기존 오디오 정리
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
const audio = new Audio();
audioRef.current = audio;
// iOS 자동재생 정책 대응: 사용자 제스처 이후 재생이 안정적
audio.src = url;
audio.onended = () => {
setIsSpeaking(false);
URL.revokeObjectURL(url);
};
audio.onerror = () => {
console.error('❌ 오디오 재생 오류');
setIsSpeaking(false);
URL.revokeObjectURL(url);
};
await audio.play();
} catch (err) {
console.error('❌ MiniMax TTS 에러:', err);
setIsSpeaking(false);
}
},
[],
);
const cancel = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
if (typeof window !== 'undefined') {
window.speechSynthesis.cancel();
}
setIsSpeaking(false);
}, []);
return { speak, cancel, isSpeaking };
}
만들어진 훅을 코드에 적용시키면 끝!
// 🔧 MiniMax TTS 훅 사용 (Web Speech 자동 폴백 포함)
const { speak, cancel, isSpeaking } = useMinimaxTTS();
결국 어떤 모델을 선택했나?
현시점 가장 좋은 모델인
로 테스트 해보다가 가장 저렴한 모델인 speech-02-turbo로 바꿔서 테스트 해봤는데, 오히려 가장 저렴한 모델이 억양이 가장 자연스러워서 결국 speech-02-turbo 를 선택했다.
(목소리마다 억양과 자연스러움이다르니 목소리를 먼저 변경해보고 만족스럽지 않으면 모델을 바꿔보자.)