Section 01

ファイル構成 #

プロジェクトは最小構成で、Node.js サーバー1ファイルと HTML ファイル2枚だけです。外部フレームワーク(React/Vue等)は使っていません。

kw-translator/
server.js ── WebSocket + REST サーバー(Node.js)
index.html ── メインアプリ UI(ブラウザで開くページ)
admin.html ── 管理者画面(アカウント管理)
package.json ── Node.js 設定・依存ライブラリ定義
.env.example ── 環境変数のテンプレート(本番は .env に)
💡

なぜシンプルな構成なのか?
ビルドツール・バンドラーなしでデプロイが完結するため、Render など PaaS への配備が簡単です。node server.js 一発で起動します。

依存ライブラリ

package.json を見ると、依存ライブラリは ws(WebSocketライブラリ)のみです。それ以外(https・fs・crypto・path)は Node.js 標準モジュールを使用しており、追加インストール不要です。

📄 package.json
JSON
// 依存ライブラリは ws だけ "dependencies": { "ws": "^8.18.0" // WebSocket サーバー実装 } // 標準モジュール(インストール不要) // http, https, fs, path, crypto — すべて Node.js 組み込み

Section 02

環境変数 #

機密情報(APIキー・パスワード)はソースコードに直接書かず、環境変数として外から注入します。これはセキュリティの基本です。

変数名必須説明
GEMINI_API_KEY必須Google AI Studio で発行するAPIキー。翻訳・OCRに使用。
JWT_SECRET必須ログインセッションの暗号化キー。未設定だと再起動のたびにログアウトされる。
ADMIN_SECRET必須管理画面(/admin)へのアクセスパスワード。未設定だと管理画面が無効。
SUPABASE_URL任意Supabase プロジェクトのURL。未設定だとデータが永続保存されない。
SUPABASE_SERVICE_KEY任意Supabase サービスロールキー(管理者権限)。
PORT任意サーバーのポート番号。Render等PaaSは自動設定するため基本不要。
GEMINI_MODEL任意使用するGeminiモデル名。デフォルトは gemini-2.5-flash-lite。
ALLOWED_ORIGIN任意CORSで許可するドメイン。未設定だと全オリジン(*)を許可。
TRUSTED_PROXY_COUNT任意信頼するリバースプロキシの段数。クライアントIPの取得精度に影響。
API_RATE_TEXT_PER_HOUR任意1時間あたりのテキスト翻訳上限。0=無制限。
API_RATE_IMAGE_PER_HOUR任意1時間あたりの画像翻訳上限。0=無制限。
📄 server.js — 環境変数の読み込み
JS
// process.env.XXX で環境変数を読む // || の右側はデフォルト値(環境変数が未設定のとき使われる) const PORT = process.env.PORT || 3000; const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ''; const MODEL_NAME = process.env.GEMINI_MODEL || 'gemini-2.5-flash-lite'; // JWT_SECRET が未設定なら毎回ランダム生成(再起動でセッション無効化) const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex');
⚠️

本番環境では必ず JWT_SECRET を固定値に設定してください。
未設定のままだとサーバー再起動のたびに乱数が変わり、すべてのユーザーがログアウトされます。


Section 03

認証の仕組み(JWT・パスワードハッシュ) #

パスワードの保存方法(scrypt)

パスワードは平文で保存しないのがセキュリティの鉄則です。scrypt という計算コストの高いハッシュ関数を使って変換してから保存しています。

📄 server.js — hashPassword / checkPassword
JS
// パスワードをハッシュ化して保存用の文字列を作る function hashPassword(password) { return new Promise((resolve, reject) => { // salt: ランダムな16バイト(同じパスワードでも毎回違うハッシュになる) const salt = crypto.randomBytes(16).toString('hex'); // scrypt: パスワード+salt → 64バイトのハッシュ値 crypto.scrypt(password, salt, 64, (err, key) => { if (err) reject(err); // 保存形式: "scrypt:ソルト:ハッシュ値" else resolve(`scrypt:${salt}:${key.toString('hex')}`); }); }); } // 入力パスワードと保存済みハッシュを比較する function checkPassword(password, stored) { // timingSafeEqual: タイミング攻撃を防ぐ定数時間比較 // → 文字数が合ってもバイト比較を必ず全部行う return crypto.timingSafeEqual(derived, stored); }

