はじめまして、CyberAgent AI Lab Interactive Agentチームの技術研究員の大平といいます。

この記事は CyberAgent AI Lab Advent Calendar 2024 2日目の記事です。

昨年のちょうど今頃には LLM音声対話システムの応答を高速化してみた という記事を書いていました。当時はまだ生成AIを使った音声対話が十分に開発されつくされておらず、つい一年前までこのような対話の基礎部分が開発の焦点だったことを考えると、この分野の進展の速さに驚かされます。

2024年12月現在、基本的な対話システムの構成要件はそろそろ出そろってきた感じがしており、ここから先は夢の領域(人類と共生するAIやパートナーロボット)と、ビジネスの領域(一部の仕事を自律エージェント化)に分かれていくと考えられます。夢の領域は実現までにまだ時間がかかりそうですが、ビジネス領域については実用化が近づいている感触があります。商用的な意味での対話システム開発に取り組むタイミングとしては適切かもしれません。

ということで今回は、実際のフィールドに対話システムを設置するときに重要となる身体性、つまり同時性と同空間性を持つ「ロボット対話」のコストを抑えたミニマムモデルとして、DockKitを使ったiPhone対話システムを作ってみようという試みになります。

完成品イメージ

OpenAI Real Time API

Real Time APIの特徴をChatGPTにまとめさせた内容はこちら。

  • 低遅延のマルチモーダルエクスペリエンスを構築可能。
  • 6つのプリセット音声で自然な音声対音声の会話をサポート。
  • テキストや音声の入力から応答を生成可能。
  • 従来必要だった複数モデルの組み合わせを1回のAPI呼び出しで解消。
  • 感情やアクセントの自然さを維持した会話体験を実現。
  • 中断処理を自動化し、スムーズな音声エクスペリエンスを提供。
  • 開発者は言語アプリ、教育ソフト、カスタマーサポートなどで活用可能。
  • 関数呼び出しをサポートし、アクションやパーソナライズ応答を実現可能。

上記だけでは少し分かりづらいかもしれません。私の理解では以下のようになります。

  • 簡単に音声対話が実現できる
  • 発話衝突に対応できる
  • 任意のタイミングでトリガーを発行できる(将来的には)
  • マルチモーダルに対応できる(将来的には)

という感じです。現時点では完成度の高い音声対話APIという感じで、将来的にはもう少し発展した対話システムもできそうな枠組みだという理解をしました。

具体的なAPIの使い方はnpakaさん がいい感じにまとめてくださっているので参考にしてみてください。
ここでは要点だけまとめます。

session.update

Realtime APIとはwebsocketでJSON形式でやりとりをすることになります。音声対話目的の場合、こちらからOpenAIに送らなければならない情報の種類は2つだけで、session.updateとinput_audio_buffer.appendの二つです。

private func sendInitialPrompt(instructions: String) {
  let temperatureValue = Decimal(0.8) // 小数点以下を16桁までに制限した値
  let requestInfo: [String: Any] = [
    "type": "session.update",
    "session": [
      "turn_detection": [
        "type": "server_vad",
        "threshold": 0.5,
        "prefix_padding_ms": 300,
        "silence_duration_ms": 500
      ],
      "voice": "alloy",
      "instructions": instructions,
      "modalities": ["text", "audio"],
      "temperature": temperatureValue // Decimal型で精度を制御した値
    ]
  ]

  //turn_detection:
  if let jsonData = try? JSONSerialization.data(withJSONObject: requestInfo, options: []),
    let jsonString = String(data: jsonData, encoding: .utf8) {
      webSocketTask?.send(.string(jsonString)) { error in
      if let error = error {
        print("Failed to send initial prompt: \(error.localizedDescription)")
      } else {
        print("Initial prompt sent successfully")
      }
    }
  }
}

Realtime APIにはsessionという概念があり音声対話を開始するためにはこれを作成する必要があるのですが、実はwebsocketで接続ができた時点で session.create されているようです。ですので session.create をわざわざ使う必要はなく、初期設定はsession.updateだけでよいみたいです。

