🗂

AVAudioEngineとOpenAI Realtime APIで実現するiOS音声対話アプリ開発

2024/10/06に公開

はじめに

2024年10月に発表されたOpenAI Realtime APIは、リアルタイムの音声対話を可能にする新しいAPIです。この記事では、このAPIを活用してiOSアプリケーションで音声通話機能を実装する方法を解説します。

主な特徴と実装のポイントは以下の通りです:

  1. WebSocketを使用した双方向リアルタイム通信
  2. AVAudioEngineを用いた音声の録音と再生
  3. 音声フォーマットの変換(iOS <-> OpenAI Realtime API)
  4. セッション管理とシステムプロンプトの設定

このアプリケーションでは、ユーザーの音声入力に対して「マジで何でも食べる料理評論家Agent」として設定されたAIが、リアルタイムで音声応答を返します。この過程で、音声データの録音、送信、受信、再生という一連の流れを実装します。

OpenAI Realtime APIの概要

https://platform.openai.com/docs/guides/realtime

OpenAI Realtime APIはOpenAIが新しく開発したAPIで、テキストや音声の通信をリアルタイムで行うことができます。従来のSSEベースなChat Completions APIとの違いはやはり最初からマルチモーダルで音声とテキストの両方をサポートしている点です。Realtime APIはWebSocketを使用して持続的接続でフットフットプリントを小さく、双方向な通信が可能になります。ChatGPT公式アプリのAdvanced Voice ModeではこのRealtime APIと同じ仕組みを構築していると想像されます。なので公式iOSアプリの機能を一部自作するようなものですね。
以下はAPIの機能を比較した表です。

Feature Chat Completions API OpenAI Realtime API
Interaction Type Text-based conversations Multi-modal (text and speech)
Latency Slower than human conversation Low-latency streaming
Connection Type Single request-response(and streaming) Persistent WebSocket connection
Use Cases Chatbots, interactive applications Voice assistants, real-time apps

本iOSアプリの基本構造

以下では、アプリケーションの構造、各機能の実装方法、そして開発中に直面した課題とその解決策について説明していきます。

アプリの仕様はシンプルな1画面のアプリで、ユーザーはマイクを通じて話した内容をOpenAI Realtime APIに送信し、その応答をリアルタイムで受信します。システムプロンプトとして「何でも食べる料理評論家」を設定し、ユーザーの発言に対して料理や食べ物に関する評論や解説を行います。

https://x.com/laiso/status/1842867683248988427

アプリの動作風景(マイク入力は録音されていない)

「マジで何でも食べる料理評論家」AIは少し前に話題になったyuisekiさん公開の以下のAIプレイグラウンドを参考にしています。

https://dare-ai.com/apps/cugre2po80ae

本記事で使うRealtime APIの操作ではエンドポイントにWebSocketで接続するとRealtime APIのセッションを確立します。次に音声入力があると、会話(Conversation)が作成されます。音声入力の完了地点を自動で検出して、生成された音声データがpushされてきます。これをシーケンス図にすると以下のようになります。

AVAudioEngineでの録音と再生

AVAudioEngineを使用して音声の録音と再生を行います。以下は主要な部分のコード例です:

ios-app/ios-app/ContentView.swift
import AVFoundation

// AVAudioEngineとAVAudioPlayerNodeの初期化
private let audioEngine = AVAudioEngine()
private var playerNode = AVAudioPlayerNode()

// 録音開始関数
private func startRecording() {
    connectWebSocket()
    
    let inputNode = audioEngine.inputNode
    let inputFormat = inputNode.outputFormat(forBus: 0)
    
    // 入力ノードにタップを設置し、音声データを取得
    inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { buffer, _ in
        self.sendAudioData(buffer)
    }

    // プレイヤーノードの設定
    audioEngine.attach(playerNode)
    playerNode.stop()
    
    let mainMixerNode = audioEngine.mainMixerNode
    let outputFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 24000, channels: 1, interleaved: false)
    audioEngine.connect(playerNode, to: mainMixerNode, format: outputFormat)
    
    // オーディオエンジンの準備と開始
    audioEngine.prepare()
    do {
        try audioEngine.start()
        isRecording = true
    } catch {
        print("Failed to start audio engine: \(error)")
    }
    
    transcription = ""
}

// 録音停止関数
private func stopRecording() {
    audioEngine.inputNode.removeTap(onBus: 0)
    isRecording = false
    transcription = ""
}

// 音声データの送信
private func sendAudioData(_ buffer: AVAudioPCMBuffer) {
    // Base64エンコードと送信処理
    // ...
}

// 受信した音声データの処理と再生
private func handleAudioDelta(_ base64Audio: String) {
    if let buffer = base64ToPCMBuffer(base64String: base64Audio) {
        playerNode.scheduleBuffer(buffer, at: nil)
    }
    if !playerNode.isPlaying {
        playerNode.play()
        stopRecording()
    }
}

