iOS のアプリに音声読み上げ機能を追加したいと思い始めたのは、ある日の深夜のことでした。2014年からアプリ開発を続けてきた中で、累計5,000万ダウンロードを超えた壁紙アプリや癒し系アプリを作ってきましたが、「テキストを読み上げて聴かせる」という機能だけは、ずっと後回しにしてきました。
Apple の AVSpeechSynthesizer は手軽ですが、声のトーンや抑揚に限界があります。Gemini の TTS API を使えば、もっと自然な音声が作れるのではないか——そう思って試し始めたのですが、予想以上に詰まりました。
詰まったポイントは2つありました。1つは PCM データのフォーマット解釈、もう1つは AVAudioSession の設定タイミング です。公式ドキュメントには書かれていない、実装して初めてわかることでした。
Gemini TTS API が返すデータの形式
まず、Gemini TTS API の基本的な動作を確認します。Python での簡単なテストコードはこのようになります:
import google.generativeai as genai
import base64
genai.configure(api_key="YOUR_GEMINI_API_KEY")
client = genai.Client()
response = client.models.generate_content(
model="gemini-2.5-flash-preview-tts",
contents="今日もお疲れさまでした。",
config={
"response_modalities": ["AUDIO"],
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Aoede"
}
}
}
}
)
# レスポンスの中に音声データが含まれている
audio_data = response.candidates[0].content.parts[0].inline_data.data
# audio_data は base64 エンコードされた PCM データ
print(f"データ長: {len(audio_data)} bytes (base64)")ここで重要なのは、レスポンスに含まれる音声データが Raw PCM(符号付き16ビット、24000Hz、モノラル) であるという点です。MP3 でも AAC でもなく、生の PCM です。
Python では wave モジュールに渡せばすぐに再生確認できますが、iOS では話が変わります。
AVAudioPlayer では再生できない理由
最初、私は取得した PCM データを Data に変換して、AVAudioPlayer(data:) に渡せばいいだろうと思っていました。これが最初の落とし穴でした。
// ❌ これは動かない
let audioData = Data(base64Encoded: base64String)!
let player = try AVAudioPlayer(data: audioData)
player.play()AVAudioPlayer は、WAV や MP3 などのヘッダー付きフォーマットを期待しています。Raw PCM を渡しても、フォーマット解釈に失敗してエラーが返ってきます。
解決策は2つあります:
- 方法A: Raw PCM に WAV ヘッダーを付与して
AVAudioPlayerに渡す - 方法B:
AVAudioEngine+AVAudioPlayerNodeで PCM バッファを直接再生する
方法Aは手っ取り早いですが、ヘッダーの構造を正確に組み立てる必要があります。私は方法Bを選びました。AVAudioEngine の方がより細かい制御ができ、将来的にエフェクト処理などを追加しやすいからです。
AVAudioEngine での正しい実装
import SwiftUI
import AVFoundation
class GeminiTTSPlayer: ObservableObject {
private var engine = AVAudioEngine()
private var playerNode = AVAudioPlayerNode()
// Gemini TTS API のフォーマット: 24000Hz, モノラル, 16bit PCM
private let geminiFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 24000,
channels: 1,
interleaved: true
)!
init() {
setupEngine()
}
private func setupEngine() {
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: geminiFormat)
}
func play(pcmData: Data) throws {
// AVAudioSession の設定(詳細は後述)
try configureAudioSession()
// エンジンが動いていなければ開始
if !engine.isRunning {
try engine.start()
}
// PCM データを AVAudioPCMBuffer に変換
let frameCount = UInt32(pcmData.count) / 2 // 16bit = 2 bytes/frame
guard let buffer = AVAudioPCMBuffer(
pcmFormat: geminiFormat,
frameCapacity: frameCount
) else {
throw TTSError.bufferCreationFailed
}
buffer.frameLength = frameCount
// Int16 データをバッファにコピー
pcmData.withUnsafeBytes { rawPtr in
if let src = rawPtr.baseAddress?.assumingMemoryBound(to: Int16.self),
let dst = buffer.int16ChannelData?[0] {
dst.initialize(from: src, count: Int(frameCount))
}
}
// 再生
playerNode.scheduleBuffer(buffer, completionHandler: nil)
playerNode.play()
}
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio)
try session.setActive(true)
}
enum TTSError: Error {
case bufferCreationFailed
}
}2つ目の落とし穴:AVAudioSession の設定タイミング
configureAudioSession() を init() の中で呼ぶと、シミュレーターでは動くのに実機で音が出ない、という問題が起きます。
原因は、アプリ起動直後の AVAudioSession の初期化タイミングと、システムの音声ルーティングの競合です。私が確認した限り、以下のルールを守れば安定しました:
AVAudioSession.setCategoryとsetActiveは 再生の直前 に呼ぶAVAudioEngine.start()はsetActive(true)の 後 に呼ぶ
上記のコードではこの順序を守っています。configureAudioSession() を play(pcmData:) の冒頭で呼ぶのがミソです。
SwiftUI ビューとの連携
struct ContentView: View {
@StateObject private var ttsPlayer = GeminiTTSPlayer()
@State private var isLoading = false
var body: some View {
VStack(spacing: 24) {
Text("Gemini TTS デモ")
.font(.title2)
Button(action: {
Task { await speakWithGemini("今日もお疲れさまでした。ゆっくり休んでください。") }
}) {
Label(isLoading ? "生成中..." : "読み上げる", systemImage: "speaker.wave.2")
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(isLoading)
}
.padding()
}
func speakWithGemini(_ text: String) async {
isLoading = true
defer { isLoading = false }
do {
// API呼び出し(URLSession を使った実装)
let pcmData = try await GeminiTTSAPI.generateAudio(text: text)
try ttsPlayer.play(pcmData: pcmData)
} catch {
print("TTS エラー: \(error)")
}
}
}API 呼び出し部分の GeminiTTSAPI.generateAudio は、URLSession で Gemini の REST API を叩き、レスポンスの inlineData.data(Base64 文字列)を Data に変換して返す実装です。Gemini の Swift SDK が TTS に対応していない場合は、REST API を直接叩く形になります。
実際に動かしてみた印象
実装が完了して、壁紙アプリの「今日のおすすめ壁紙」説明文を読み上げさせてみました。AVSpeechSynthesizer と比べると、Gemini TTS の声は明らかに自然です。特に日本語の長い文章で、イントネーションの不自然さが少ない点が印象的でした。
一方で、API のレイテンシ は課題です。テキスト量にもよりますが、50文字程度でも0.5〜1秒の遅延があります。リアルタイム応答が必要な場面では、Gemini Live API のストリーミング音声を検討する方が現実的かもしれません。
個人開発アプリとして考えると、「記事や壁紙の説明を読み上げる」という非リアルタイムのユースケードでは、この実装で十分だと感じています。2014年からアプリを作り続けてきた経験からすると、こういった「ちょっと手間がかかる機能」をきちんと実装するだけで、ユーザー体験が大きく変わることがあります。
全体を振り返って
Gemini TTS API を iOS に組み込む際のポイントを整理します:
- Raw PCM(16bit / 24000Hz / モノラル)を返す —
AVAudioPlayerでは直接使えない AVAudioEngine+AVAudioPCMBufferを使う構成が安定しているAVAudioSession.setActive(true)は再生直前に呼ぶAVAudioEngine.start()はsetActiveの後
まずは短いテキストで動作を確認し、実機のさまざまなオーディオ状況(イヤホン接続・Bluetooth・スピーカー)でテストしてみてください。音声機能はシミュレーターで動いても実機で挙動が変わるケースが多いので、早めの実機検証をおすすめします。