個人開発で癒し系アプリの案内音声を Gemini の TTS で用意していたときのことです。文単位で合成して繋いだ一本を、はじめて通しで聴きました。そして手が止まりました。
文と文の変わり目で、声の主が一瞬だけ別人に入れ替わるように聞こえるのです。一文ごとの音は綺麗なのに、繋ぐと落ち着かない。眠りに寄り添うはずの声が、境目でわずかに緊張していました。App Store と Google Play に出しているアプリで、毎日耳を傾けてくれる人がいると思うと、この小さな緊張を見過ごせませんでした。
原因はひとつではありませんでした。抑揚のリセット、PCM の継ぎ目に走る小さなクリック音、そして間の不揃い。三つが重なって「継ぎ目のある音声」になっていたのです。私自身、はじめは素朴に繋いでいて、その粗さに気づいていませんでした。以下では、その三つを切り分けて、それぞれに効く対処を実装ごとまとめます。単に割って繋ぐのではなく、繋いだあとに耳が止まらないところまで持っていく設計です。
なぜ継ぎ目は耳につくのか
長文をひとつのリクエストで音声化することはできません。合成できる分量に上限があり、数千字を丸ごと渡せば途中で切れます。だから文で割ります。ここまでは避けられません。
問題は、割った音を繋いだときに何が起きるかです。私の手元で起きていた不連続は、性質の違う三種類でした。
継ぎ目の症状 耳に届くもの 効く層
抑揚のリセット 文頭ごとに読み直したような硬さ 文脈プライミング
境界のクリック 「プチッ」という短い異音 ゼロクロス整合と端フェード
間の不揃い 文と文がぶつかる/間延びする 句読点別の適応的無音
素朴に「文で割って、固定長の無音を挟んで、PCM をそのまま連結する」だけでは、抑揚のリセットとクリックが残ります。無音の長さを一定にすると、間の不揃いも消えません。ここから、層ごとに潰していきます。
ひとつ補足があります。継ぎ目は再生環境で聞こえ方が変わります。私自身、開発中はイヤホンで確認していて気づけず、スマートフォンのスピーカーで通して聴いたときに初めて粗が耳につきました。仕上げの確認は、聴き手が実際に使う経路でも一度通すことをお勧めします。
前の文の読み終わりを次へ渡す(文脈プライミング)
いちばん耳につくのは、文頭で抑揚がまっさらに戻ることです。前の文がどんな流れで終わったかを、モデルは次のリクエストで知りません。だから毎回「はじめまして」の声で入ってきます。
そこで、直前の文の末尾を短く添えて、同じ調子で続けるよう促します。読み上げさせるのは今の一文だけ。前の文はあくまで抑揚を引き継ぐための手がかりです。
from google import genai
from google.genai import types
client = genai.Client()
MODEL = "gemini-3.1-flash-tts-preview"
VOICE = "Kore"
STYLE = "落ち着いた低めの声で、一定のテンポでゆっくり読んでください。"
def build_prompt (text: str , prev_tail: str ) -> str :
if prev_tail:
return (
f " { STYLE }\n "
f "直前はこう読み終えました:「 { prev_tail } 」。声色と抑揚の流れを保ってください。 \n "
f "次の一文だけを読み上げてください: \n{ text } "
)
return f " { STYLE }\n 次の一文を読み上げてください: \n{ text } "
def synth (text: str , prev_tail: str = "" ) -> bytes :
resp = client.models.generate_content(
model = MODEL ,
contents = build_prompt(text, prev_tail),
config = types.GenerateContentConfig(
response_modalities = [ "AUDIO" ],
speech_config = types.SpeechConfig(
voice_config = types.VoiceConfig(
prebuilt_voice_config = types.PrebuiltVoiceConfig( voice_name = VOICE )
)
),
),
)
cand = resp.candidates[ 0 ]
if cand.finish_reason and cand.finish_reason.name != "STOP" :
raise RuntimeError ( f "想定外の finish_reason: { cand.finish_reason.name } " )
return cand.content.parts[ 0 ].inline_data.data # 24kHz 16bit mono PCM
完全に揃うわけではありません。プライミングは指示であって保証ではないからです。それでも、手元では文頭の硬さが目に見えて減りました。前文の末尾は 20〜24 字ほどに留めています。長く渡すと、稀にその内容まで読み上げに混ざるためです。手がかりは短く、あくまで流れを伝える程度に。
クリックを消す(ゼロクロス整合と端フェード)
抑揚が繋がっても、波形の継ぎ目に小さなクリックが残ることがあります。原因は、音声の端がゼロを横切らない位置で切れているためです。振幅がゼロでない点で次の無音(ゼロ)に切り替わると、段差ができて「プチッ」と鳴ります。
対処は二段です。まず端をゼロに近い点まで詰め、次にごく短いフェードをかけます。フェードは 8 ミリ秒ほどで十分で、音韻を削らずに段差だけを均せます。
import numpy as np
SR = 24000 # Gemini TTS は 24kHz 16bit mono を返す
FADE = 192 # 8ms @ 24kHz
def pcm_to_np (pcm: bytes ) -> np.ndarray:
return np.frombuffer(pcm, dtype = np.int16).astype(np.float32)
def np_to_pcm (a: np.ndarray) -> bytes :
return np.clip(a, - 32768 , 32767 ).astype(np.int16).tobytes()
def snap_zero_crossing (a: np.ndarray, head: bool , window: int = 240 ) -> np.ndarray:
n = min (window, len (a))
if n == 0 :
return a
if head:
idx = int (np.argmin(np.abs(a[:n])))
return a[idx:]
idx = int (np.argmin(np.abs(a[ - n:])))
return a[: len (a) - (n - 1 - idx)]
def edge_fade (a: np.ndarray, n: int = FADE ) -> np.ndarray:
n = min (n, len (a))
if n == 0 :
return a
ramp = np.linspace( 0.0 , 1.0 , n, dtype = np.float32)
a = a.copy()
a[:n] *= ramp
a[ - n:] *= ramp[:: - 1 ]
return a
素朴な連結では、クリックが約30%のチャンク境界で聞こえていました。ゼロクロス整合と端フェードを入れてからは、意識して探さないと分からない程度に収まっています。派手な処理ではありませんが、静かな音源ほど効きます。
間を句読点で揃える(適応的な無音)
固定長の無音は、どこかで必ず不自然になります。文の切れ目と段落の切れ目を同じ間にすると、段落が続き読みに聞こえます。会話の閉じ括弧のあとに間がないと、話し手が息を継がずに次へ突っ込んだように響きます。
だから、文末の記号で無音の長さを変えます。手元で落ち着いた値がこちらです。題材で最適値は動くので、出発点として使ってください。
文末の状況 挟む無音(秒) ねらい
通常の句点で終わる 0.28 読点より長く、段落より短い自然な区切り
感嘆符・疑問符で終わる 0.34 余韻を一拍ぶん長めに取る
会話の閉じ括弧のあと 0.36 話者が息を継ぐ間を作る
段落・話題の境界 0.60 場面が切り替わったと伝える
def gap_for (sentence: str , is_paragraph_end: bool ) -> float :
if is_paragraph_end:
return 0.60
s = sentence.rstrip()
if s.endswith(( "」" , "』" )):
return 0.36
if s.endswith(( "!" , "?" , "!" , "?" )):
return 0.34
return 0.28
def silence (sec: float ) -> np.ndarray:
return np.zeros( int ( SR * sec), dtype = np.float32)
一チャンクだけ焼き直す(冪等なシーム台帳)
長文の音声は、一文直したいだけで全部を作り直すと、時間もコストも無駄になります。しかもプライミングには非決定性があり、作り直すたびに全体の抑揚が微妙にずれます。直していない箇所まで変わってしまうのです。
そこで、セグメントごとに何を根拠に生成したかを台帳に残し、変わった箇所だけを焼き直します。
{
"voice" : "Kore" ,
"sample_rate" : 24000 ,
"segments" : [
{ "idx" : 0 , "text_hash" : "a1b2c3d4e5f6" , "style_hash" : "9f2c7a" , "gap_after" : 0.28 , "audio" : "seg_000.pcm" },
{ "idx" : 1 , "text_hash" : "0b1c2d3e4f5a" , "style_hash" : "9f2c7a" , "gap_after" : 0.60 , "audio" : "seg_001.pcm" }
]
}
import hashlib, json, os
def h (s: str ) -> str :
return hashlib.sha1(s.encode( "utf-8" )).hexdigest()[: 12 ]
def render (sentences, gaps, ledger_path, seg_dir):
os.makedirs(seg_dir, exist_ok = True )
style_hash = h( STYLE + VOICE )
old = json.load( open (ledger_path)) if os.path.exists(ledger_path) else { "segments" : []}
prev = {seg[ "idx" ]: seg for seg in old.get( "segments" , [])}
segments, prev_tail = [], ""
for i, s in enumerate (sentences):
th = h(s)
cached = prev.get(i)
reuse = (
cached
and cached[ "text_hash" ] == th
and cached.get( "style_hash" ) == style_hash
and os.path.exists(os.path.join(seg_dir, cached[ "audio" ]))
)
name = cached[ "audio" ] if reuse else f "seg_ { i :03d } .pcm"
if not reuse:
open (os.path.join(seg_dir, name), "wb" ).write(synth(s, prev_tail))
segments.append({ "idx" : i, "text_hash" : th, "style_hash" : style_hash,
"gap_after" : gaps[i], "audio" : name})
prev_tail = s[ - 24 :]
json.dump({ "voice" : VOICE , "sample_rate" : SR , "segments" : segments},
open (ledger_path, "w" ), ensure_ascii = False , indent = 2 )
return segments
def assemble (segments, seg_dir) -> bytes :
out = []
for seg in segments:
pcm = open (os.path.join(seg_dir, seg[ "audio" ]), "rb" ).read()
a = pcm_to_np(pcm)
a = snap_zero_crossing(a, head = True )
a = snap_zero_crossing(a, head = False )
a = edge_fade(a)
out.append(a)
out.append(silence(seg[ "gap_after" ]))
return np_to_pcm(np.concatenate(out))
生成と結合を分けているのは、整音のパラメータだけを触りたいときに再合成を走らせないためです。無音の長さやフェードは、ディスク上の PCM から何度でも組み直せます。
公式に書かれていない運用の勘所
ドキュメントを読んでも書かれていない、手元でつまずいた点をまとめます。
サンプルレートを取り違えないこと。Gemini の TTS は 24kHz です。WAV ヘッダを付けるときに 16kHz や 44.1kHz と書くと、再生が速く、あるいは間延びして聞こえます。声質の問題と勘違いしやすい落とし穴です。
finish_reason を必ず確認すること。STOP 以外で返ると、音声が途中で欠けたまま繋がれます。継ぎ目を疑う前に、そのチャンクが最後まで返っているかを見てください。
プライミングの波及を決めておくこと。前の文を直すと、次の文のプライマも変わります。厳密には直後のセグメントも焼き直すべきですが、抑揚のわずかな差を許容して編集箇所だけに留める運用も現実的です。私は「段落内の一文修正なら次も焼き直す、段落の頭なら止める」を目安にしています。話題が切り替わる段落の頭では、焼き直しを局所に留める運用を推奨します。
段落の頭ではプライミングを外すこと。話題が大きく変わる境界では、むしろ抑揚をリセットしたい場面があります。段落の最初の文は prev_tail を空にして、新しい流れとして読ませます。
キャッシュ鍵は入力で固定すること。プライミングが非決定である以上、同じ音を再現する保証は音声ファイル側に持たせます。テキストとスタイルのハッシュが一致する限り、ディスクの PCM を正とする。これが焼き直しを安全にする土台です。
まとめ — 最初の一手
やることは派手ではありません。文脈プライミングで抑揚を繋ぎ、ゼロクロス整合と端フェードでクリックを消し、句読点で無音を変え、台帳で焼き直しを局所化する。層は四つですが、どれも小さな整音の積み重ねです。
まずは手元の一記事で、素朴に繋いだ音と本設計の音を録って、続けて聴き比べてみてください。耳が止まる境目がひとつ見つかるはずです。そこを潰すところから始めれば十分です。
耳に残る小さな違和感を一つずつ消していく作業は地味ですが、聴く人の体験を静かに支えます。実装の役に立てば幸いです。