JWTによるセッション管理

JWT(JSON Web Token)は、ログイン状態を「トークン文字列」として表現する仕組みです。サーバー側でセッションを保持する必要がなく、スケーラブルです。

仕組みのまとめ:
① ログイン時 → サーバーがユーザー情報を JWT に署名して返す
② 以降の通信 → クライアントが JWT を Authorization: Bearer xxx ヘッダに付与
③ サーバーが署名を検証 → 正しければ認証OK(DBを見なくて済む)
④ 有効期限は90日。改ざんは署名で検出できる。
📄 server.js — signJWT / verifyJWT
JS
// JWTを生成する(ログイン成功時に呼ばれる) function signJWT(payload) { // payload = { id, username, display_name, lang, iat(発行時刻) } const data = Buffer.from(JSON.stringify({ ...payload, iat: Date.now() })) .toString('base64url'); // base64url エンコード const sig = crypto.createHmac('sha256', JWT_SECRET) .update(data).digest('base64url'); // HMAC署名 return `${data}.${sig}`; // "データ.署名" 形式 } // JWTを検証する(APIリクエスト時に呼ばれる) function verifyJWT(token) { // 1. 署名を再計算して一致するか確認(改ざん検出) // 2. 発行から90日以内かチェック // 3. OKなら payload(ユーザー情報)を返す、NGなら null if (Date.now() - payload.iat > 90 * 24 * 3600 * 1000) return null; // 90日 }

Section 04

Supabase 連携 #

Supabase は PostgreSQL ベースのクラウドデータベースです。REST API を HTTP で叩くだけで使えるため、専用SDKなしで直接 https モジュールから接続しています。

sbRequest — 汎用ヘルパー関数

全 DB 操作はこの1関数に集約されています。CRUD 操作のラッパーとして sb.get / sb.insert / sb.update / sb.delete が定義されています。

📄 server.js — Supabase ヘルパー
JS
// 全 DB 操作の基底関数(10秒タイムアウト付き) function sbRequest(method, urlPath, body = null) { if (!SUPABASE_URL || !SUPABASE_KEY) return Promise.resolve(null); // 未設定は無視 return new Promise((resolve) => { const headers = { apikey: SUPABASE_KEY, // 認証ヘッダー Authorization: `Bearer ${SUPABASE_KEY}`, Prefer: 'return=representation', // 操作結果を返してもらう }; // 10秒で強制タイムアウト(DB応答が遅くてもサーバーが止まらない) const timeout = setTimeout(() => { req.destroy(); settle(null); }, 10000); ... }); } // 使いやすいラッパー const sb = { get: (table, qs) => sbRequest('GET', `/${table}?${qs}`), insert: (table, data) => sbRequest('POST', `/${table}`, data), update: (table, qs, data) => sbRequest('PATCH', `/${table}?${qs}`, data), delete: (table, qs) => sbRequest('DELETE', `/${table}?${qs}`), };

使用テーブル

テーブル名用途主なカラム
accountsユーザーアカウントusername, password_hash, display_name, lang, is_active
account_roomsアカウントが作成したルーム一覧room_code, owner_id, room_name, last_activity
messagesチャット履歴(永続保存)room_code, sender_name, sender_lang, original, translations
logs翻訳ログ(後方互換)room_id, speaker, lang, original, translated
💡

Supabase の URL・キーを設定しない場合、sbRequest は即座に null を返します。 データが永続化されないだけで、チャット自体は動作します(インメモリのみ)。


Section 05

HTTP ルーティング #

Express などのフレームワークは使わず、Node.js 標準の http モジュールでルーティングを実装しています。URL とメソッドを手動で判定する方式です。

