🔊

SkyWayで音声AIボットとリアルタイムに通話する

2023/12/17に公開

この記事は、 NTT Communications Advent Calendar 2023 17 日目の記事です。


PR を含みます。

はじめに

SkyWay は、ビデオ・音声・データ通信機能をアプリケーションに簡単に実装できる SDK&API です。ビデオ会議や、オンライン診療、遠隔ロボットなど、リアルタイムなコミュニケーションを実現します。

本記事では、ユーザが SkyWay の音声通話を介して OpenAI の AI 搭載音声ボットとリアルタイムで対話するサンプルアプリケーションの作成方法について説明します。

以下に使用する主なライブラリと用途を記載します。

openai は音声をテキストに変換する機能も持っていますが、リアルタイム変換が難しいため、音声文字起こしには deepgram を使用します。deepgram はアカウント登録時に 200 ドル分のクレジットが提供され無料で試せます。

本サンプルアプリケーションでは Bot はサーバーサイドで実行されます。しかし SkyWay の公式 JS-SDK は Node.js に対応していないため、本記事では Node.js 用の非公式の SDK である skyway-nodejs-sdk を使用します。

SkyWay は無料で試用可能です。AppID とシークレットの発行方法は以下を参照してください。

本記事のサンプルアプリケーションの完全なソースコードは次のページで公開しています

デモ

音声ボットとの通話デモ

https://twitter.com/Shin2079/status/1733815128058151374

実行方法

実行方法は README.md を参照してください。

Bot の実装

全体の流れ

ユーザと Bot の通話は次のような流れで行われます。

ユーザ -> skyway-js-sdk -> ネットワーク
-> skyway-nodejs-sdk -> deepgram -> openai.chat

openai.chat -> openai.speech -> skyway-nodejs-sdk -> ネットワーク
-> skyway-js-sdk -> ユーザ

次のソースコードはこれら全体の流れを制御しています。

src/main.ts

import {
  LocalAudioStream,
  RemoteAudioStream,
  SkyWayContext,
  SkyWayRoom,
} from "@shinyoshiaki/skyway-nodejs-sdk";
import { randomUUID } from "crypto";
import { token } from "./const.js";

import { TextToSpeech } from "./tts.js";
import { SpeechToText } from "./stt.js";
import { Agent, marks } from "./agent.js";

// 初期設定
const context = await SkyWayContext.Create(token);
// ルームを作る
const room = await SkyWayRoom.FindOrCreate(context, {
  name: randomUUID(),
  type: "sfu",
});
// ルームの名前を表示。あとでクライアント側でこの値を入力する
console.log(room.name);

// ルームにBotを参加させる
const member = await room.join();

// OpenAIのエージェントを作る
const agent = new Agent();

// AIの発言を文字列から音声に変換するモジュールを作る
const tts = new TextToSpeech();
agent.onRensponse.add(async (block) => {
  // 文字列から不要な記号を削除する
  let speak = block;
  for (const mark of marks) {
    speak = speak.replaceAll(mark, "");
  }
  // 文字列を音声に変換する
  await tts.speek(speak);
});

// AIの音声をSkyWayのルームにPublishする
await member.publish(new LocalAudioStream(tts.track), {
  codecCapabilities: [{ mimeType: "audio/opus" }],
});

// ユーザの音声を受信する
room.onStreamPublished.add(async (e) => {
  console.log("published", e.publication.id);

  // ユーザの音声を文字起こしするモジュールを作る
  const stt = new SpeechToText();
  // ユーザの音声を受信する
  const { stream } = await member.subscribe<RemoteAudioStream>(e.publication);
  // 文字起こしを開始する
  stt.start(stream);

  // 文字起こしの結果をAIに渡す
  stt.onTranscript.add(async (transcript) => {
    console.log("transcript", transcript);

    // AIが話している間は文字起こしを一時停止する
    stt.paused = true;

    await agent.request(transcript);
  });

  // AIが話し終わったら文字起こしを再開する
  tts.onEnd.add(() => {
    console.log("tts end");
    stt.paused = false;
  });
});

