GEMINI LABEN
SIRI — WWDC 2026で刷新版SiriがGoogle Geminiモデルで動くと確定。ただしEUではDMAによりiOS 27時点で提供されませんFLASH3.5 — Gemini 3.5 FlashがGA。エージェント・コーディングで持続的なフロンティア性能を発揮する最上位FlashモデルですIMAGE-GA — Gemini 3.1 Flash Image / 3.1 Pro Imageがネイティブ視覚モデルとしてGA。preview版は6/25に終了予定MANAGED-AGENTS — Gemini APIでManaged Agentsが公開プレビュー。Googleホストの隔離Linuxサンドボックスで自律エージェントを構築できますFILE-SEARCH — File Searchがマルチモーダル対応。gemini-embedding-2で画像のネイティブ埋め込み・検索が可能になりましたDEPRECATION — gemini-3.1-flash-image-preview / gemini-3-pro-image-previewは6/25に停止。GA版への移行をお早めにSIRI — WWDC 2026で刷新版SiriがGoogle Geminiモデルで動くと確定。ただしEUではDMAによりiOS 27時点で提供されませんFLASH3.5 — Gemini 3.5 FlashがGA。エージェント・コーディングで持続的なフロンティア性能を発揮する最上位FlashモデルですIMAGE-GA — Gemini 3.1 Flash Image / 3.1 Pro Imageがネイティブ視覚モデルとしてGA。preview版は6/25に終了予定MANAGED-AGENTS — Gemini APIでManaged Agentsが公開プレビュー。Googleホストの隔離Linuxサンドボックスで自律エージェントを構築できますFILE-SEARCH — File Searchがマルチモーダル対応。gemini-embedding-2で画像のネイティブ埋め込み・検索が可能になりましたDEPRECATION — gemini-3.1-flash-image-preview / gemini-3-pro-image-previewは6/25に停止。GA版への移行をお早めに
記事一覧/API / SDK
API / SDK/2026-05-14中級

Gemini TTS API を SwiftUI アプリに組み込む — AVAudioEngine との連携で詰まった2つの問題

Gemini TTS API が返す PCM データを SwiftUI + AVAudioEngine で再生する実装を解説。PCM フォーマット変換と AVAudioSession 設定の2つの落とし穴を、実際のコードとともに紹介します。

gemini-api286tts6swiftui4ios12avaudioengine音声合成2

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.setCategorysetActive再生の直前 に呼ぶ
  • 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・スピーカー)でテストしてみてください。音声機能はシミュレーターで動いても実機で挙動が変わるケースが多いので、早めの実機検証をおすすめします。

シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API / SDK2026-05-12
Gemini 3.2 Pro の Function Calling を iOS/Android アプリに統合する:個人開発 12 年・累計 5,000 万 DL から見えた設計パターン
Gemini 3.2 Pro の Function Calling を iOS/Android アプリに統合する実装ガイド。SwiftUI・Kotlin 両対応のコード例と、12 年の個人開発・累計 5,000 万 DL 事業で実証した本番設計パターンを解説します。
API / SDK2026-04-03
Gemini API × SwiftUI 本番アプリ実装ガイド:ストリーミング・マルチモーダル・エラーハンドリング・App Store対策まで
Gemini APIをSwiftUIアプリに本番レベルで統合する実装ノウハウ。ストリーミング応答・マルチモーダル入力・エラーハンドリング・テスト戦略・App Store申請対策まで、プロ向けの実装ノウハウを実例とともに整理します。
API / SDK2026-06-02
Firebase AI Logic で iOS から Gemini を呼ぶと 403 になる原因と対処
Firebase AI Logic(旧 Vertex AI in Firebase)で iOS アプリから Gemini を呼び出すと 403 PERMISSION_DENIED が返る問題。App Check の強制適用・API 未有効化・Blaze 未設定の3つの原因を切り分けて解決する手順を、実機検証の経験から整理します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →