🎙️

オンライン会議にボットを参加させリアルタイムに文字起こし

2025/02/25に公開

音声データの取得と文字起こし

Google Meet や Zoom のようなオンライン会議ツールと連携した議事録ツールを作るにあたって、

  • 音声データの取得
  • 文字起こし

を行いたい。特に、リアルタイムで文字起こしをするには、ボットやChromeプラグイン、デスクトップアプリで会議の音声データを取得する必要がある。同時に、取得した音声データを、WebSocket等でリアルタイムにSpeech to Text モデルに送信し、文字起こしデータを受信したい。今回は、Meeting Baas と Gladia の API を利用して、それぞれ WebSocket でデータを送受信してみる。

Meeting Baas とは

Google Meet や Zoom, Teams に対応したボットを作成できる開発者向けAPI。会議の音声データを、ストリーム、録音、文字起こし等の形で取得できる。
https://meetingbaas.com/

curl -X POST "https://api.meetingbaas.com/bots" \
     -H "Content-Type: application/json" \
     -H "x-meeting-baas-api-key: [Meeting Baas の API Key]" \
     -d '{
           "meeting_url": "[Google Meet の URL]",
           "bot_name": "[ボットの表示名]",
           "recording_mode": "speaker_view",
           "bot_image": "[ボットの画像URL]",
           "entry_message": "[ボットが会議に参加した際に投稿するメッセージ]",
           "reserved": false,
           "speech_to_text": {
             "provider": "Default"
           },
           "automatic_leave": {
             "waiting_room_timeout": 600
           }
         }'

ボットの状態は、webhookで受け取る。ボットの状態は、
joining_call, in_waiting_room, in_call_not_recording,in_call_recording, call_ended のように変化し、最終的に、録画データのURLと文字起こしデータを含むイベントが飛んできます。
参考:Getting the data | Meeting Baas
その他、WebSocketのエンドポイントを指定してリアルタイムに音声データを取得することも可能。

Gladia とは

非同期とリアルタイムの両方に対応した文字起こしAPI。
※ Meeting Baas のボット作成時に、文字起こしツールとして指定することもできる。

"speech_to_text": {
    "api_key": "[Gladia の API Key]",
    "provider": "Gladia"
},

Gladia のリアルタイム文字起こしは、

  1. セッションの作成、WebSocket URLの取得
  2. WebSocketで音声データを送信/文字起こしデータを受信
    の流れで行う。
    Real-time Speech-to-Text Getting Started
curl --request POST \
  --url https://api.gladia.io/v2/live \
  --header 'Content-Type: application/json' \
  --header 'x-gladia-key: [Gladia の API Key]' \
  --data '{
        "encoding": "wav/pcm",
        "sample_rate": 16000,
        "bit_depth": 16,
        "channels": 1
    }
  '

セッションのidurlが取得できます。
WebSocketで接続し、音声データを送るとともに文字起こしデータを受け取ります。

const socket = new WebSocket(url);

//音声データを送信
socket.send(buffer);

this.socket.on("message", (message) => {
    //文字起こしや状態変化のイベントを受け取り
})

会議のリアルタイム文字起こしを試す

今回は、Meeting BaaS と Gladia にそれぞれWebSocketで接続し、会議の音声をリアルタイムに文字起こししてみます。

バックエンドを実装

  • Meeting BaaS からの音声データを受信する WebSocketサーバを作成
  • Gladia とのセッションを作成して、WebSocket 接続
  • Meeting Baasから音声データを受信したら、Gladia に送信
  • Gladia から受信した文字起こしデータを出力
import WebSocket, { WebSocketServer } from "ws";
import fetch from "node-fetch";
import dotenv from "dotenv";

dotenv.config();

// 環境変数の取得、適宜 .env に設定
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080;
const GLADIA_API_BASE = process.env.GLADIA_API_BASE || "https://api.gladia.io";
const GLADIA_API_KEY = process.env.GLADIA_API_KEY || "";
const GLADIA_LIVE_API = `${GLADIA_API_BASE}/v2/live`;
const RECONNECT_INTERVAL = 5000;

// WebSocket の接続管理
class ConnectionManager {
    private gladiaUrl: string | null = null;
    private gladiaWs: WebSocket | null = null;
    // Gladia の WebSocket を再接続するインターバル
    private reconnectTimer: NodeJS.Timeout | null = null;
    // Meeting BaaS から音声データを受け取る WebSocket サーバ
    private wss: WebSocketServer;

    constructor() {
        this.wss = new WebSocketServer({ port: PORT });
        this.setupWebSocketServer();
    }

    // Meeting BaaS から音声データを受け取る WebSocket サーバを作成
    private setupWebSocketServer() {
        this.wss.on("connection", (ws: WebSocket) => {
            console.log("New Meeting BaaS connection established");

            // Gladia の WebSocket に音声データを送る
            ws.on("message", (audioData: Buffer) => {
                if (!this.gladiaWs || this.gladiaWs.readyState !== WebSocket.OPEN) return;
                this.sendToGladia(audioData);
            });

            ws.on("error", (error) => {
                console.error("Meeting BaaS connection error:", error);
            });

            ws.on("close", (code, reason) => {
                console.log("Meeting BaaS disconnected", {
                    code,
                    reason: reason.toString()
                });
            });
        });

        this.wss.on("error", (error) => {
            console.error("[Server] WebSocket server error:", error);
        });
    }