文字起こし

deepgram を使用してユーザの音声をテキストに変換します。

自分の環境だと deepgram の connection が頻繁に切断されたので切断を検知したら再接続するようにしています。

src/stt.ts

export class SpeechToText {
  constructor() {
    this.connect();
  }

  connect() {
    const connection = deepgram.listen.live({
      model: "enhanced",
      smart_format: true,
      punctuate: true,
      language: "ja",
    });

    connection.on(LiveTranscriptionEvents.Open, () => {
      console.log("Connection opened.");

      this.setupWebm(connection);

      connection.on(LiveTranscriptionEvents.Close, () => {
        console.log("Connection closed.");
        this.connect();
      });

      connection.on(
        LiveTranscriptionEvents.Transcript,
        async (data: LiveTranscriptionEvent) => {
          const transcript = data.channel.alternatives[0].transcript;
          if (transcript) {
            this.onTranscript.emit(transcript);
          }
        }
      );
    });
  }
}

AI エージェント

openai.chat を使用してユーザの問いかけに対する返答を生成します。

openai.chat の stream 機能を使うことで AI の返答を高速に受け取ることができます。
この後、文字列を読み上げる際に自然な単語になっている必要があるので、句読点ごとに塊を作って文字列を読み上げさせています。

src/agent.ts

import OpenAI from "openai";
import { Event } from "@shinyoshiaki/skyway-nodejs-sdk";
import { ChatCompletionMessageParam } from "openai/resources/index.mjs";
import { openaiSecret } from "./const.js";

const openai = new OpenAI({
  apiKey: openaiSecret,
});
export const marks = ["、", "。", "・", "!", "?", "?", ":", ". ", "-"];

export class Agent {
  private messages: ChatCompletionMessageParam[] = [];
  private contentBuffer: string[] = [];
  private messageBuffer = "";

  readonly onRensponse = new Event<string>();

  private response(content: string, end = false) {
    this.contentBuffer.push(content);
    if (marks.find((mark) => content.includes(mark)) || end) {
      const message = this.contentBuffer.join("");
      this.contentBuffer = [];

      this.messageBuffer += message;

      if (end) {
        this.messages.push({
          role: "assistant",
          content: this.messageBuffer,
        });
        this.messageBuffer = "";
      }
      return message;
    }
  }

  async request(transcript: string) {
    this.messages.push({ role: "user", content: transcript });

    const stream = await openai.chat.completions.create({
      model: "gpt-3.5-turbo",
      messages: this.messages,
      stream: true,
    });
    for await (const chunk of stream) {
      const [choice] = chunk.choices;
      const content = chunk.choices[0].delta.content ?? "";
      const message = this.response(content, choice.finish_reason === "stop");
      if (message) {
        this.onRensponse.emit(message);
      }
    }
  }
}

AI の返答を読み上げる

openai/speech でテキストを音声に変換し、skyway-nodejs-sdk を通じてクライアントに送信します。

openai は Opus 形式で音声を読み上げることができます。

https://platform.openai.com/docs/guides/text-to-speech/supported-output-formats

「Opus: For internet streaming and communication, low latency.」とあるようにリアルタイムな用途だと Opus 形式を選ぶとよいでしょう。

Opus というコーデックは SkyWay が利用している低遅延なメディア通信を行うことができる技術である WebRTC に対応しています。

openai が返してくる Opus のコンテナは Ogg 形式になっているので OggParser で Ogg コンテナから Opus のデータを取り出しています。取り出した Opus のデータを WebRTC 経由でユーザに送信しています。

(余談:Javascript でいい感じに Ogg のコンテナを高速にパースするライブラリが見つからなかったので頑張って自作しました。Opus ファイルを WebRTC で送信する音声ソースにする方法をおかげで学ぶことができました)

src/tts.ts

import OpenAI from "openai";
import { openaiSecret } from "./const.js";
import { OggParser, PromiseQueue } from "werift-rtp";
import {
  Event,
  MediaStreamTrack,
  RtpBuilder,
} from "@shinyoshiaki/skyway-nodejs-sdk";