重要なのは turn_detection の設定です。iPhoneに備え付けのマイクを使う以上、音質やボリュームを操作することはできません。特に iPhone はもともと電話として設計されているため、マイクの収音特性上離れた位置から話しかけられることを想定していない部品設計になっている可能性があります。
実際に iPhone + Realtime APIで話しかけてみると、こちらの応答を無視ししたりこちらの問いかけとは全く違う内容を返答するといった不安定な挙動が散見されます。これらの問題のうち、『こちらの問いかけを無視する』挙動は、turn_detectionの設定を適切に行うことで解決可能です。こちらの設定を忘れないようにしてください。

注意点としてはmodalitiesが挙げられます。現時点では textaudio の二つをいれおけばOKです。
temperature はそのまま数字を直接入れると桁数関係のエラーになるので Decimal(0.8) できちんと型を宣言してあげた方が良いです。

input_audio_buffer.append

こちらで、マイクから取得した音声チャンクを送信します。

func sendAudio(_ audioData: Data) {
  let base64Audio = audioData.base64EncodedString()
  let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64Audio]
        
  if let jsonData = try? JSONSerialization.data(withJSONObject: message, options: []),
    let jsonString = String(data: jsonData, encoding: .utf8) {
      webSocketTask?.send(.string(jsonString)) { error in
      if let error = error {
        print("WebSocket send error: \(error)")
      }
    }
  }
}

送る部分はシンプルですが、この audioData を取得するまでに一工夫必要です。

private func processAudioBuffer(buffer: AVAudioPCMBuffer, gainRate: Float) -> Data? {
    guard let channelData = buffer.floatChannelData?[0] else {
        print("チャンネルデータが見つかりません")
        return nil
    }

    let frameLength = Int(buffer.frameLength)
    let sampleRateRatio = buffer.format.sampleRate / targetSampleRate
    var resampledData = Data()
    var sampleAccumulator: Float = 0
    var sampleCount = 0

    for i in stride(from: 0, to: frameLength, by: Int(sampleRateRatio)) {
        // 入力信号にゲインを適用
        let amplifiedSample = channelData[i] * gainRate
        sampleAccumulator += amplifiedSample
        sampleCount += 1

        if sampleCount >= Int(sampleRateRatio) {
            let averageSample = sampleAccumulator / Float(sampleCount)
            // Int16の範囲にクリッピング
            let clippedSample = min(max(averageSample, -1.0), 1.0) // -1.0 ~ 1.0 の範囲に制限
            let pcmSample = Int16(clippedSample * Float(Int16.max)) // PCM 16ビットに変換
            resampledData.append(contentsOf: withUnsafeBytes(of: pcmSample.littleEndian, Array.init))
            sampleAccumulator = 0
            sampleCount = 0
        }
    }
    return resampledData.prefix(targetBufferSize)
}

上記では、マイクから取得した音声チャンクに対してリサンプリングと音量調整をしています。ただ、試したところ音量調整は必要ないと感じました。(1メートルくらいの位置から話しかけても20%~50%くらいの音量は取れていたので)
重要なのはリサンプリングの方で、Realtime APIが受け取る音声のサンプリングレートは24000、チャンクサイズは2400程度が望ましいと言われています。
(チャンクサイズの方は0.1秒くらいみたいな表現だったので多少上下しても問題ないと考えられます)

マイクによって取得できるサンプリングレートもチャンクサイズも変わってきたりするので、汎用的に動くアプリケーションにするためにはリサンプリングとチャンクサイズの調整が必要になります。
マイクから取得できる音声のチャンクサイズが指定できるなら、リサンプリング後のチャンクサイズが2400になるように計算して指定するのもありですし、2400を超えた場合や足りなかった場合は一旦適切なキューに一時的に保存し、queueの音声チャンクの合計の長さが2400を超えた段階で2400分だけを切り取って送るような実装にしてもよいかもしれません。

長くなるので要点だけをまとめると、マイクから取得した音声の以下の部分をReal Time APIの形に合わせる必要があります。

  • サンプリングレート
  • チャンクサイズ
  • データフォーマット
  • 圧縮形式(base64でいいはず)

iPhoneの場合はデータフォーマット変換は特に不要な気がしますが、Twilioなどを使う場合は変わったフォーマットを使用していますので変換が必要だったりします。

音声再生

Realtime APIから送られてくるテキストは以下のように処理しました。必要なのは response.audio.delta だけです。playAudioという関数を呼び出しています。

