0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zoom RTMSで手話翻訳アプリを作る:リアルタイム文字起こしから手話アバター表示まで

Last updated at Posted at 2025-12-19

はじめに

Zoomの RTMS(Realtime Media Streams) を使って、ミーティングの音声をリアルタイムで手話に翻訳し、アバターで表示するアプリを作りました。この記事では、開発過程で詰まったポイントや解決策を含めて、実装の全体像を解説します。

完成イメージ

Screenshot 2025-12-18 at 17.57.07.png

  • Zoomミーティング中の発言がリアルタイムで文字起こしされる
  • 文字起こしテキストが手話(ASL)に翻訳される
  • pose-viewerコンポーネントでスケルトンアニメーションとして表示

使用技術

カテゴリ 技術
バックエンド Node.js, Express, WebSocket
フロントエンド Vanilla JS, pose-viewer
Zoom連携 RTMS API, Zoom Apps SDK
手話翻訳 sign.mt API

1. アーキテクチャ概要

シーケンス図(小さくてすみません)

データフロー

  1. 参加者が発言 → Zoomが音声を文字起こし
  2. RTMS Webhookmeeting.rtms_started イベント受信
  3. Signaling WebSocket接続 → ハンドシェイク → Media WebSocket接続
  4. Transcript受信 (msg_type: 17) → sign.mt APIで手話ポーズデータ取得
  5. フロントエンドにブロードキャスト → pose-viewerで表示

2. 環境構築

2.1 プロジェクト構成

rtms-asl-viewer/
├── backend/
│   └── server.js          # Express + WebSocket サーバー
├── frontend/
│   └── public/
│       ├── index.html     # メインHTML
│       ├── app.js         # フロントエンドロジック
│       ├── styles.css     # スタイル
│       └── pose-viewer/   # pose-viewer Web Component直置き
├── package.json
└── .env

2.2 必要なパッケージ