const openai = new OpenAI({
  apiKey: openaiSecret,
});

export class TextToSpeech {
  private readonly builder = new RtpBuilder({ between: 20, clockRate: 48000 });
  private readonly queue = new PromiseQueue();

  readonly track = new MediaStreamTrack({ kind: "audio" });
  readonly onEnd = new Event<void>();

  speek = async (input: string) => {
    console.log("speak", { input });

    const res = await openai.audio.speech.create({
      model: "tts-1",
      voice: "nova",
      input,
      response_format: "opus",
    });

    const stream = res.body;
    const ogg = new OggParser();
    stream.on("data", async (chunk) => {
      ogg.read(chunk);
      const segments = ogg.exportSegments();

      await Promise.all(
        segments.map((segment) =>
          this.queue.push(async () => {
            const rtp = this.builder.create(segment);
            this.track.writeRtp(rtp);
            await new Promise((resolve) => setTimeout(resolve, 20));
          })
        )
      );

      if (!this.queue.running) {
        this.onEnd.emit();
      }
    });
  };
}

クライアントの実装

skyway-js-sdk と React を用いてサーバーサイドの AI と通話するクライアントアプリケーションを実装します。
SkyWay のルームに入って自分の音声の送信と AI の音声の受信をするだけのシンプルな実装です。

音声認識をサーバ側に寄せることによってクライアントアプリケーションの実装が非常に薄くなっており、他のプラットフォーム(iOS/Android など)への移植がしやすくなっています。

今回は単純化のためにクライアント側でトークンを発行していますが、一般公開するようなサービスを作る場合はシークレットキーの漏洩を防ぐために必ずサーバサイドでトークンを発行してください。

client/main.tsx

import {
  RemoteAudioStream,
  SkyWayContext,
  SkyWayRoom,
  SkyWayStreamFactory,
} from "@skyway-sdk/room";
import React, { FC, useRef, useState } from "react";
import ReactDOM from "react-dom/client";
import { token } from "./token.js";

const App: FC = () => {
  const [roomName, setRoomName] = useState<string>("");
  const audioRef = useRef<HTMLAudioElement>(null);

  const audio = audioRef.current!;

  const join = async () => {
    // 初期設定
    const context = await SkyWayContext.Create(token);
    // ルームを取得する
    const room = await SkyWayRoom.FindOrCreate(context, {
      name: roomName,
      type: "sfu",
    });
    // ルームに入る
    const member = await room.join();
    // AIが話す音声を受信する
    const { stream } = await member.subscribe<RemoteAudioStream>(
      room.publications[0]
    );
    stream.attach(audio);

    // 自分の音声を送信する
    const local = await SkyWayStreamFactory.createMicrophoneAudioStream();
    await member.publish(local);
  };

  return (
    <div>
      {/* サーバ側で出力されたルーム名を入力する */}
      <input
        value={roomName}
        onChange={(e) => setRoomName(e.target.value)}
        placeholder="roomName"
      />
      <button onClick={join}>join</button>
      <audio controls autoPlay ref={audioRef} />
    </div>
  );
};

まとめ

この記事では、リアルタイム通話に適した OpenAI の ChatAPI の使用方法を含む、次の 4 つの主要タスクの実装方法を紹介しました。

  • クライアントサーバ間のリアルタイム双方向音声通話
  • サーバサイドでのリアルタイム音声認識
  • サーバサイドでのリアルタイム音声生成
  • リアルタイム通話に適した OpenAI の ChatAPI の使い方

今回紹介したアプリケーションは最低限機能しますが、まだ次のような課題が残っています。

  • ユーザが話している途中に間が空くと、音声認識が話し終わったと判断して AI の返答が始まる。
  • AI が話しているときに止めたり話を遮ったりできない。
  • AI の話し声が時折乱れたり、発音が英語っぽくなることがある。

サンプルアプリケーションに対して、これらの課題に対する改良をしたり、音声認識や音声生成のサービスを別のものに変更して試すのも面白いでしょう。

参考文献

Discussion