書いた記事を音声でも届けたい、と思いながら見送ってきた理由は、私の場合はずっとコストでした。Dolice Labs では記事を継続的に公開していますが、その一本一本を TTS(音声合成)に通すと、品質とお金の折り合いがどうしても付かなかったのです。
本日プレビュー提供が始まった Gemini 3.1 Flash TTS は、その損益分岐をはっきり動かしてくれそうな更新でした。表情を保ったまま低コストで、しかも制御しやすい。記事の音声化のように「分量が多く・頻度も高い」用途では、この三拍子が効いてきます。ここでは、書いた記事を一人語りのナレーションに変換し、stand.fm のような場所に載せるところまでを、実装とコストの両面から整理します。
なぜ「プレビュー版の追加」が記事音声化の採算を変えるのか
記事の音声化は、一度きりの派手なデモとは性質が違います。毎日のように本文 3,000〜5,000 字を流し込み、それを何ヶ月も続けます。だからこそ、1記事あたりの単価がそのまま事業の重さになります。
これまで私が二の足を踏んでいたのは、表情のある音声を出せるモデルは単価が高く、安いモデルは棒読みになりがちという二択だったからです。Flash 系の TTS プレビューが入ってきたことで、その二択の真ん中に現実的な選択肢が生まれました。
判断材料を一枚にすると、見るべきは次の三点です。
1記事あたりの文字数(=課金対象の分量)
表情の制御がプロンプトでどこまで効くか(=録り直しの少なさ)
失敗時の再生成コスト(=本番運用での目減り)
この記事の後半で、最初の項目は実際に円で見積もります。残りの二項目は、実装の作り方で吸収できる部分が大きいので、先にそちらを片付けていきます。
長文は一発で渡せない — 文単位で割って自然に繋ぐ
最初にぶつかる壁が、長文を丸ごと一回のリクエストに渡せないことです。TTS は1回の合成で扱える分量に上限があり、記事のような数千字をそのまま投げると途中で切れたり、後半の抑揚が崩れたりします。
句点で割り、長すぎる文だけ二次分割する
私は「文の途中では絶対に割らない」を原則にしています。文の途中で切ると、繋いだときに息継ぎが不自然になるためです。日本語なら句点(。)を一次の区切りにして、それでも長すぎる塊だけ読点で二次分割します。
import re
def split_for_tts (text: str , max_chars: int = 280 ) -> list[ str ]:
"""記事本文を TTS に渡せる単位へ分割する。
文の途中では割らない。長すぎる文だけ読点で二次分割する。"""
# 句点・改行で一次分割(句点は残す)
raw = re.split( r " (?<= 。 ) \s *| \n + " , text.strip())
sentences = [s for s in raw if s]
chunks: list[ str ] = []
buf = ""
for s in sentences:
# 1文が上限を超える場合は読点で二次分割
if len (s) > max_chars:
parts = re.split( r " (?<= 、 ) " , s)
else :
parts = [s]
for p in parts:
if len (buf) + len (p) <= max_chars:
buf += p
else :
if buf:
chunks.append(buf)
buf = p
if buf:
chunks.append(buf)
return chunks
# 例: 4,000字の記事 → 280字前後のチャンク約20個に揃う
max_chars を 280 前後にしているのは、長すぎると抑揚が単調になり、短すぎると継ぎ目が増えるという、運用で見えてきた折衷点です。題材によって最適値は動くので、ここは触りながら決めてください。
PCM を継ぎ目なく結合する
Gemini の TTS は 24kHz・16bit・モノラルの PCM を返します。チャンクごとに音声を受け取り、生の PCM をそのまま連結すれば1本になります。注意点は、各チャンクの間に短い無音を挟むことです。無音ゼロで繋ぐと文と文がぶつかって聞き苦しく、長すぎると間延びします。私は文間に 0.25 秒の無音を入れています。
import struct
SAMPLE_RATE = 24000 # Gemini TTS の出力サンプルレート
SILENCE_SEC = 0.25 # 文間に挟む無音
def silence_pcm (seconds: float ) -> bytes :
n = int ( SAMPLE_RATE * seconds)
return struct.pack( "<" + "h" * n, * ([ 0 ] * n)) # 16bit 無音
def join_pcm (chunks_pcm: list[ bytes ]) -> bytes :
gap = silence_pcm( SILENCE_SEC )
out = bytearray ()
for i, pcm in enumerate (chunks_pcm):
if i > 0 :
out += gap
out += pcm
return bytes (out)
この「割って・繋ぐ」を骨組みにすると、記事の長さが変わってもパイプラインはそのまま使い回せます。
一人語りの声を最後まで揺らさない
複数話者のポッドキャストと違い、記事の音声化で求められるのは「同じ人が、同じ温度で、最後まで読み切る」ことです。チャンクを分割した瞬間に、各チャンクが独立した合成になるため、何もしないと声色や速度が少しずつズレていきます。
スタイル指示を全チャンクで固定する
私は、声と読み方をプロンプトで明示し、それを全チャンクへ同じ文言で渡すようにしています。チャンクごとに指示を変えないことが、揺れを抑える一番の近道です。
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
# 全チャンク共通のスタイル指示(途中で変えないことが肝心)
STYLE = (
"落ち着いた中性的な声で、解説記事のナレーターとして読み上げてください。"
"速度はやや遅め、感情は控えめ、語尾は丁寧に。専門用語は明瞭に発音します。"
)
def synthesize (chunk_text: str ) -> bytes :
res = client.models.generate_content(
model = "gemini-3.1-flash-preview-tts" ,
contents = f " { STYLE }\n\n 本文: { chunk_text } " ,
config = types.GenerateContentConfig(
response_modalities = [ "AUDIO" ],
speech_config = types.SpeechConfig(
voice_config = types.VoiceConfig(
prebuilt_voice_config = types.PrebuiltVoiceConfig(
voice_name = "Charon" , # 一度決めたら全記事で固定する
)
)
),
),
)
part = res.candidates[ 0 ].content.parts[ 0 ]
return part.inline_data.data # 24kHz PCM
声の名前(voice_name)は記事ごとに変えず、チャンネル全体で一つに固定するのが私のおすすめです。聴き手は「この声=この媒体」として覚えるので、回ごとに声が変わると別物に聞こえてしまいます。個人開発で長く運営している Dolice Labs の癒し系アプリでも、私自身、ナレーションの声を途中で替えたときにレビューで違和感を指摘された経験があり、声の一貫性は思った以上に効くのだと実感しました。
数字と英語の読みは前処理で整える
TTS が苦手なのは、文脈依存で読みが変わる箇所です。「3.1」を「さんてんいち」と読ませたいのか「スリーポイントワン」なのか、モデル任せだと揺れます。私は合成前に簡単な置換辞書を通し、読ませたい読みをかな書きで固定しています。
READ_DICT = {
"Gemini" : "ジェミニ" ,
"TTS" : "ティーティーエス" ,
"PCM" : "ピーシーエム" ,
"API" : "エーピーアイ" ,
}
def normalize_reading (text: str ) -> str :
for k, v in READ_DICT .items():
text = text.replace(k, v)
return text
辞書はチャンネルごとに育てていく性質のもので、最初から完璧を狙う必要はありません。耳で聞いて気になった語を一つずつ足していくと、数週間でほとんど引っかからなくなります。
1記事あたりのコストを円で見積もる
ここが採算の核心です。TTS の課金は文字数(または音声秒数)に比例するため、1記事の本文字数が分かれば単価はかなり正確に読めます。
料金は改定され得るので、必ず最新の料金表で前提値を置き換えてください。本稿では計算手順を示すために、入力 100万文字あたり 1.00 ドル、為替を 1 ドル = 155 円という前提で進めます。本文 4,000 字の記事1本なら、課金対象はおおむね本文+スタイル指示で 4,300 字程度です。
項目 値 備考
本文+指示の文字数 約 4,300 字 スタイル指示を全チャンクに付与
前提単価 $1.00 / 100万文字 必ず最新料金で置換
1記事の試算 約 $0.0043(約 0.67 円) 4,300 ÷ 1,000,000 × $1.00
月30本の試算 約 20 円 0.67 × 30
桁を取り違えていないか不安になるくらい小さい数字ですが、これが「プレビュー版の追加で採算が動いた」と私が感じた理由です。仮に表情のあるモデルの単価がこの 10 倍だったとしても、月 200 円程度です。録り直しを 2 割見込んでも本数を維持できる水準で、これなら毎日の公開フローに音声を組み込めます。
ただし、スタイル指示を長くすると、その文字数も毎チャンク課金されます。私はスタイル指示を 60 字以内に削り、共通化することで無駄な上乗せを抑えています。指示文を 200 字書いてしまうと、4,000 字記事でチャンク 20 個に対し 4,000 字ぶんの指示課金が乗ってしまうので、ここは地味に効きます。
失敗しやすい三箇所 — finish_reason・サンプルレート・継ぎ目
本番で回し始めると、品質が落ちるのはいつも決まった場所でした。先回りして潰しておきます。
finish_reason を必ず確認する
長いチャンクや読みづらい記号が混じると、合成が途中で止まり、後半が欠けた音声が返ることがあります。finish_reason が正常終了かを確認し、異常なら短く割り直して再合成する仕組みを入れておくと、欠けたまま公開する事故を防げます。
def synthesize_safe (chunk_text: str , depth: int = 0 ) -> bytes :
res = client.models.generate_content(
model = "gemini-3.1-flash-preview-tts" ,
contents = f " { STYLE }\n\n 本文: { chunk_text } " ,
config = types.GenerateContentConfig( response_modalities = [ "AUDIO" ]),
)
cand = res.candidates[ 0 ]
if str (cand.finish_reason) not in ( "FinishReason.STOP" , "STOP" ):
if depth < 1 and len (chunk_text) > 80 : # 一度だけ半分に割って再試行
mid = len (chunk_text) // 2
return synthesize_safe(chunk_text[:mid]) + synthesize_safe(chunk_text[mid:])
raise RuntimeError ( f "TTS 異常終了: { cand.finish_reason } " )
return cand.content.parts[ 0 ].inline_data.data
サンプルレートを取り違えない
24kHz の PCM を 44.1kHz と思い込んで WAV ヘッダを書くと、声が高く・速く再生されてしまいます。これは Live API の音声でも頻発する定番の落とし穴で、最初にハマったときは原因に気づくまで時間を溶かしました。出力は 24kHz と決め打ちにし、結合・変換のすべてで同じ値を使ってください。
継ぎ目の無音を均一にする
チャンクによって末尾の余韻が違うと、無音を一律 0.25 秒入れても継ぎ目が揃って聞こえません。気になる場合は、各チャンク末尾の微小な無音をトリムしてから一定の無音を足すと、間(ま)が安定します。私は耳で確認して、句点で割っている限りは末尾トリムまではしなくても十分でした。
stand.fm へ載せるところまで
最後に、結合した PCM を配信できる形式へ変換します。多くのプラットフォームは MP3 を受け付けるので、WAV に包んでから MP3 へ変換します。
import wave, subprocess
def pcm_to_wav (pcm: bytes , path: str ):
with wave.open(path, "wb" ) as w:
w.setnchannels( 1 ) # モノラル
w.setsampwidth( 2 ) # 16bit
w.setframerate( 24000 ) # 24kHz 固定
w.writeframes(pcm)
def wav_to_mp3 (wav_path: str , mp3_path: str ):
subprocess.run(
[ "ffmpeg" , "-y" , "-i" , wav_path, "-b:a" , "128k" , mp3_path],
check = True ,
)
ここまで組めば、記事の Markdown を入力に、配信用の MP3 が一本出てくる流れができます。私はこれを記事公開のフックに繋ぎ、本文が確定したら音声も自動で生成される形にしています。なお Dolice Labs のメンバーシップ運営では Stripe を使っていますが、音声配信は無料導線として置き、記事と音声の両方から読者に届く入口を増やす設計にしています。
仕組みとして地味ですが、書いたものを別のチャンネルへ薄く広げる手段が一つ増えると、同じ記事の届く範囲は静かに広がっていきます。まずは手元の記事を一本、split_for_tts に通して 0.67 円のナレーションを出してみてください。その一本で、音声化が「いつかやること」から「今日の公開フローの一部」へ変わります。
実装の参考になれば幸いです。お読みいただきありがとうございました。