このコードでは、AVAudioEngineを使用して音声の録音と再生を管理しています。主な特徴は以下の通りです:

  1. startRecording() 関数で録音を開始し、入力ノードにタップを設置して音声データを取得します。
  2. 取得した音声データは sendAudioData() 関数を通じてサーバーに送信されます。
  3. handleAudioDelta() 関数では、サーバーから受信した音声データを AVAudioPCMBuffer に変換し、playerNode を使用して再生します。
  4. stopRecording() 関数で録音を停止し、入力ノードからタップを削除します。

AVAudioEngineについては公式ドキュメントのガイドも不足していると言っても過言ではないので、初めての方はまずは以下のスライドで入門するのがおすすめです。

https://speakerdeck.com/yaminoma/jin-ri-karafen-karu-avaudioenginefalsequan-te

音声フォーマットの仕様の違い

OpenAI Realtime APIとAVAudioEngineでは、音声フォーマットの仕様が異なります。Realtime APIは現時点では「raw 16 bit PCM audio at 24kHz, 1 channel, little-endian and G.711 at 8kHz (both u-law and a-law). 」を要求しますが、AVAudioEngineのデフォルト設定で録音した音声は「1 ch, 48000 Hz, Float32」になり互換性を持ちません。またOpenAI Realtime API側のフォーマット(pcmFormatInt16)にiOSアプリのアウトプットノードを合わせると再生時に実行時エラーが発生します[1]。このため、音声データを送受信する際には、フォーマットの変換が必要になります。

項目 iOS (AVAudioEngine) OpenAI Realtime API
サンプリングレート 48000 Hz 24000 Hz
ビット深度 32-bit float 16-bit integer
チャンネル数 1 (モノラル) 1 (モノラル)
エンコーディング PCM PCM
データ形式 AVAudioPCMBuffer Base64エンコードされた文字列

これをもとにSwiftで相互変換を行う場合は以下のように書きます。

ios-app/ios-app/ContentView.swift
// MARK: - iOS -> OpenAI Realtime API
private func base64ToPCMBuffer(base64String: String) -> AVAudioPCMBuffer? {
    let sampleRate = 24000.0
    let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)!
    
    guard let audioData = Data(base64Encoded: base64String) else {
        print("Failed to decode Base64 audio data")
        return nil
    }
    
    let frameCount = audioData.count / MemoryLayout<Int16>.size
    guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)) else {
        return nil
    }
    buffer.frameLength = AVAudioFrameCount(frameCount)
    
    audioData.withUnsafeBytes { rawBufferPointer in
        let int16BufferPointer = rawBufferPointer.bindMemory(to: Int16.self)
        for i in 0..<frameCount {
            let int16Value = int16BufferPointer[i]
            buffer.floatChannelData?[0][i] = Float(int16Value) / Float(Int16.max)
        }
    }
    
    return buffer
}

// MARK: - OpenAI Realtime API -> iOS
private func pcmBufferToBase64(pcmBuffer: AVAudioPCMBuffer) -> String? {
    guard let floatChannelData = pcmBuffer.floatChannelData else {
        return nil
    }
    
    let originalSampleRate = pcmBuffer.format.sampleRate
    let targetSampleRate = 24000.0
    let frameLength = Int(pcmBuffer.frameLength)
    
    let resampledData = resampleAudio(floatChannelData: floatChannelData.pointee, frameLength: frameLength, originalSampleRate: originalSampleRate, targetSampleRate: targetSampleRate)
    
    let int16Data = resampledData.map { Int16($0 * Float(Int16.max)) }
    
    let audioData = Data(bytes: int16Data, count: int16Data.count * MemoryLayout<Int16>.size)
    
    return audioData.base64EncodedString()
}

private func resampleAudio(floatChannelData: UnsafePointer<Float>, frameLength: Int, originalSampleRate: Float64, targetSampleRate: Float64) -> [Float] {
    let resampleRatio = targetSampleRate / originalSampleRate
    let resampledFrameLength = Int(Double(frameLength) * resampleRatio)
    var resampledData = [Float](repeating: 0, count: resampledFrameLength)
    
    for i in 0..<resampledFrameLength {
        let originalIndex = Int(Double(i) / resampleRatio)
        resampledData[i] = floatChannelData[originalIndex]
    }
    
    return resampledData
}

OpenAIサーバーとの認証とWebSocketでの送受信

Starscreamなどの簡便なWebSocketライブラリが存在しますが今回はAPIの動作を理解するため使用しません。iOS SDK内のURLSessionでもWebSocketのような双方向通信ができるのでここではそれを利用します。

以下のようにコネクションを確立し、OpenAIサーバーからpushされてくるJSONデータに各イベントタイプが含まれているので、それをハンドリングします。

