Gemini 3.8 TTS가 지시문까지 읽는다면 speech_metadata 확인하기
Gemini 3.8 Flash TTS에서 대본과 말투 지시를 분리하고 Interactions API 요청 및 음성 응답을 확인하는 이전 절차를 설명합니다.
Gemini 3.8 Flash TTS에서는 읽을 말은 대본에, 지속적인 말투 지시는 음성 메타데이터에 넣습니다. 모델이 문자 그대로 읽을 대본으로 처리하는 텍스트에 ‘차분하게 말해’라는 지시를 넣으면 그 문장도 음성에 포함될 수 있습니다. Gemini 3.8 Flash TTS 이전 안내는 대본 텍스트와 말투 메타데이터를 구분합니다.
Google은 2026년 9월 22일 API 릴리스 노트에서 새 TTS 모델을 발표했습니다. 이 글은 공식 문서에 맞춘 Interactions REST API 이전 예시입니다. 요청 구조와 디코딩 로직은 로컬에서 확인할 수 있지만 실제 청취 시험, 음질 개선, 현재 Ofox의 해당 엔드포인트 지원을 주장하지 않습니다.
읽을 내용과 읽는 방식을 분리하기
| 정보 | 이 예시에서 넣을 위치 |
|---|---|
| 청자가 들어야 할 말 | text 콘텐츠의 text 필드 |
| 차분하고 명료하게 등 지속적인 말투 | speech_metadata 주석의 style |
| 선택한 음성 | generation_config.speech_config |
| 음성 출력 요청 | response_format |
지시가 짧아도 분리하면 요청을 점검하기 쉽고 대본과 혼동하지 않게 됩니다. 등장인물이 ‘차분하게 말해’라고 말하는 대본은 다릅니다. 실제 청자가 들어야 하는 말이므로 대본에 넣습니다.
모델 문서는 특정 시점의 발성 이벤트도 설명합니다. 과거 프롬프트 태그나 임의 주석이 모두 지원된다고 가정하지 말고 선택한 모델과 API의 현재 문서를 사용하세요.
기존 내레이션 요청을 필드별로 옮기기
기존 프롬프트 전체를 한 문자열로 옮기지 말고 승인된 낭독 원고부터 분리하세요. 기존 입력이 “Read warmly as a station announcer: The next train leaves at noon.”이라면 실제 읽을 내용은 마지막 한 문장입니다. 따뜻한 역 안내 방송 스타일은 style로 보내고 원고에서 제거합니다. 이는 편집 예시이며 생성된 음성 결과가 아닙니다.
첫 요청에서는 목소리와 문장을 고정합니다. 기존 요청을 별도 파일로 저장하고 다음 절의 Interactions 요청을 만드세요. GenerateContent의 contents, parts, speech_metadata 위치나 응답 처리를 섞지 않습니다. 이름이 익숙하더라도 JSON 계층은 다를 수 있습니다.
현재 모델 문서는 지속되는 스타일과 순간적인 발성 이벤트를 구분합니다. 문장 전체의 속삭임은 메타데이터에 넣고, 실제 한숨을 원하는 위치에는 <sigh>를 원고에 남길 수 있습니다. 모든 지시를 임의의 태그로 바꾸지 마세요. 승인된 대사에 지시처럼 들리는 단어가 원래 들어 있다면 본문에 그대로 둡니다.
두 화자라면 순서, 정확한 대사, speaker ID, style, 설정 목소리를 표로 정리합니다. 모든 턴에 설정과 일치하는 speaker를 명시해야 합니다. 본문의 Speaker 1: 접두사는 구조화된 speaker 필드가 아니며 그대로 읽힐 수 있습니다. 긴 대화로 늘리기 전에 대응 관계를 확정하세요. 단일 화자 배열을 복사하는 것으로 다중 화자 설정이 끝나지 않습니다.
요청부터 응답까지 하나의 API 형식 사용하기
다음 예시는 Interactions API를 사용합니다. input 배열과 annotations를 GenerateContent 본문이나 구형 SDK의 필드와 섞지 마세요. 해당 요청 형식은 공식 음성 생성 가이드를 따릅니다.
다음을 request.json으로 저장합니다. 짧은 영어 대본을 사용하는 예시를 그대로 유지했습니다.
{
"model": "gemini-3.8-flash-tts",
"input": [{
"type": "user_input",
"content": [{
"type": "text",
"text": "The next train leaves at noon.",
"annotations": [{
"type": "speech_metadata",
"style": "calm and clear"
}]
}]
}],
"response_format": {"type": "audio"},
"generation_config": {
"speech_config": [{"voice": "Kore"}]
}
}
접근 권한이 있는 직접 Google 계정의 요청은 다음과 같습니다.
curl --fail-with-body --silent --show-error \
'https://generativelanguage.googleapis.com/v1beta/interactions' \
-H "x-goog-api-key: $GEMINI_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @request.json > response.json
본인의 키를 환경변수로 안전하게 전달하세요. 이 명령은 제공자 API를 실제 호출하며 사용 요금이 발생할 수 있습니다. 로컬 JSON 파싱 성공만으로 계정의 모델 접근이나 제공자의 요청 수락이 증명되지는 않습니다.
단일 화자 예시입니다. 공식 다중 화자 설정은 구조가 다르므로 위 speech_config 배열을 임의의 대화 스키마로 바꾸지 마세요. 각 대화 턴의 화자는 설정된 화자와 일치해야 합니다.
응답 확인 후 음성 디코딩하기
response.json에 저장된 HTTP 오류는 음성이 아닙니다. base64를 디코딩하기 전에 HTTP 결과와 응답 구조를 확인하세요. Interactions REST 응답에서는 model_output 단계와 그 content 블록을 살펴봅니다. SDK의 편의 속성인 output_audio가 원시 JSON에도 있다고 가정하지 마세요.
안전한 디코더는 다음을 확인해야 합니다.
- 오류 응답을 음성 파일로 쓰지 않습니다.
- model_output의 음성 콘텐츠를 선택하고 텍스트와 도구 콘텐츠는 제외합니다.
- 반환된 MIME 유형을 확인한 뒤 확장자를 정합니다.
- base64를 디코딩하고 원래 컨테이너 형식을 보존합니다.
이전 가이드는 비스트리밍 응답의 기본 출력이 WAV라고 설명합니다. 과거 raw PCM 예제의 WAV 헤더를 무조건 붙이지 마세요. 이미 유효한 WAV에 헤더를 다시 붙이면 파일이 손상될 수 있습니다. 다른 형식을 요청했다면 확장자만 바꾸지 말고 실제 MIME 유형과 컨테이너를 따릅니다.
작은 디코더로 확인된 WAV 저장하기
이 비스트리밍 예제는 response_format을 다음처럼 바꿔 WAV를 명시합니다.
{"type":"audio","mime_type":"audio/wav"}
Interactions 응답 참조에 step/content 구조가 정의되어 있습니다. 다음을 response.json과 같은 폴더의 decode_tts.py로 저장하고 python3 decode_tts.py를 실행합니다. Python 표준 라이브러리만 사용하며 네트워크 요청을 보내지 않습니다.
import base64
import io
import json
import wave
from pathlib import Path
body = json.loads(Path('response.json').read_text())
if body.get('error') or body.get('status') != 'completed':
raise SystemExit('Interaction failed or is not completed; inspect response.json')
blocks = [
item
for step in body.get('steps', [])
if step.get('type') == 'model_output'
for item in step.get('content', [])
if item.get('type') == 'audio'
]
if len(blocks) != 1:
raise SystemExit('Expected one audio block; inspect the response before combining audio')
audio = blocks[0]
if audio.get('mime_type') != 'audio/wav' or not isinstance(audio.get('data'), str):
raise SystemExit('Expected inline audio/wav data; inspect MIME type and delivery mode')
payload = base64.b64decode(audio['data'], validate=True)
if payload[:4] != b'RIFF' or payload[8:12] != b'WAVE':
raise SystemExit('Payload is not a RIFF/WAVE file')
with wave.open(io.BytesIO(payload), 'rb') as wav:
frames = wav.getnframes()
rate = wav.getframerate()
if frames == 0 or rate == 0:
raise SystemExit('Audio contains no usable frames')
print({'channels': wav.getnchannels(), 'sample_rate': rate,
'duration_seconds': frames / rate})
Path('speech.wav').write_bytes(payload)
print('Saved speech.wav')
예상 로컬 출력은 메타데이터와 Saved speech.wav입니다. 길이와 샘플링 속도는 반환 파일에서 읽으며 특정 값을 약속하지 않습니다. 여러 오디오 블록, 미완료 interaction, URI 전달, 다른 인코딩이면 멈춥니다. 단일 WAV 결과를 위한 좁은 디코더이지 범용 스트리밍 클라이언트가 아닙니다. 잘못된 base64나 지원하지 않는 WAV도 오류를 내므로 오해할 파일을 조용히 저장하지 않습니다.
로컬에서 만든 PCM WAV, 잘못된 데이터, 오류 응답으로 이 디코더를 확인했습니다. 로컬 파싱과 거부 동작만 검증하며 계정 접근, Google 실응답, 발음, 음질을 검증하지는 않습니다.
파일 컨테이너 문제와 낭독 문제 구분하기
speech.wav가 열리지 않으면 원고를 바꾸기 전에 HTTP 상태, MIME 유형과 바이트를 확인합니다. WAV에 헤더를 한 번 더 붙여도 잘못된 대사는 고쳐지지 않습니다. 반대로 메타데이터가 정상이고 재생돼도 승인된 모든 단어를 읽었다는 보장은 없습니다.
| 문제 | 확인 및 수정 |
|---|---|
| 말투 지시까지 읽음 | 승인 원고와 비교해 불필요한 지시를 본문에서 style로 이동 |
| 화자 라벨을 읽음 | 본문 라벨을 제거하고 설정과 일치하는 speaker 메타데이터 사용 |
| JSON 오류를 음성으로 저장 | HTTP/interaction 오류에서 중지하고 오류 객체를 디코딩하지 않음 |
| WAV 손상 또는 잡음 | 컨테이너와 인코딩 확인. 이미 WAV면 기존 raw PCM 헤더 추가 코드 제거 |
| 비어 있거나 일부만 낭독 | 완료 상태와 종료 정보를 보고 텍스트 크기·모델 제한 확인 후 분할 결정 |
| 대화 중 뜻밖의 목소리 변화 | 턴과 speaker 대응을 확인하고 각 목소리를 짧게 시험 |
data 없음 | URI 전달이나 스트리밍을 요청했는지 확인하고 해당 방식으로 처리 |
스트리밍은 별도의 구현 과제입니다. 청크가 각각 독립된 WAV여서 바이트만 붙이면 되는 것은 아닙니다. 완성된 여러 WAV를 연결해도 중간에 헤더가 남습니다. 첫 이전은 비스트리밍으로 유지하고, 이후 스트리밍이나 연결이 필요하면 문서의 PCM 형식과 타이밍을 명시적으로 처리하세요.
첫 이전 시험은 작게 시작하기
화자 한 명, 짧은 대본, 말투 지시 하나로 시작하세요. 누락된 단어, 추가로 읽힌 지시문, 발음, 예상치 못한 목소리 변화를 듣고 확인합니다. 요청, 모델 ID, 시각, 출력 파일을 함께 보관하세요. 이는 제안하는 합격 기준이며 이 글에서 얻은 실험 결과는 아닙니다.
그다음 대본을 늘리거나 화자를 추가합니다. 한 번에 한 변수만 바꾸세요. 지시를 메타데이터로 옮기면서 음성, 대본 분할, API까지 동시에 바꾸면 원인을 찾기 어렵습니다.
내레이션 작업에서는 영상에 넣기 전에 음성과 정확한 대본을 비교합니다. 얼굴 없는 영상 제작 워크플로(영문)는 전체 제작 과정을 다룹니다. TTS 이전은 그중 한 단계이며 타이밍, 발음, 특정 음성 사용에 대한 동의까지 보장하지 않습니다.
영상에 넣기 전에 내레이션 승인하기
긴 원고 생성 전에 합격 기준을 정합니다. 열차 문장은 승인된 한 문장만 빠짐없이 읽고 “calm and clear”, 화자 접두사, 시간 누락, 끝부분 반복이 없어야 합니다. 발음, 클리핑, 불필요한 침묵, 목소리 변화는 따로 기록합니다. 유효한 파일도 이 기준에서는 실패할 수 있습니다.
실용적인 이전 세트는 중립적인 짧은 문장 하나, 프로젝트의 고유명사나 숫자가 있는 문장 하나, 여러 화자가 필요한 제품에만 두 턴 대화입니다. 지시를 메타데이터로 옮길 때 원고와 목소리를 고정합니다. 요청/출력 쌍과 청취 메모를 저장하세요. 정해진 분모로 모든 실행을 평가하지 않았다면 성공률을 발표하지 않습니다.
음성이 통과한 뒤 실제 길이에 장면을 맞춥니다. 15초 영상 요청이 TTS도 15초가 되게 하지는 않습니다. 원고나 타임라인을 의도적으로 수정하고 속도 변경 뒤 다시 듣습니다. 원본 음성을 보존하면 편집 결함과 생성 문제를 구분할 수 있습니다. 이는 지시 배치부터 사용할 수 있는 자산까지의 이전 절차이며 음질 벤치마크가 아닙니다.
다른 제공자를 경유하기 전 확인할 사항
OpenAI 호환 텍스트 엔드포인트가 있다고 Google Interactions API와 음성 메타데이터 필드가 지원되는 것은 아닙니다. 구체적인 음성 경로, 지원 모델 ID, 출력 형식을 확인하세요. 이 글의 직접 Google 예시는 Ofox 엔드포인트 설정법이 아닙니다.
모델 선택과 API 형식 이전을 분리하세요. Gemini 3.8 Flash-Lite TTS는 관련 모델이지만 모델 문자열을 바꾸기 전에 자체 문서에서 지원과 동작을 확인해야 합니다. 멀티모달 API 개요(영문)는 전반적 맥락을 제공하지만 현재 제공자 문서를 대신하지는 않습니다.
자주 묻는 질문
- 말투 지시를 소리 내어 읽는 이유는 무엇인가요?
- 새 모델은 입력 텍스트를 문자 그대로의 대본으로 처리합니다. 지속적인 말투 지시는 대본에 삽입하지 말고 문서화된 음성 메타데이터 위치에 넣으세요.
- 이 JSON을 GenerateContent에 복사해도 되나요?
- 아닙니다. 이 예시는 Interactions 입력과 주석 필드를 사용합니다. GenerateContent는 요청 구조가 다르므로 해당 API의 공식 예제를 처음부터 끝까지 사용하세요.
- 출력은 항상 raw PCM인가요?
- 아닙니다. 이전 안내는 비스트리밍 응답의 기본값이 WAV라고 설명합니다. 실제 응답 형식을 확인하고 WAV 헤더를 두 번 넣지 마세요.


