はじめに
Zoomの RTMS(Realtime Media Streams) を使って、ミーティングの音声をリアルタイムで手話に翻訳し、アバターで表示するアプリを作りました。この記事では、開発過程で詰まったポイントや解決策を含めて、実装の全体像を解説します。
完成イメージ
- Zoomミーティング中の発言がリアルタイムで文字起こしされる
- 文字起こしテキストが手話(ASL)に翻訳される
- pose-viewerコンポーネントでスケルトンアニメーションとして表示
使用技術
| カテゴリ | 技術 |
|---|---|
| バックエンド | Node.js, Express, WebSocket |
| フロントエンド | Vanilla JS, pose-viewer |
| Zoom連携 | RTMS API, Zoom Apps SDK |
| 手話翻訳 | sign.mt API |
1. アーキテクチャ概要
シーケンス図(小さくてすみません)
データフロー
- 参加者が発言 → Zoomが音声を文字起こし
-
RTMS Webhook →
meeting.rtms_startedイベント受信 - Signaling WebSocket接続 → ハンドシェイク → Media WebSocket接続
- Transcript受信 (msg_type: 17) → sign.mt APIで手話ポーズデータ取得
- フロントエンドにブロードキャスト → 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設定
- App Type: Zoom Apps (General App)
- Surface: In-Meeting
-
Scopes:
meeting:read:meeting_caption_rtmsmeeting:read:rtms
-
Zoom App SDK APIs:
getRunningContextgetMeetingContext- その他必要な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-ancestorsにhttps://*.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_idandsecretwith your app's Client ID and Client Secret, andmeeting_uuidandrtms_stream_idwith 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);
}
なぜこれで動くのか
-
同一オリジン:
/pose/p1は自サーバーへのリクエストなのでCORS問題なし - Zoom Apps環境でもOK: 通常のHTTPリクエストなのでiframe内からもアクセス可能
- サイズ制限なし: 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を使った手話翻訳アプリの開発では、以下のポイントが重要でした:
- OWASPセキュリティヘッダー - Zoom Apps表示の必須条件
-
正しい署名生成 -
CLIENT_ID,meetingUuid,streamIdのフォーマット - SSL証明書問題の回避 - 開発環境での対処
- データ転送方式 - Blob URL/Data URLではなくサーバーキャッシュ方式
RTMSは比較的新しいAPIですが、リアルタイムで音声・映像・文字起こしにアクセスできる強力な機能です。アクセシビリティ向上や会議分析など、様々な応用が考えられます。
参考リンク