エンドポイントメソッド認証説明
GET /healthGETなしサーバー稼働確認(稼働ルーム数・モデル名等)
/auth/loginPOSTなしログイン → JWT返却
/auth/meGETJWTトークンからアカウント情報を取得
/auth/roomsGETJWT自分のルーム一覧を取得
/rooms/newGETJWT新規ルームコードを発行
/rooms/:codeDELETEJWTルームと履歴を削除
/glossary/uploadPOSTJWT or AdminExcel辞書データを一括登録
/adminGETなし管理画面HTML配信
/admin/accountsGET/POSTAdmin Secretアカウント一覧取得・作成
/admin/accounts/:namePATCHAdmin Secretパスワード変更・有効化/無効化
/* (fallback)GETなしindex.html を返す(SPA的動作)
📄 server.js — HTTPサーバーのルーティング構造
JS
const httpServer = http.createServer((req, res) => { (async () => { const u = new URL(req.url, 'http://localhost'); const method = req.method.toUpperCase(); // CORS ヘッダーを全レスポンスに付与 res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN); // OPTIONS プリフライトリクエストへの即応答 if (method === 'OPTIONS') { res.writeHead(204); return res.end(); } // 各エンドポイントへの振り分け if (u.pathname === '/health') { return json({...}); } if (u.pathname === '/auth/login' && method === 'POST') { ... } // ...(以下、各パスに対応する処理) // どこにもマッチしなければ index.html を返す(SPA フォールバック) fs.readFile(path.join(__dirname, 'index.html'), (err, data) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(data); }); })().catch(e => { res.writeHead(500); ... }); });

Section 06

WebSocket 処理(サーバー側) #

WebSocket は HTTP と異なり、一度接続したら繋ぎっぱなしで双方向通信できるプロトコルです。チャットアプリに最適です。

Browser
クライアント
join メッセージ送信
Node.js
WSサーバー
room_state 返却
Browser
参加者一覧表示

メッセージの種類(サーバー→クライアント)

type意味
room_state接続時に送られる「今いる参加者の一覧」
join誰かが新しく参加した通知
leave誰かが退出した通知
speech翻訳済みテキストメッセージ
speech_imageOCR・翻訳済みの画像メッセージ
speech_failed翻訳失敗(原文のみ)
speaking「話し中」インジケーター更新
replay_start/end再接続時の過去メッセージ配信の開始/終了
history_start/endSupabaseからの履歴配信の開始/終了
host_leftホストが退出し、ルーム閉鎖カウントダウン開始
room_closedルームが閉鎖された通知
guest_warningゲストの2時間タイムアウト10分前警告
guest_kickedゲストを2時間タイムアウトで退出させる
server_shutdownメンテナンス等でサーバーが停止する通知
errorエラー通知(ROOM_NOT_FOUND等)
pongクライアントのping応答

join 処理の詳細

クライアントが join を送ると、サーバーは以下を行います:

📄 server.js — join ハンドラー(要約)
JS
case 'join': { // 1. アカウントトークンを検証 → isHost を決定 const accountPayload = verifyJWT(accountToken); const isHost = !!accountPayload; // 2. ゲストなのにルームが存在しない → エラーで弾く if (!isHost && !rooms.has(roomId)) { ws.send(JSON.stringify({ type: 'error', code: 'ROOM_NOT_FOUND' })); return; } // 3. ルームを作成(なければ)して参加者を登録 const room = getOrCreateRoom(roomId); room.clients.set(clientId, { ws, name, lang, isHost, speechQueue: Promise.resolve() }); // 4. 既存参加者リストを新規参加者に返す ws.send(JSON.stringify({ type: 'room_state', participants: [...] })); // 5. 他の全員に「誰かが参加した」を通知 broadcast(room, { type: 'join', clientId, name, lang, isHost }, clientId); // 6. 再接続ならバッファから未受信メッセージを送る if (sinceTs > 0) { /* getMissedMsgs → replay */ } // 7. ホストなら Supabase から履歴を取得して送る if (isHost) { loadMessages(roomId).then(msgs => { /* history_start ... */ }); } }

speechQueue による逐次処理

翻訳APIは非同期処理のため、連続で発言があると順番が前後する可能性があります。クライアントごとに Promise チェーン(キュー)を持つことで、発言の順序を保証しています。

📄 server.js — speechQueue パターン
JS
// クライアント登録時に空の Promise(キュー)を作る room.clients.set(clientId, { ws, speechQueue: Promise.resolve(), // 最初は空(即座に解決済み) }); // 発言が来るたびに前の翻訳完了を待ってから次を実行 client.speechQueue = client.speechQueue .then(() => handleSpeech(room, senderId, text, roomId)) .catch(e => console.error(e)); // → 発言A完了 → 発言B完了 → 発言C ... という順番が保証される