{
  "type": "module",
  "dependencies": {
    "express": "^4.18.2",
    "ws": "^8.14.2",
    "dotenv": "^16.3.1"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

2.3 環境変数 (.env)

# Zoom App Credentials
ZOOM_CLIENT_ID=your_client_id
ZOOM_CLIENT_SECRET=your_client_secret
ZOOM_SECRET_TOKEN=your_webhook_secret_token

# Server Config
PORT=3000
WEBHOOK_PATH=/webhook

# Sign Language Settings
SPOKEN_LANGUAGE=en
SIGN_LANGUAGE=ase //現状jslはsign.mt側でうまく動かずHTTP500でした

2.4 Zoom Marketplace設定

  1. App Type: Zoom Apps (General App)
  2. Surface: In-Meeting
  3. Scopes:
    • meeting:read:meeting_caption_rtms
    • meeting:read:rtms
  4. Zoom App SDK APIs:
    • getRunningContext
    • getMeetingContext
    • その他必要なAPI

3. 詰まりポイント①:OWASPセキュリティヘッダー

問題

Zoom Appパネルが真っ白で何も表示されない。ブラウザで直接アクセスすると表示されるのに、Zoomクライアント内では表示されない。

原因

Zoom AppsはOWASPセキュリティヘッダーが必須。これがないとZoomクライアントがレンダリングをブロックする。

Marketplaceの設定画面に以下の警告が表示される:

⚠️ Home URL is missing required OWASP response header(s):
  - Strict-Transport-Security
  - X-Content-Type-Options
  - Content-Security-Policy
  - Referrer-Policy

解決策

Expressミドルウェアでセキュリティヘッダーを追加:

// ============================================================
// OWASP Security Headers (Required for Zoom Apps)
// ============================================================
app.use((req, res, next) => {
    // これがないとZoom Appsで白紙になることがあります
    res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('X-Frame-Options', 'SAMEORIGIN');
    res.setHeader('X-XSS-Protection', '1; mode=block');
    res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
    res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
    
    // Content Security Policy
    res.setHeader('Content-Security-Policy', [
        "default-src 'self'",
        "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://appssdk.zoom.us",
        "style-src 'self' 'unsafe-inline'",
        "connect-src 'self' wss: ws: https://appssdk.zoom.us",
        "frame-ancestors 'self' https://*.zoom.us",
        "frame-src 'self'"
    ].join('; '));
    
    next();
});

ポイント

  • Strict-Transport-Security が特に重要
  • frame-ancestorshttps://*.zoom.us を含める必要がある
  • CSPの connect-src にWebSocket (wss:, ws:) を含める

4. 詰まりポイント②:RTMS署名生成

問題

Signaling WebSocketに接続後、すぐに切断される。ログを見ると stop_reason: 18 (認証失敗)。

✅ Signaling WebSocket opened
📤 Sending signaling handshake...
🔌 Signaling WebSocket closed: stop_reason: 18

原因

署名生成時のメッセージフォーマットが間違っていた。

// ❌ 間違い:CLIENT_IDが含まれていない
const message = `${meetingUuid},${rtmsStreamId}`;

// ✅ 正解:CLIENT_IDを含めて、これをsecretでSHA256ハッシュする
const message = `${ZOOM_CLIENT_ID},${meetingUuid},${rtmsStreamId}`;

解決策

正しい署名生成関数:

function generateSignature(meetingUuid, rtmsStreamId) {
    // CRITICAL: CLIENT_IDを含める!
    const message = `${ZOOM_CLIENT_ID},${meetingUuid},${rtmsStreamId}`;
    const signature = crypto
        .createHmac('sha256', ZOOM_CLIENT_SECRET)
        .update(message)
        .digest('hex');
    
    console.log('🔐 Signature Debug:');
    console.log(`   Message: ${message}`);
    console.log(`   Signature: ${signature.substring(0, 16)}...`);
    
    return signature;
}

リファレンス

RTMS / Meetings / Working with streams の Step 3: App establishes signaling connection には

Run the following command to create a signature that your app will use to securely connect to the RTMS server; replacing client_id and secret with your app's Client ID and Client Secret, and meeting_uuid and rtms_stream_id with the meeting_uuid and rtms_stream_id from the streaming notification event.

HMACSHA256(client_id + "," + meeting_uuid + "," + rtms_stream_id, secret);

この順番と区切り文字(カンマ)が重要です。

5. 詰まりポイント③:sign.mt API fetch失敗

問題

サーバーからsign.mt APIにfetchすると fetch failed エラー。

🤟 Translating to sign language: "Hello"
❌ sign.mt translation error: fetch failed

原因

ngrok経由でのSSL証明書チェーン検証エラー。

$ node -e "fetch('https://us-central1-sign-mt.cloudfunctions.net/...').then(console.log).catch(console.error)"

Error: self-signed certificate in certificate chain

解決策(開発環境のみ)

// ⚠️ 開発環境専用!本番では使わないこと!
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';

本番環境では適切なSSL証明書を設定する必要があります。

6. 詰まりポイント④:pose-viewerで「Load failed」

問題

サーバーでpose dataを取得成功し、フロントエンドにWebSocketで送信も成功。しかしpose-viewerに「Load failed」と表示される。

試行錯誤の過程

試行1: Blob URL

// Base64をデコードしてBlob URLを作成
const binaryString = atob(poseBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);

elements.poseViewer.setAttribute('src', blobUrl);

結果: Blob URLはブラウザのメモリ内にのみ存在するため、Zoom Apps環境(iframe内)では 別コンテキストからアクセスできない

試行2: Data URL

const dataUrl = `data:application/octet-stream;base64,${poseBase64}`;
elements.poseViewer.setAttribute('src', dataUrl);

結果: データが重い。 4MB超のBase64データ をData URLとして渡すとブラウザの制限に引っかかる。

試行3: サーバーキャッシュ方式 ✅

サーバー側でPose dataをキャッシュし、URLとして提供する方式。

解決策

サーバー側

// Pose Data Cache
const poseCache = new Map();
let poseIdCounter = 0;

// Pose Data Endpoint
app.get('/pose/:id', (req, res) => {
    const cached = poseCache.get(req.params.id);
    if (cached) {
        res.set('Content-Type', 'application/octet-stream');
        res.send(cached.buffer);
    } else {
        res.status(404).send('Not found');
    }
});

// sign.mt APIからデータ取得してキャッシュ
async function fetchSignLanguagePose(text) {
    const response = await fetch(signMtUrl);
    const buffer = Buffer.from(await response.arrayBuffer());
    
    const poseId = `p${++poseIdCounter}`;
    poseCache.set(poseId, {
        buffer: buffer,
        timestamp: Date.now()
    });
    
    return poseId;
}

// Transcript処理時にURLを送信
async function handleTranscript(msg) {
    const poseId = await fetchSignLanguagePose(msg.content.data);
    
    broadcastToFrontend({
        type: 'transcript',
        content: {
            text: msg.content.data,
            poseUrl: poseId ? `/pose/${poseId}` : null,
            // ...
        }
    });
}

フロントエンド側

function displaySignLanguageAvatar(poseUrl, userName) {
    // 同一オリジンのURLを直接設定
    elements.poseViewer.setAttribute('src', poseUrl);
}

なぜこれで動くのか

  1. 同一オリジン: /pose/p1 は自サーバーへのリクエストなのでCORS問題なし
  2. Zoom Apps環境でもOK: 通常のHTTPリクエストなのでiframe内からもアクセス可能
  3. サイズ制限なし: URLは短いのでData URLの制限を受けない

7. 実装詳細:RTMS接続フロー

7.1 Webhook受信

app.post('/webhook', (req, res) => {
    const { event, payload } = req.body;
    
    // URL検証チャレンジ
    if (event === 'endpoint.url_validation') {
        const hash = crypto
            .createHmac('sha256', ZOOM_SECRET_TOKEN)
            .update(payload.plainToken)
            .digest('hex');
        
        return res.json({
            plainToken: payload.plainToken,
            encryptedToken: hash
        });
    }
    
    // RTMS開始イベント
    if (event === 'meeting.rtms_started') {
        const { meeting_uuid, rtms_stream_id, server_urls } = payload;
        connectToSignalingWebSocket(meeting_uuid, rtms_stream_id, server_urls);
    }
    
    res.sendStatus(200);
});

7.2 Signaling WebSocket接続

function connectToSignalingWebSocket(meetingUuid, rtmsStreamId, serverUrls) {
    const signalingUrl = parseServerUrls(serverUrls).signaling;
    const signalingWs = new WebSocket(signalingUrl);
    
    signalingWs.on('open', () => {
        // SIGNALING_HAND_SHAKE_REQ (msg_type: 1)
        signalingWs.send(JSON.stringify({
            msg_type: 1,
            protocol_version: 1,
            meeting_uuid: meetingUuid,
            rtms_stream_id: rtmsStreamId,
            sequence: 0,
            signature: generateSignature(meetingUuid, rtmsStreamId)
        }));
    });
    
    signalingWs.on('message', (data) => {
        const msg = JSON.parse(data.toString());
        
        // SIGNALING_HAND_SHAKE_RESP (msg_type: 2)
        if (msg.msg_type === 2 && msg.status_code === 0) {
            const transcriptUrl = msg.media_server.server_urls.transcript;
            connectToMediaWebSocket(transcriptUrl, meetingUuid, rtmsStreamId, signalingWs);
        }
        
        // KEEP_ALIVE_REQ (msg_type: 12)
        if (msg.msg_type === 12) {
            signalingWs.send(JSON.stringify({
                msg_type: 13,
                timestamp: msg.timestamp
            }));
        }
    });
}

7.3 Media WebSocket接続(Transcript用)

function connectToMediaWebSocket(mediaUrl, meetingUuid, rtmsStreamId, signalingWs) {
    const mediaWs = new WebSocket(mediaUrl);
    
    mediaWs.on('open', () => {
        // DATA_HAND_SHAKE_REQ (msg_type: 3) - Transcriptのみ要求
        mediaWs.send(JSON.stringify({
            msg_type: 3,
            protocol_version: 1,
            sequence: 0,
            meeting_uuid: meetingUuid,
            rtms_stream_id: rtmsStreamId,
            signature: generateSignature(meetingUuid, rtmsStreamId),
            media_type: 8  // TRANSCRIPT enum
        }));
    });
    
    mediaWs.on('message', async (data) => {
        const msg = JSON.parse(data.toString());
        
        // DATA_HAND_SHAKE_RESP (msg_type: 4)
        if (msg.msg_type === 4 && msg.status_code === 0) {
            // CLIENT_READY_ACK (msg_type: 7) をSignaling経由で送信
            signalingWs.send(JSON.stringify({
                msg_type: 7,
                rtms_stream_id: rtmsStreamId
            }));
        }
        
        // MEDIA_DATA_TRANSCRIPT (msg_type: 17)
        if (msg.msg_type === 17 && msg.content?.data) {
            await handleTranscript(msg);
        }
    });
}

8. 実装詳細:フロントエンド

8.1 HTML構造

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>RTMS ASL Viewer</title>
    
    <!-- Zoom Apps SDK -->
    <script src="https://appssdk.zoom.us/sdk.min.js"></script>
    
    <!-- pose-viewer Web Component -->
    <script type="module" src="./pose-viewer/pose-viewer.esm.js"></script>
    
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div id="app">
        <header class="header">
            <h1>ASL Viewer</h1>
            <div id="connection-status" class="status">
                <span class="status-dot"></span>
                <span class="status-text">Connecting...</span>
            </div>
        </header>

        <!-- Debug Panel -->
        <details class="debug-panel">
            <summary>Debug Panel</summary>
            <div id="debug-content"></div>
        </details>

        <main class="main-content">
            <!-- Sign Language Avatar -->
            <section class="avatar-section">
                <div class="avatar-container">
                    <div id="avatar-placeholder">
                        <p>Waiting for transcript...</p>
                    </div>
                    <pose-viewer 
                        id="pose-viewer"
                        autoplay="true"
                        loop="false"
                        style="display: none;">
                    </pose-viewer>
                </div>
            </section>

            <!-- Live Transcript -->
            <section class="transcript-section">
                <h2>Live Transcript</h2>
                <div id="transcript-container"></div>
            </section>
        </main>
    </div>
    
    <script src="app.js"></script>
</body>
</html>

8.2 Zoom Apps SDK初期化

async function initializeZoomSdk() {
    // SDK存在チェック
    if (typeof zoomSdk === 'undefined') {
        console.log('Not running inside Zoom');
        return;
    }
    
    try {
        // 最小限のcapabilitiesで初期化
        await zoomSdk.config({
            capabilities: ['getRunningContext'],
            version: '0.16.13'
        });
        
        const context = await zoomSdk.getRunningContext();
        console.log('Running context:', context);  // { context: "inMeeting" }
        
    } catch (error) {
        console.error('SDK error:', error);
    }
}

8.3 WebSocket接続とメッセージ処理

function connectWebSocket() {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/ws`;
    
    const ws = new WebSocket(wsUrl);
    
    ws.onmessage = (event) => {
        const message = JSON.parse(event.data);
        
        switch (message.type) {
            case 'transcript':
                handleTranscript(message.content);
                break;
            case 'rtms_started':
                updateStatus('RTMS Active');
                break;
            case 'rtms_ready':
                updateStatus('Ready for transcripts');
                break;
        }
    };
}

function handleTranscript(content) {
    const { text, userName, poseUrl } = content;
    
    // トランスクリプト表示
    displayTranscript(text, userName);
    
    // 手話アバター表示
    if (poseUrl) {
        displaySignLanguageAvatar(poseUrl, userName);
    }
}

function displaySignLanguageAvatar(poseUrl, userName) {
    const poseViewer = document.getElementById('pose-viewer');
    const placeholder = document.getElementById('avatar-placeholder');
    
    placeholder.style.display = 'none';
    poseViewer.style.display = 'block';
    poseViewer.setAttribute('src', poseUrl);
}

9. デバッグのコツ

9.1 デバッグパネルの実装

Zoom Apps内ではDevToolsが使えないため、アプリ内にデバッグパネルを実装するのが有効でした。これはZoom Appsでは他でも使えるアイデアかと思います。

function debug(message, data = null) {
    const timestamp = new Date().toLocaleTimeString();
    console.log(`[${timestamp}] ${message}`, data || '');
    
    // 画面上のデバッグパネルに追加
    const debugContent = document.getElementById('debug-content');
    if (debugContent) {
        const entry = document.createElement('div');
        entry.innerHTML = `
            <span class="time">${timestamp}</span>
            <span class="msg">${message}</span>
            ${data ? `<pre>${JSON.stringify(data, null, 2)}</pre>` : ''}
        `;
        debugContent.prepend(entry);
    }
}

2025/12/24 追記:すみません。実は方法がありました。Zoomクライアント内のアプリのWebViewインスタンスで開発者ツールを有効にするには、以下の手順に従ってください。

まず、 Zoom クライアントを一旦終了 した上で、次の操作を実行します。
Windowsの場合: %appdata%/Zoom/data ディレクトリにある zoom.us.ini ファイルに以下の行を追加します。このファイルは にあります。

[ZoomChat]
webview.context.menu=true

Macの場合:

defaults write ZoomChat webview.context.menu true

その後、再度 Zoom クライアントを起動し、改めてZoom Appsを表示すると、 右クリックでデバッグコンソールが表示されるようになります。(すいませんスクショ撮るの忘れました)

9.2 サーバーログの詳細化

// 署名デバッグ
console.log('🔐 Signature Debug:');
console.log(`   Client ID: ${ZOOM_CLIENT_ID?.substring(0, 8)}...`);
console.log(`   Meeting UUID: ${meetingUuid}`);
console.log(`   Stream ID: ${rtmsStreamId}`);
console.log(`   Message: ${message}`);
console.log(`   Signature: ${signature.substring(0, 16)}...`);

// WebSocketメッセージタイプ
console.log(`📨 Signaling message (type: ${msg.msg_type})`);
console.log(`   Full response:`, JSON.stringify(msg, null, 2));

9.3 ngrok inspectorの活用

ngrok http 3000

http://127.0.0.1:4040 でリクエスト/レスポンスを確認できる。特にWebhookのペイロード確認に便利。


10. RTMSメッセージタイプ一覧

msg_type 名前 方向 説明
1 SIGNALING_HAND_SHAKE_REQ Client→Server Signaling接続要求
2 SIGNALING_HAND_SHAKE_RESP Server→Client Signaling接続応答
3 DATA_HAND_SHAKE_REQ Client→Server Media接続要求
4 DATA_HAND_SHAKE_RESP Server→Client Media接続応答
7 CLIENT_READY_ACK Client→Server クライアント準備完了
8 STREAMING_STARTED Server→Client ストリーミング開始
9 STREAMING_STOPPED Server→Client ストリーミング停止
12 KEEP_ALIVE_REQ Server→Client キープアライブ要求
13 KEEP_ALIVE_RESP Client→Server キープアライブ応答
14 MEDIA_DATA_AUDIO Server→Client 音声データ
15 MEDIA_DATA_VIDEO Server→Client 映像データ
17 MEDIA_DATA_TRANSCRIPT Server→Client 文字起こしデータ

11. media_type 一覧

const MEDIA_TYPE = {
    AUDIO: 1,
    VIDEO: 2,
    SHARE: 4,
    TRANSCRIPT: 8,
    ALL: 15
};

複数のメディアタイプを組み合わせる場合はビット論理和を使用:

// 音声とTranscript両方
media_type: MEDIA_TYPE.AUDIO | MEDIA_TYPE.TRANSCRIPT  // = 9

12. よくあるエラーと対処法

エラー: stop_reason: 18

原因: 認証失敗
対処: 署名生成のメッセージフォーマットを確認。CLIENT_ID,meetingUuid,streamId の順番。

エラー: fetch failed (SELF_SIGNED_CERT_IN_CHAIN)

原因: ngrok経由でのSSL証明書検証エラー
対処: 開発環境では NODE_TLS_REJECT_UNAUTHORIZED=0

エラー: 80004 (app_not_support)

原因: Zoom Apps SDKのAPIが許可されていない
対処: MarketplaceでZoom App SDK APIsを有効化

画面が真っ白

原因: OWASPセキュリティヘッダー不足
対処: Strict-Transport-Security 等のヘッダーを追加

pose-viewer: Load failed

原因: Blob URLやData URLがZoom Apps環境で使えない
対処: サーバーキャッシュ方式でURLを提供

13. パフォーマンス考慮事項

Pose Dataのキャッシュ管理

データ量がそれなりにあるため、一時キャッシュ後は破棄するのがおすすめです。または、きちんとDBで管理したうえで、Replayができるようにするのもありかと思います。

// 古いデータを定期的に削除
setInterval(() => {
    const now = Date.now();
    for (const [id, data] of poseCache.entries()) {
        if (now - data.timestamp > 5 * 60 * 1000) {  // 5分
            poseCache.delete(id);
        }
    }
}, 60 * 1000);

sign.mt APIの応答時間

sign.mt APIは1リクエストあたり2〜5秒かかることがある。長文の場合はさらに時間がかかります。リアルタイム性を担保するのであれば、

  • UIにローディング表示
  • 文を分割して並列リクエスト
  • 結果をキャッシュ

なども検討可能かと思われます。

まとめ

Zoom RTMSを使った手話翻訳アプリの開発では、以下のポイントが重要でした:

  1. OWASPセキュリティヘッダー - Zoom Apps表示の必須条件
  2. 正しい署名生成 - CLIENT_ID,meetingUuid,streamId のフォーマット
  3. SSL証明書問題の回避 - 開発環境での対処
  4. データ転送方式 - Blob URL/Data URLではなくサーバーキャッシュ方式

RTMSは比較的新しいAPIですが、リアルタイムで音声・映像・文字起こしにアクセスできる強力な機能です。アクセシビリティ向上や会議分析など、様々な応用が考えられます。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?