private func handleMessage(_ text: String) {
  guard let jsonData = text.data(using: .utf8),
  let message = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
    print("メッセージのJSON解析に失敗しました")
    return
  }

  if let type = message["type"] as? String {
    switch type {
      case "response.audio_transcript.delta":
        if let delta = message["delta"] as? String {
          DispatchQueue.main.async {
            self.transcript += delta
          }
        }
      case "response.audio_transcript.done":
        DispatchQueue.main.async {
          print("Transcript received: \(self.transcript)")
        }
      case "response.audio.delta":
        if let audioBase64 = message["delta"] as? String,
          let audioData = Data(base64Encoded: audioBase64) {
            playAudio(data: audioData)
        }
      default:
        break
    }
  }
}

この先はよくあるswiftのスピーカー再生の話なので省略します。簡単にテキストでまとめると

  • マイクで行った処理の逆の処理を行う(リサンプリング、音声フォーマット変換)
  • スピーカーの状態を監視し、スピーカーが停止状態ならQueueにためたチャンクを取り出して再生する
  • 今から再生するチャンクの音声強度を計算

という感じです。途中で音声強度を計算する理由としては、エージェントのリップシンクに使うためです。以下で計算できます。

// RMS(Root Mean Square)を計算する関数
private func calculateRMS(from buffer: AVAudioPCMBuffer) -> Float {
  guard let channelData = buffer.floatChannelData?[0] else {
    print("チャンネルデータが見つかりません")
    return 0.0
  }
        
  let frameLength = Int(buffer.frameLength)
  var rms: Float = 0.0
  for i in 0..<frameLength {
    rms += channelData[i] * channelData[i]
  }
  rms = sqrt(rms / Float(frameLength))
  return rms
}

上は暫定的な処理です。何故かというとチャンクに対して1つの音声強度しか得られないのでふんわり口を開いているかどうかくらいしかわかりません。もっと動きをシャープにするためにはスピーカー自体にアクセスしてリアルタイムに音声強度を求める必要があります。もっとちゃんとやるなら母音の解析をして口の形も出すべきですが、この辺りはLIVE2Dのようなリアルな人間に近いモデルの場合は必要になるかもしれません。

エコーキャンセル

音声ロボット界隈ではあるあるなのですが「ロボットのスピーカーから出た音がマイクに入り、ロボット一人だけで対話を始めてしまう」問題がiPhoneでも起きました。
ちゃんと対処しようとすればエコーキャンセルライブラリ(多くの場合、機械学習や最適化モデルを利用)を使用することが理想です。というかこれを実装しないと、せっかくのRealtime APIの利点である発話衝突が活かせないことになります。
短時間調査した限りでは、適切に利用できそうなライブラリは見つかりませんでした。そのため「ロボットの音声が再生している間はマイクを停止する」方法で対処したいと思います。

private func setupAudioEngine() {

   // 前略
        
   // マイク入力のタップを設定
   inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in
     guard let self = self else { return }
     // Audio bufferを配列としてアクセス
     let channelDataArray = Array(UnsafeBufferPointer(start: buffer.floatChannelData?[0], count: Int(buffer.frameLength)))

     // 最大値を計算
     let maxAmplitude = channelDataArray.max() ?? 0
     let maxPercentage = maxAmplitude * 100 // 音量の割合 (%)
     // 音量ログ出力
     print(String(format: "Current input volume: %.2f%% of maximum", maxPercentage))
            
     // 中略
            
     if self.validMicrophone, let resampledData = self.processAudioBuffer(buffer: buffer,gainRate: 1.0) {
       self.sendAudio(resampledData)
     } else {
       // 0埋めデータを作成して送信
       let zeroFilledArray = [Float](repeating: 0, count: Int(buffer.frameLength))
       let zeroFilledData = zeroFilledArray.withUnsafeBytes { rawBuffer in
         Data(buffer: rawBuffer.bindMemory(to: UInt8.self))
       }
       self.sendAudio(zeroFilledData)
     }
   }

   // 後略
}

このような感じで validMicrophone がTrueの時だけ音声を送り、そうではないときは0埋めした音声を送っています。
0埋めした音声を送るべきかどうかはちゃんと検証していません(Realtime API側が音声遅延に堅牢にするために入れている処理などがあったときに不具合になる可能性を懸念して0埋めを一応送っています)
validMicrophone を true または false にするタイミングはいろいろ考えられますが、今回の実装では「response.audio.deltaを受け取ったときにfalseにする」「speaker queueの状態を監視して、0の状態が1秒以上継続したらtrueにする」としています。
Realtime APIからは response.done などのイベントも送信されるため、それを利用するのも一つの選択肢です。(Realtime APIがきちんとスピーカーの再生時間を加味したタイミングでトリガーを送っているのかどうかわからなかったので今回は採用しませんでした)