    // Gladia と WebSocket で接続
    async connectToGladia() {        
        // Gladia とのセッションを作成
        try {
            const response = await fetch(GLADIA_LIVE_API, {
                method: "POST",
                headers: {
                    "X-Gladia-Key": GLADIA_API_KEY,
                    "Content-Type": "application/json",
                },
                body: JSON.stringify({
                    encoding: "wav/pcm",
                    sample_rate: 16000,
                    bit_depth: 16,
                    channels: 1,
                }),
            });

            const data = await response.json();
            if (!data.url) throw new Error("Failed to get Gladia WebSocket URL");

            this.gladiaUrl = data.url;
            this.setupGladiaWebSocket();
        } catch (error) {
            console.error("[Gladia] Error connecting to API:", error);
            this.scheduleGladiaReconnect();
        }
    }


    // Gladia で作成したセッションと WebSocket 接続
    private setupGladiaWebSocket() {
        if (!this.gladiaUrl) return;

        this.gladiaWs = new WebSocket(this.gladiaUrl);

        this.gladiaWs.on("open", () => {
            if (this.reconnectTimer) {
                clearTimeout(this.reconnectTimer);
                this.reconnectTimer = null;
            }
        });

        this.gladiaWs.on("message", (message) => {
            try {
                const data = JSON.parse(message.toString());

                switch (data.type) {

                    // 文字起こしデータを出力
                    case "transcript":
                        const utterance = data.data.utterance;
                        console.log(`Transcript:`, {
                            sessionId: data.session_id,
                            isFinal: data.data.is_final,
                            text: utterance.text,
                            language: utterance.language,
                            confidence: utterance.confidence,
                            speaker: utterance.speaker,
                            channel: utterance.channel,
                            timeRange: `${utterance.start}-${utterance.end}`,
                            words: utterance.words.length
                        });
                        break;
                }
            } catch (error) {
                console.error("Error processing Gladia message:", error);
            }
        });

        this.gladiaWs.on("error", (error) => {
            console.error("WebSocket error:", error);
            this.gladiaWs?.close();
        });

        this.gladiaWs.on("close", (code, reason) => {
            this.scheduleGladiaReconnect();
        });
    }

    private sendToGladia(audioData: Buffer) {

        if (!this.gladiaWs || this.gladiaWs.readyState !== WebSocket.OPEN) return;
        try {
            this.gladiaWs.send(audioData);
        } catch (error) {
            console.error("Error sending audio to Gladia:", error);
        }
    }


    // Gladia との WebSocket 接続が切れた場合の再接続
    private scheduleGladiaReconnect() {
        if (this.reconnectTimer) return;

        this.reconnectTimer = setTimeout(() => {
            this.reconnectTimer = null;
            this.connectToGladia();
        }, RECONNECT_INTERVAL);
    }

    public close() {
        if (this.reconnectTimer) {
            clearTimeout(this.reconnectTimer);
            this.reconnectTimer = null;
        }

        if (this.gladiaWs) {
            this.gladiaWs.close();
            this.gladiaWs = null;
        }

        this.wss.close();
    }
}

async function main() {
    const manager = new ConnectionManager();
    
    process.on('SIGINT', () => {
        console.log('[Main] Received SIGINT signal');
        manager.close();
        process.exit(0);
    });

    try {
        await manager.connectToGladia();
    } catch (error) {
        console.error('Failed to initialize connection manager:', error);
        process.exit(1);
    }
}

main().catch(error => {
    console.error('Unhandled error:', error);
    process.exit(1);
});

テスト環境の作成

  1. サーバの起動
    Meeting BaaSから音声データを受信するために、ローカルで起動したサーバを、ngrok を用いて外部に公開します。
ngrok http 8080
  1. オンライン会議とボットの作成
    Google Meet で新しい会議を作成し、URLを取得。
    https://meet.google.com/[id]
    Meeting BaaS の APIを呼び出して、ボットを作成 & 会議に追加します。
curl -X POST "https://api.meetingbaas.com/bots" \
     -H "Content-Type: application/json" \
     -H "x-meeting-baas-api-key: [Meeting Baas の API Key]" \
     -d '{
           "meeting_url": "[Google Meet の URL]",
           "bot_name": "[ボットの表示名]",
           "recording_mode": "speaker_view",
           "bot_image": "[ボットの画像URL]",
           "entry_message": "[ボットが会議に参加した際に投稿するメッセージ]",
           "reserved": false,
           "speech_to_text": {
             "provider": "Default"
           },
           "streaming": {
                "audio_frequency": "16khz",
                "input": "wss://[ngrokで作成したエンドポイント]",
                "output": "wss://[ngrokで作成したエンドポイント]"
           },
           "automatic_leave": {
             "waiting_room_timeout": 600
           }
         }'

しばらくすると、Google Meet の参加者として、ボットが参加リクエストをするので、承認しましょう。Meeting BaaS のボットの状態変化は、Webhookで受け取ることができます。webhook-test.comを使ってテスト用のエンドポイントを作成し、Meeting BaaSの管理画面に設定すると、サクッと動作検証できます。

文字起こしデータを確認する

会議で何か話してみてください。Gladia からリアルタイムに文字起こしデータが送られてくることが確認できます。

New Meeting BaaS connection established
Transcript: {
  sessionId: '2183b331-17a8-4147-8cae-8fb0cf2f15b8',
  isFinal: true,
  text: ' 音声データのテスト',
  language: 'ja',
  confidence: 1,
  speaker: undefined,
  channel: 0,
  timeRange: '0.9960000000000001-2.54',
  words

まとめ

この記事では、Meeting BaaSとGladiaのAPIを利用して、オンライン会議の音声をリアルタイムで取得し文字起こしする実装を試してみました。実際の利用では、会議やボットの状態を WebhookやWebSocketで取得した状態を使ってより細かく制御し、文字起こしやデータの要約を行うことになります。また、ボットの会議への追加は、事前に参加予約をしておいたり、自分のカレンダーと連携して自動追加することになるでしょう ( Syncing Calendars )。

Sparkle AIブログ

Discussion