ios-app/ios-app/ContentView.swift
private func connectWebSocket() {
    if isConnected {
        return
    }
    
    guard let url = URL(string: "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01") else { return }
    var request = URLRequest(url: url)
    if let apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] {
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    } else {
        print("Error: OPENAI_API_KEY not found in environment variables")
        return
    }
    request.setValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
    
    webSocketTask = URLSession.shared.webSocketTask(with: request)
    receiveMessage()
    webSocketTask?.resume()
    isConnected = true
}

private func receiveMessage() {
    webSocketTask?.receive { result in
        switch result {
        case .success(let message):
            if case .string(let text) = message, 
               let data = text.data(using: .utf8),
               let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
               let type = json["type"] as? String {
                
                switch type {
                case "session.created":
                    sendSessionUpdate()
                case "response.audio.delta":
                    if let delta = json["delta"] as? String {
                        handleAudioDelta(delta)
                    }
                case "response.audio_transcript.delta":
                    if let delta = json["delta"] as? String {
                        transcription += delta
                    }
                case "response.audio_transcript.done":
                    if let ts = json["transcript"] as? String {
                        transcription = ts
                    }
                case "response.done":
                    if let response = json["response"] as? [String: Any],
                       let status = response["status"] as? String,
                       status == "failed",
                       let statusDetails = response["status_details"] as? [String: Any],
                       let error = statusDetails["error"] as? [String: Any],
                       let errorMessage = error["message"] as? String {
                        stopRecording()
                        self.errorMessage = errorMessage
                    }
                default:
                    print("handleReceivedText:others:type=\(type)")
                    if let jsonData = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted),
                       let jsonString = String(data: jsonData, encoding: .utf8) {
                        print("Received JSON: \(jsonString)")
                    }
                }
            }
            self.receiveMessage()
        case .failure(let error):
            print("WebSocket error: \(error)")
        }
    }
}

送ることができるイベントと受け取ることができるイベントの種類は以下のドキュメントに記載されています。

https://platform.openai.com/docs/guides/realtime/events

OPENAI_API_KEYの設定

OPENAI_API_KEYはXcodeのSchema Editorで設定します。

Column: iOSアプリにおけるAPIキー(クレデンシャル)の取り扱いについて

本アプリではOpenAIサーバーとの認証にBearerトークン(APIキー)を使用します。これはXcodeの環境変数に設定しておき、アプリ起動時に読み込むようにします。なのでAPIキーは配布物に含みません。Xcodeから実行した時のみすべての機能が動作します。

一般的にはAPIキーは直接アプリに含めるのではなく、サーバー側に保持することを推奨します。それがプレーンテキストでもコンパイル対象のソースコードでもパッケージ解析や通信内容の監視などで知ることができるからです。OpenAI自身のWebブラウザ向けクライアントサイドJavaScriptアプリケーションのサンプルでもWebSocketのリレーサーバーを経由しています[2]。先ほどのシーケンス図でいうとiOSとOpenAI Realtime APIの間にリレーサーバーが存在しているということです。

WebSocketに接続したらセッションを更新してシステムプロンプトを更新する

以降は音声通話機能の実装について、主要な部分を説明します。コードの一部を抜粋して解説します。

WebSocket接続後、セッションの更新とシステムプロンプトの設定を行います。これには"session.update"で上書きしたい値をJSONで送信します。送受信に利用するJSONデータの取り扱いはUtility関数を作っておくと便利ですがひとまずはいいでしょう[3]

ios-app/ios-app/ContentView.swift
private func sendSessionUpdate() {
    let event: [String: Any] = [
        "type": "session.update",
        "session": [
            "modalities": ["text", "audio"],
                "instructions": """
あなたは「マジで何でも食べる料理評論家」です。どんな架空の食材や料理でも、実際に食べたかのように詳細にレビューしてださい。食感、味、見た目、そして食べた後の感想をユーモアを交えて表現してください。物理的に不可能な食材や料理でも、あたかも現実のものであるかのように描写してください。
ユーザーが話した言葉がレビューの対象です。依頼されていない料理についてはレビューしないでください。ではどうぞ。
""",
            "voice": "echo"
        ]
    ]
    
    if let jsonData = try? JSONSerialization.data(withJSONObject: event),
        let jsonString = String(data: jsonData, encoding: .utf8) {
        webSocketTask?.send(.string(jsonString)) { error in
            if let error = error {
                print("Error sending session update: \(error)")
            }
        }
    }
}

Column: (きこえますか...あなたの脳に直接呼びかけています)

オーディオプログラミングではよくあるインプットデバイスからの音声が正しく入力できているかの確認は、本アプリでは困難を極めます。音声フォーマットの変換、AVAudioEngineの状態管理、WebSocketのコネクションの確立などがあるためです。これに加えて、サーバーへシリアライズされた文字列としてデコードして差分データを送っているので生成AIモデル側に実際どのような音声が送られているかのブラックボックスとなり確認はできません。