ミニロボ君の顔を作る

このプログラムはシンプルなので全載せします。

import SwiftUI
import Combine

struct EyeAnimation: View {
    @Binding var isBlinking: Bool
    @Binding var isSleeping: Bool
    @Binding var offsetY: CGFloat
    @EnvironmentObject var api: OpenAIRealtimeAPI

    private let maxMouthHeight: CGFloat = 24  // 口の最大高さを少し拡大
    private let sizeMultiplier: CGFloat = 1.2  // 全体のサイズを控えめに拡大

    private var mouthHeight: CGFloat {
        api.voiceIntensity * maxMouthHeight
    }

    @State private var lastMouthHeight: CGFloat = 0
    @State private var lastChangeTime: Date = Date()

    var body: some View {
        GeometryReader { geometry in
            let faceWidth = min(geometry.size.width, geometry.size.height)
            let eyeWidth = faceWidth * 0.15 * sizeMultiplier
            let eyeHeight = eyeWidth * 2
            let closedEyeHeight = eyeWidth * 0.1
            let eyeSpacing = eyeWidth * 4

            Ellipse()
                .fill(Color.white)
                .frame(width: eyeWidth, height: isSleeping || isBlinking ? closedEyeHeight : eyeHeight)
                .position(x: geometry.size.width / 2 - eyeSpacing / 2 - eyeWidth / 2, y: geometry.size.height * 0.4 + offsetY)

            Ellipse()
                .fill(Color.white)
                .frame(width: eyeWidth, height: isSleeping || isBlinking ? closedEyeHeight : eyeHeight)
                .position(x: geometry.size.width / 2 + eyeSpacing / 2 + eyeWidth / 2, y: geometry.size.height * 0.4 + offsetY)

            if !isSleeping || mouthHeight > 0 {
                Rectangle()
                    .fill(Color.white)
                    .frame(width: eyeWidth * 1.1, height: mouthHeight) // 口の幅をやや控えめに調整
                    .position(x: geometry.size.width / 2, y: geometry.size.height * 0.6 + offsetY)
                    .animation(.easeInOut(duration: 0.5), value: mouthHeight)
            }
        }
        .onAppear {
            initiateBlinkTimer()
            startMouthHeightMonitor()
        }
    }

    func toggleSleepMode() {
        if !isSleeping {
            withAnimation(.easeInOut(duration: 1.0)) {
                isBlinking = true
                api.voiceIntensity = 0
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                isSleeping = true
                isBlinking = false
            }
        } else {
            initiateWakeUpAnimation()
        }
    }

    func initiateWakeUpAnimation() {
        withAnimation(.easeInOut(duration: 0.1)) {
            isBlinking = true
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
            withAnimation(.easeInOut(duration: 0.1)) {
                isBlinking = false
                isSleeping = false
                withAnimation(.easeInOut(duration: 0.5)) {
                    api.voiceIntensity = 1
                }
            }
        }
    }

    private func initiateBlinkTimer() {
        Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
            guard !isSleeping else { return }
            withAnimation(.easeInOut(duration: 0.1)) {
                isBlinking = true
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                withAnimation(.easeInOut(duration: 0.1)) {
                    isBlinking = false
                }
            }
        }
    }

    private func startMouthHeightMonitor() {
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            if mouthHeight != lastMouthHeight {
                lastMouthHeight = mouthHeight
                lastChangeTime = Date()
                if isSleeping {
                    DispatchQueue.main.async {
                        initiateWakeUpAnimation()
                    }
                }
            } else {
                if Date().timeIntervalSince(lastChangeTime) > 60 {
                    DispatchQueue.main.async {
                        if !isSleeping {
                            toggleSleepMode()
                        }
                    }
                }
            }
        }
    }
}

パラメータとかは職人芸で調整しました。デザインは以下のものをできるだけ忠実に再現できるようにしました。

https://github.com/CyberAgentAILab/Web-Eye-Animation