Section 07

翻訳・OCR(Gemini API) #

テキスト翻訳フロー

server.js
handleSpeech
翻訳リクエスト
Gemini
translateWithGemini
翻訳結果
server.js
broadcast
各クライアントへ
Browser
表示

ルームに複数の言語ユーザーがいる場合、必要な言語の数だけ並列で翻訳リクエストを投げます(Promise.all)。

📄 server.js — handleSpeech(翻訳と配信)
JS
async function handleSpeech(room, senderId, text, roomId) { // 1. ルーム内にいる言語の種類を集める const targetLangsSet = new Set(); for (const [id, c] of room.clients) { if (id !== senderId) targetLangsSet.add(c.lang); } // 例: 日本語・英語・ベトナム語 → 3言語に並列翻訳 // 2. 各言語に対して翻訳(キャッシュヒットなら API 呼び出しをスキップ) const cache = {}; await Promise.all([...targetLangsSet].map(async toLang => { const hit = cacheGet(text, fromLang, toLang); if (hit !== undefined) { cache[toLang] = hit; return; } // キャッシュ使用 const tr = await translateWithGemini(text, fromLang, toLang); cacheSet(text, fromLang, toLang, tr); cache[toLang] = tr; })); // 3. 各クライアントにその人の言語の翻訳を送る for (const [id, c] of room.clients) { if (id === senderId) continue; c.ws.send(JSON.stringify({ type: 'speech', original: text, translated: cache[c.lang] })); } }

Gemini プロンプト設計

翻訳精度を高めるため、プロンプトには役割・出力ルール・用語辞書の3要素を含めています。

📄 server.js — translateWithGemini のプロンプト
JS
const prompt = // 役割を明示(建設現場の通訳者として振る舞わせる) `You are a professional construction site interpreter.\n` + `Translate the following text from ${fromName} to ${toName}.\n` + // 余計な出力を禁止 `Output ONLY the translated text. No explanations, no quotes.\n` + // 発言中の専門用語を辞書から抽出してヒントとして渡す glossarySnippet + // 例: "- 養生: curing (concrete)" `\n\nText: ${text}`;

2ステップ OCR 翻訳(画像)

画像翻訳は2回の Gemini API 呼び出しで行います。1回目でテキスト位置を検出し、2回目で翻訳することで精度と速度を両立しています。

Step 1 — OCR(ocrImageWithGemini):画像内の文字を検出し、バウンディングボックス付きでNDJSON形式で返す
Step 2 — 翻訳(translateOcrBlocks):抽出したテキストを番号付き一括翻訳して元のボックス情報に結合する
📄 server.js — ocrImageWithGemini (Step 1 OCR プロンプト)
JS
// OCR プロンプト: NDJSON(1行1テキスト)で返すよう指示 `{"original":"検出テキスト","box":[ymin,xmin,ymax,xmax]}` // box は 0〜1000 スケールの相対座標 // 例: {"original":"立入禁止","box":[200,100,280,400]} // レスポンスをパースしてブロック配列に変換 const box = { x: clamp(rb[1]), // 左端(0.0〜1.0) y: clamp(rb[0]), // 上端(0.0〜1.0) w: clamp(rb[3]) - clamp(rb[1]), // 幅 h: clamp(rb[2]) - clamp(rb[0]) // 高さ };

Gemini リトライ処理

503/429(サーバー混雑・レート超過)のときに指数バックオフで自動リトライします。

📄 server.js — geminiRequest(リトライロジック)
JS
const GEMINI_MAX_RETRY = 2; // 最大2回リトライ const GEMINI_RETRY_BASE = 1000; // 1秒・2秒で待機(指数バックオフ) async function geminiRequest(body) { for (let attempt = 0; attempt <= GEMINI_MAX_RETRY; attempt++) { const result = await geminiRequestOnce(body); if (result.ok) return result.text; // 成功 if (result.error === 'retryable') { const wait = GEMINI_RETRY_BASE * (2 ** attempt); // 1秒→2秒 await new Promise(r => setTimeout(r, wait)); } } return null; // 全リトライ失敗 }

翻訳キャッシュ

同じ文章・同じ言語ペアの翻訳は Map にキャッシュします。同じ発言が繰り返された場合に API 呼び出しをスキップし、コストと速度を改善します。

📄 server.js — 翻訳キャッシュ
JS
const translationCache = new Map(); const CACHE_MAX = 300; // 300件超えたら古い順に削除 // キーは "from言語:to言語:テキスト(正規化済み)" function cacheGet(t, f, to) { return translationCache.get(`${f}:${to}:${normKey(t)}`); }

Section 08

専門用語辞書 #

235語の建設専門用語が GLOSSARY_BASE オブジェクトとしてサーバーに内蔵されています。翻訳リクエスト時、発言テキストにマッチする用語を最大15件抽出してプロンプトに追加します。

📄 server.js — extractGlossaryHint
JS
function extractGlossaryHint(text) { const matched = []; for (const [key, desc] of Object.entries(getGlossary())) { // 全角→半角正規化してからテキスト内に含まれるか判定 const nk = key.replace(/[A-Za-z0-9]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)); if (text.includes(key) || text.includes(nk)) { matched.push(`- ${key}: ${desc}`); if (matched.length >= 15) break; // 最大15件でプロンプトを抑制 } } // マッチした用語をプロンプトに付加 // 例: "- 養生: curing (concrete) / protective covering" return matched.length ? `\n## Construction terms:\n${matched.join('\n')}` : ''; }
💡

追加辞書(glossaryExtra)は Excel アップロードでサーバー再起動なしに動的追加できます。ただしメモリ上のみで、サーバー再起動すると消えます。永続化が必要な場合は Supabase への保存処理を追加する必要があります。


Section 09

レート制限・セキュリティ #

ログインブルートフォース対策

同一IPから15分以内に10回以上ログイン失敗すると、429エラーを返してリクエストを拒否します。

📄 server.js — ログインレート制限
JS
const LOGIN_MAX_ATTEMPTS = 10; const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15分 const loginAttempts = new Map(); // ip → { count, firstAt } function isLoginRateLimited(ip) { const now = Date.now(); const entry = loginAttempts.get(ip); // 15分以上経過 → リセット if (!entry || now - entry.firstAt > LOGIN_WINDOW_MS) { loginAttempts.set(ip, { count: 1, firstAt: now }); return false; } entry.count++; return entry.count > LOGIN_MAX_ATTEMPTS; // 10回超えたら true(ブロック) }

クライアントIPの取得(リバースプロキシ対応)

Render や Cloudflare 経由の場合、実際のクライアントIPは X-Forwarded-For ヘッダーに入ります。TRUSTED_PROXY_COUNT を使って信頼できるプロキシの段数を指定し、正しいIPを取得します。

📄 server.js — getClientIp
JS
function getClientIp(req) { const direct = req.socket?.remoteAddress || 'unknown'; if (TRUSTED_PROXY_COUNT === 0) return direct; // プロキシなし const ips = req.headers['x-forwarded-for'] ?.split(',').map(s => s.trim()) || []; // 例: "client, proxy1, proxy2" → proxy1が信頼できるなら client を返す const idx = ips.length - TRUSTED_PROXY_COUNT; return idx >= 0 ? ips[idx] : ips[0]; }

API 使用量レート制限

クライアントIDごとに1時間あたりの翻訳回数を制限できます(環境変数 API_RATE_TEXT_PER_HOUR / API_RATE_IMAGE_PER_HOUR)。0のとき無制限。


Section 10

ルーム管理 #

ルームは rooms という Map でインメモリ管理されます。サーバーが生きている間だけ存在し、再起動で消えます(履歴は Supabase に保存)。

📄 server.js — ルームの構造
JS
// rooms: Map<roomId, RoomObject> { clients: new Map(), // clientId → { ws, name, lang, isHost, speechQueue, ... } hostCount: 0, // 現在接続中のホスト数 orphanTimer: null, // ホスト全員退出後の閉鎖タイマー isAccountRoom: false, // アカウントユーザーが作ったルームか ownerId: null, // オーナーのアカウントID historyLoaded: false, // Supabase履歴を読み込んだか(重複防止) }

ルーム自動閉鎖(Orphan Timer)

ホストが全員退出すると、10分後に残ったゲストを全員退出させルームを削除します。

📄 server.js — Orphan Timer
JS
function startOrphanTimer(roomId) { // 10分後に closeRoom を実行 room.orphanTimer = setTimeout( () => closeRoom(roomId, 'host_absent'), ORPHAN_CLOSE_MS // 10 * 60 * 1000 ms ); } // ホストが退出したとき if (client.isHost && room.hostCount === 0 && room.clients.size > 0) { // 残りのゲストに「ホストが抜けた。10分後に閉鎖」と通知 broadcast(room, { type: 'host_left', closeAt: Date.now() + ORPHAN_CLOSE_MS }); startOrphanTimer(roomId); }

ルームコード生成

紛らわしい文字(O/0, I/1, L)を除いた31文字から6文字のコードを生成します。既存のルームIDと被らないよう衝突チェック付きです。

📄 server.js — generateRoomCode
JS
const ROOM_CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // ↑ O/0(ゼロ), I/1(いち), L(エル) を除外 function generateRoomCode() { let code, attempts = 0; do { code = Array.from({ length: 6 }, () => ROOM_CODE_CHARS[Math.floor(Math.random() * ROOM_CODE_CHARS.length)] ).join(''); } while (rooms.has(code) && ++attempts < 100); // 衝突したら再生成 return code; }

Section 11

メッセージバッファ(再接続リプレイ) #

接続が一時的に切れた場合でも、再接続後に「切断中に届いたメッセージ」を受け取れます。各ルームの直近150件をインメモリに保持します。

📄 server.js — bufferMsg / getMissedMsgs
JS
// ルームごとにバッファ: roomId → メッセージ配列(最大150件) const roomBuffers = new Map(); function bufferMsg(roomId, msgObj) { const buf = roomBuffers.get(roomId) || []; buf.push({ ...msgObj, _ts: Date.now() }); // タイムスタンプを付与 if (buf.length > MSG_BUFFER_SIZE) buf.shift(); // 古いものを削除 roomBuffers.set(roomId, buf); } // 再接続時: sinceTs より後のメッセージだけ取り出す function getMissedMsgs(roomId, sinceTs) { return (roomBuffers.get(roomId) || []) .filter(m => m._ts > sinceTs); } // クライアントは join 時に since: S.lastMsgTs を送ってくる

Section 12

状態管理 — S オブジェクト(index.html) #

フロントエンドはフレームワークなしの素の JavaScript です。アプリ全体の状態を グローバルな S オブジェクト一つで管理しています。

📄 index.html — S オブジェクト(全フィールド)
JS
const S = { // ── ルーム情報 ── roomId: null, // 現在のルームID(例: "AB3K7Q") myId: null, // 自分のクライアントID(乱数10文字) myName: '', // 表示名 lang: 'ja', // 自分の使用言語 isHost: false, // アカウントログイン済みかどうか _roomName: '', // ルーム名 // ── WebSocket ── ws: null, // WebSocket インスタンス wsReady: false, // 接続確立済みか reconnectTimer: null, // 再接続タイマーID reconnectCount: 0, // 再接続試行回数(待機時間の計算に使用) // ── 音声 ── recording: false, // 録音中フラグ rec: null, // SpeechRecognition インスタンス // ── 参加者 ── participants: {}, // clientId → { name, lang, speaking, isMe } participantOrder: [], // 参加順の clientId 配列(表示順序の維持) // ── メッセージ ── msgCount: 0, // 表示済みメッセージ数 log: [], // CSV出力用ログ配列 lastMsgTs: 0, // 最後にメッセージを受け取った時刻(再接続リプレイ用) pendingQueue: [], // オフライン中に溜まった未送信メッセージ pendingImage: null,// 送信待ちの画像データ // ── アカウント ── accountToken: null, // JWT トークン accountName: '', // ログイン中のアカウント表示名 accountLang: 'ja', // アカウントの言語設定 isAccountHolder: false, // アカウントログイン済みか // ── TTS ── ttsEnabled: true, // 読み上げ ON/OFF };

Section 13

WebSocket(クライアント側) #

自動再接続ロジック

接続が切れたとき、指数バックオフで自動再接続を試みます。待機時間は最大20秒になります。

📄 index.html — WebSocket再接続
JS
const WS_DELAYS = [1500, 3000, 5000, 10000, 20000]; // 待機ms S.ws.onclose = () => { S.wsReady = false; const delay = WS_DELAYS[Math.min(S.reconnectCount, WS_DELAYS.length - 1)]; S.reconnectCount++; S.reconnectTimer = setTimeout(connectWS, delay); }; // 再接続成功 → 未送信メッセージをまとめて再送 S.ws.onopen = () => { S.wsReady = true; S.reconnectCount = 0; const q = [...S.pendingQueue]; S.pendingQueue = []; q.forEach((m, i) => setTimeout(() => wsSend(m), i * 120)); // 120ms間隔で再送 };

Ping/Pong による死活監視

サーバーとクライアント双方で ping/pong を実装。クライアントは20秒ごとに ping を送り、サーバーは60秒メッセージがなければ接続を強制切断します。


Section 14

音声認識(クライアント側) #

ブラウザ内蔵の Web Speech APISpeechRecognition)を使います。音声データ自体はブラウザが処理し、テキストに変換されたものだけをサーバーに送ります。

⚠️

Web Speech API は Chrome での利用が安定しています。iOS Safari では動作しない場合があります(micDisabled フラグで制御)。

📄 index.html — startRec(音声認識)
JS
function startRec() { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const r = new SR(); r.lang = LANG_TO_BCP47[S.lang]; // 例: 'ja' → 'ja-JP' r.continuous = true; // 話し続けても認識し続ける r.interimResults = true; // 確定前の途中テキストも取得(リアルタイム表示用) r.onresult = e => { let interim = '', final = ''; for (let i = e.resultIndex; i < e.results.length; i++) { const t = e.results[i][0].transcript; e.results[i].isFinal ? (final += t) : (interim += t); } // 確定テキストだけ送信。重複送信を lastSent で防ぐ if (final.trim() && final !== lastSent) { lastSent = final; sendSpeech(final); } }; // continuous=true でも認識が終わることがある → 自動再起動 r.onend = () => { if (S.recording) { try { r.start(); } catch { stopRec(); } } }; }

スリープ防止(Wake Lock)

スマートフォンが画面オフになると音声認識が止まります。WakeLock API と無音の AudioContext ループを組み合わせてスリープを防止しています。

📄 index.html — スリープ防止
JS
// 方法1: WakeLock API(モダンブラウザ) async function requestWakeLock() { if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen'); } } // 方法2: 無音の AudioContext ループ(iOS Safari フォールバック) function startSilentAudio() { const buf = _silentCtx.createBuffer(1, _silentCtx.sampleRate, _silentCtx.sampleRate); const src = _silentCtx.createBufferSource(); src.buffer = buf; src.loop = true; // 無音を永久ループ再生 src.connect(_silentCtx.destination); src.start(0); }

Section 15

画像送信・Canvas 描画 #

画像の送信

選択した画像を FileReader で Base64 エンコードし、WebSocket で JSON に含めて送信します。上限は5MB。

📄 index.html — onImageSelected
JS
function onImageSelected(event) { const file = event.target.files[0]; if (file.size > MAX_IMG_SIZE) { toast('画像が大きすぎます'); return; } const reader = new FileReader(); reader.onload = e => { const dataUrl = e.target.result; // "data:image/jpeg;base64,xxxx" S.pendingImage = { base64: dataUrl.split(',')[1], // base64 部分だけ取り出す mime: file.type, }; }; reader.readAsDataURL(file); }

OCR結果のCanvas描画(drawOverlay)

サーバーから返ってくる blocks(位置・翻訳テキスト)をもとに、canvas 要素に元画像を描いた上から翻訳テキストを重ねます。背景色はピクセル平均から自動決定し、テキスト色はその輝度によって白/黒を選択します。

📄 index.html — drawOverlay(要約)
JS
function drawOverlay(canvas, imgEl, blocks, showOriginal) { // 1. 元画像を canvas に描画 ctx.drawImage(imgEl, 0, 0, W, H); blocks.forEach(b => { // 2. ボックス範囲のピクセル平均色を取得 const px = ctx.getImageData(bx, by, bw, bh).data; // → R/G/B の平均を計算 → 輝度 lum を算出 const lum = 0.299*avgR + 0.587*avgG + 0.114*avgB; // 3. 背景を元画像の平均色で塗りつぶす(馴染ませる) ctx.fillStyle = `rgba(${avgR},${avgG},${avgB},0.97)`; ctx.fillRect(bx, by, bw, bh); // 4. テキスト色: 背景が明るければ黒、暗ければ白 ctx.fillStyle = lum > 140 ? '#111111' : '#f2f2f2'; // 5. ボックスに収まるフォントサイズを探索(最大72px→最小7px) for (let fs = maxFS; fs >= minFS; fs--) { ctx.font = `700 ${fs}px "Noto Sans JP"`; const lines = wrapTextToLines(ctx, text, innerW); if (lines.length * (fs * 1.25) <= innerH) { bestFS = fs; break; } } // 6. テキストを描画 ctx.fillText(line, bx + bw/2, startY + i*lineH, innerW); }); }

Section 16

TTS(テキスト読み上げ) #

ブラウザ内蔵の SpeechSynthesis API を使います。翻訳テキストが届くたびにキューに入れ、一つずつ順番に読み上げます(同時に複数話させない)。

📄 index.html — TTS キューシステム
JS
const ttsQueue = []; let ttsSpeaking = false; // 翻訳テキストをキューに追加 function ttsEnqueue(text, lang) { ttsQueue.push({ text, lang }); if (!ttsSpeaking) ttsDequeue(); // 再生中でなければすぐ開始 } // キューから1件取り出して読み上げ、終わったら次へ function ttsDequeue() { if (!ttsQueue.length) { ttsSpeaking = false; return; } ttsSpeaking = true; const { text, lang } = ttsQueue.shift(); const u = new SpeechSynthesisUtterance(text); u.lang = lang; // iOS で長文が途中で止まるバグ対策: 10秒ごとに pause/resume const ka = setInterval(() => { speechSynthesis.pause(); speechSynthesis.resume(); }, 10000); u.onend = () => { clearInterval(ka); ttsDequeue(); }; // 終了→次へ speechSynthesis.speak(u); }
💡

TTS の「プライミング」について:
ブラウザはユーザー操作(タップ・クリック)なしに TTS を起動できません。そのため、マイクボタンを押したタイミング(ユーザー操作直後)に無音の Utterance を一度 speak させておく primeTTS が呼ばれています。


Section 17

管理画面 — admin.html #

管理画面は admin.html 単体で動作する静的HTMLです。JavaScript から REST API(/admin/*)を叩いてアカウント管理を行います。

認証フロー

ログイン時に x-admin-secret ヘッダーを付けて GET /admin/accounts を呼びます。200が返ればシークレットが正しいと判断し、メイン画面を表示します。JWT は使わず、毎リクエストにシークレットを送る方式です。

📄 admin.html — adminLogin
JS
async function adminLogin() { const res = await fetch('/admin/accounts', { headers: { 'x-admin-secret': secret } }); if (res.ok) { adminSecret = secret; // ローカル変数に保持(localStorage は使わない) showMainScreen(); } else { // 401 → エラーメッセージ表示 } }
🔴

セキュリティ上の注意:
管理者シークレットはブラウザのメモリ上にのみ保持されます(localStorage / Cookie には保存しない)。ページを閉じると消えます。これは意図的な設計で、ブラウザ履歴から漏洩するリスクを避けています。

操作一覧

操作API説明
アカウント一覧GET /admin/accounts全アカウントと状態を取得
アカウント追加POST /admin/accountsユーザー名・パスワード・表示名・言語を送信
有効化/無効化PATCH /admin/accounts/:name{ active: true/false } を送信
パスワード変更PATCH /admin/accounts/:name{ password: "新しいPW" } を送信

Appendix

グレースフルシャットダウン

プロセスが SIGTERM / SIGINT を受け取った時(デプロイ更新・手動停止など)、接続中のクライアント全員に server_shutdown を通知してから切断します。

📄 server.js — gracefulShutdown
JS
function gracefulShutdown(signal) { // 1. 全クライアントに停止通知を送る for (const [, room] of rooms) { for (const [, c] of room.clients) { c.ws.send(JSON.stringify({ type: 'server_shutdown' })); c.ws.close(1001, 'Server shutting down'); } } // 2. HTTP サーバーを新規接続受け付け停止 httpServer.close(() => process.exit(0)); // 3. 10秒経っても終わらなければ強制終了 setTimeout(() => process.exit(1), 10000).unref(); } process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT'));