そこで筆者は以下のようにプロンプトレベルでまるで旧時代の電信システムのように、問いかけてデバッグすることになりました。

                "instructions": """
聞こえていますか?聞こえる場合はユーザーの話している言葉をそのまま返信してください。もし聞こえていない場合は「聞こえていません」と言ってください。
"""

冗談のような話これは期待通りに動作しました。そのまま返信(echo)を要求しているのでハルシネーションというわけではありません。LLMがブラックボックスであり人間との対話に最適化されているが故にこのようなことが起こるのが面白いと感じました。

AVAudioEngineで音声録音を開始して一定間隔で音声データを送信する

音声録音の開始と、録音データの送信を行います。

ios-app/ios-app/ContentView.swift
private func startRecording() {
    // ...
    let inputNode = audioEngine.inputNode
    let inutFormat = inputNode.outputFormat(forBus: 0)
    inputNode.installTap(onBus: 0, bufferSize: 1024, format: inutFormat) { buffer, _ in
        self.sendAudioData(buffer)
    }
    // ...
}

private func sendAudioData(_ buffer: AVAudioPCMBuffer) {
    // バッファをBase64エンコード
    guard let base64Audio = self.pcmBufferToBase64(pcmBuffer: buffer) else {
        return
    }
    
    // WebSocketでデータを送信
    let event: [String: Any] = [
        "type": "input_audio_buffer.append",
        "audio": base64Audio,
    ]
    
    // JSONデータを送信
    // ...
}

WebSocketで音声データを受信する

WebSocketから受信したメッセージを処理します。レスポンスにはbase64エンコードされた音声データが含まれています。これをAVAudioPCMBufferに変換して最終的にはユーザーのデバイス上でストリーミング再生します。

ios-app/ios-app/ContentView.swift
// 前述のコードから抜粋
private func receiveMessage() {
    webSocketTask?.receive { result in
        switch result {
        case .success(let message):
            if case .string(let text) = message,
               let data = text.data(using: .utf8),
               let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
               let type = json["type"] as? String {
                
                switch type {
                case "response.audio.delta":
                    if let delta = json["delta"] as? String {
                        handleAudioDelta(delta)
                    }
                // その他のケース処理
                // ...
                }
            }
            self.receiveMessage() // 継続的にメッセージを受信
        case .failure(let error):
            print("WebSocket error: \(error)")
        }
    }
}

handleAudioDeltaでは受信した音声データを変換して再生します。"response.audio.delta"は会話の途中での音声データが逐次的に送られてくるので、それをバッファに変換したらそのままスケジューリングすると自動で連続再生されます。AVAudioEngineすごい。

ios-app/ios-app/ContentView.swift
private func handleAudioDelta(_ base64Audio: String) {
    if let buffer = base64ToPCMBuffer(base64String: base64Audio) {
        playerNode.scheduleBuffer(buffer, at: nil)
    }
    if !playerNode.isPlaying {
        playerNode.play()
        stopRecording()
    }
}

ここまで実装すると前述の動作風景のようになります。SwiftUIの完全なコードは以下のURLにあります。

https://gist.github.com/laiso/0239c6f0a825a0fb477c5764f930601a

おわりに

本記事では、OpenAI Realtime APIを活用してリアルタイムの音声通話機能を持つiOSアプリケーションの開発について解説しました。主な実装ポイントとして、以下の点を詳しく説明しました:

  1. AVAudioEngineを使用した音声の録音と再生
  2. WebSocketを通じたOpenAI Realtime APIとの双方向通信
  3. 音声フォーマットの変換(iOS <-> OpenAI Realtime API)
  4. セッション管理とシステムプロンプトの設定

またOpenAI Realtime APIにはAgent特有のToolsを定義した多段階の連携処理を行うことができます。また会話履歴の操作や、モード切り替えによる手動コミット(返答開始をこちらから指定できる)等のトピックについては書ききれなかったので、また別の機会に解説したいと思います。

本記事が、OpenAI Realtime APIを使用したiOSアプリケーション開発の一助となり、皆様のプロジェクトに貢献できれば幸いです。

脚注
  1. pcmFormatInt16問題:例によってフォーラム情報ですが実際に試すとアプリが落ちます… AVAudioEngine connect:to:format: f… | Apple Developer Forums ↩︎

  2. https://github.com/openai/openai-realtime-console/blob/971323d7f81b42f14177741e4c8666bbe4591c1c/relay-server/index.js ↩︎

  3. 筆者はリサーチの過程で以下のクライアントライブラリを見つけました。https://github.com/m1guelpf/swift-realtime-openai ↩︎

Discussion