特に難しい所とかはないです。Ellipse()Rectangle() を使って両目と口を表現しています。Realtime APIのスピーカーの音声強度を口の高さにするため api.voiceIntensity という変数で参照しています。

DockKitで顔追従

こちらも短いので全載せします。やっていることはとてもシンプルで、インカメラを起動して画面に表示し、そしてその画面表示を透明にしているだけです。これだけで顔追従が実現できてしまいます。
実はDockKitライブラリすら使っていません。

import SwiftUI
import AVFoundation

struct DockkitController: UIViewRepresentable {
    let captureSession = AVCaptureSession()

    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        
        let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        previewLayer.videoGravity = .resizeAspectFill
        previewLayer.opacity = 0.0 // カメラ映像のみ透明に設定
        view.layer.addSublayer(previewLayer)
        
        DispatchQueue.main.async {
            previewLayer.frame = view.bounds
            previewLayer.connection?.videoRotationAngle = 90 // 横向きに設定
        }
        
        startSession()
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        if let previewLayer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
            DispatchQueue.main.async {
                previewLayer.frame = uiView.bounds
                previewLayer.connection?.videoRotationAngle = 90
            }
        }
    }

    func startSession() {
        captureSession.sessionPreset = .photo

        guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front),
              let input = try? AVCaptureDeviceInput(device: camera) else {
            print("カメラデバイスが見つかりません。")
            return
        }

        if captureSession.canAddInput(input) {
            captureSession.addInput(input)
        }

        DispatchQueue.global(qos: .userInitiated).async {
            self.captureSession.startRunning()
        }
    }

    func stopSession() {
        if captureSession.isRunning {
            captureSession.stopRunning()
        }
    }
}

もともと、DockKit自体が電動雲台(動くカメラ台)として製品をアピールしており、iPhone の「カメラ」アプリはDockKitの顔追従に対応しています。そのため同様のプログラムを実装するだけで動くのではないかと憶測はしていました。問題は別のアプリからカメラ機能を呼び出しただけでもDockKitが動くのかどうかという点だったのですが動き出したので後ろではカメラアプリと同じプログラムを参照しているのかもしれません。
顔認識や位置特定、DockKitライブラリへの軸角度情報送信などを実装しようと考えていましたが、これだけで動作したため、この方法を採用しました。

XCodeのプライバシー設定

必要なprivacy設定ですが、今回の場合ですとPrivacy-Microphone Usage DescriptionPrivacy-Camera Usage Descriptionの二つだけです。画像のようにして設定できます。

XCodeのプライバシー設定

感想

動かしてみた感じでいくとまだまだ対話部分には課題があり、答えた質問に明確に回答しないような挙動はまだ解決していません。これの抜本的解決は音声チャンクにフィルタリングするなどして音質を改善するか、異なるSTTプログラムを走らせるなどが考えられます。ノイズキャンセリングもいれられていないのでRealtime APIの特徴の一つである発話衝突に対する対処も動かせていません。Realtime APIのメリットを活かしきれず、デメリットが目立つ状況です。この点については、根気強く丁寧にデバッグを進めるか、あるいは従来型の音声認識、LLM、音声合成を別々に走らせる対話システムの方がiPhone向けという意味ではまだ現実的という可能性はありそうです。

対話部分以外のところに関してはかなり良い線行っていると思います。
世の中にはいろいろなロボットがありますが、どんなに安くてもロボット本体だけで10万近くかかり、それを動かすPCやマイクなんかも含めると20万、通信費でまた月5000円などがかかるのがロボット社会実装のよくあるコスト感でした。
家庭用ロボットとして考えた場合、皆様がお持ちのiPhoneのSIMを利用することで通信費は実質0円になり、iPhone本体も既存の所有品と考えれば追加費用は発生しません。3万円程度のDockKitの初期投資だけでロボット運用が可能となったことは、コストパフォーマンスの観点で非常に現実的な水準です。

このような製品の登場を皮切りに、この後数年はロボットの社会実装が進んでいくような気がしています。

最後に

もし、人手不足をロボットで解決したいと考えている事業者様は CyberAgent AI Lab, Interactive Agent チームまで是非お声がけください。
またこのような課題に貢献したいと考えている技術者・研究者の方がいらっしゃいましたら、AI Labでは随時、新たなメンバーを募集しておりますので、ぜひお気軽にお問い合わせください。