Kw-Translator
/
技術ドキュメント
/
コード解説
Section 01
ファイル構成 #
プロジェクトは最小構成で、Node.js サーバー1ファイルと HTML ファイル2枚だけです。外部フレームワーク(React/Vue等)は使っていません。
kw-translator/
server.js
index.html
admin.html
package.json
.env.example
💡
なぜシンプルな構成なのか?
ビルドツール・バンドラーなしでデプロイが完結するため、Render など PaaS への配備が簡単です。node server.js 一発で起動します。
依存ライブラリ
package.json を見ると、依存ライブラリは ws(WebSocketライブラリ)のみです。それ以外(https・fs・crypto・path)は Node.js 標準モジュールを使用しており、追加インストール不要です。
// 依存ライブラリは 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=無制限。 |
// 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 という計算コストの高いハッシュ関数を使って変換してから保存しています。
// パスワードをハッシュ化して保存用の文字列を作る
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日。改ざんは署名で検出できる。
// 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 が定義されています。
// 全 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 /health | GET | なし | サーバー稼働確認(稼働ルーム数・モデル名等) |
| /auth/login | POST | なし | ログイン → JWT返却 |
| /auth/me | GET | JWT | トークンからアカウント情報を取得 |
| /auth/rooms | GET | JWT | 自分のルーム一覧を取得 |
| /rooms/new | GET | JWT | 新規ルームコードを発行 |
| /rooms/:code | DELETE | JWT | ルームと履歴を削除 |
| /glossary/upload | POST | JWT or Admin | Excel辞書データを一括登録 |
| /admin | GET | なし | 管理画面HTML配信 |
| /admin/accounts | GET/POST | Admin Secret | アカウント一覧取得・作成 |
| /admin/accounts/:name | PATCH | Admin Secret | パスワード変更・有効化/無効化 |
| /* (fallback) | GET | なし | index.html を返す(SPA的動作) |
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 と異なり、一度接続したら繋ぎっぱなしで双方向通信できるプロトコルです。チャットアプリに最適です。
メッセージの種類(サーバー→クライアント)
| type | 意味 |
| room_state | 接続時に送られる「今いる参加者の一覧」 |
| join | 誰かが新しく参加した通知 |
| leave | 誰かが退出した通知 |
| speech | 翻訳済みテキストメッセージ |
| speech_image | OCR・翻訳済みの画像メッセージ |
| speech_failed | 翻訳失敗(原文のみ) |
| speaking | 「話し中」インジケーター更新 |
| replay_start/end | 再接続時の過去メッセージ配信の開始/終了 |
| history_start/end | Supabaseからの履歴配信の開始/終了 |
| host_left | ホストが退出し、ルーム閉鎖カウントダウン開始 |
| room_closed | ルームが閉鎖された通知 |
| guest_warning | ゲストの2時間タイムアウト10分前警告 |
| guest_kicked | ゲストを2時間タイムアウトで退出させる |
| server_shutdown | メンテナンス等でサーバーが停止する通知 |
| error | エラー通知(ROOM_NOT_FOUND等) |
| pong | クライアントのping応答 |
join 処理の詳細
クライアントが join を送ると、サーバーは以下を行います:
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 チェーン(キュー)を持つことで、発言の順序を保証しています。
// クライアント登録時に空の 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) #
テキスト翻訳フロー
Gemini
translateWithGemini
ルームに複数の言語ユーザーがいる場合、必要な言語の数だけ並列で翻訳リクエストを投げます(Promise.all)。
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要素を含めています。
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):抽出したテキストを番号付き一括翻訳して元のボックス情報に結合する
// 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(サーバー混雑・レート超過)のときに指数バックオフで自動リトライします。
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 呼び出しをスキップし、コストと速度を改善します。
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件抽出してプロンプトに追加します。
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エラーを返してリクエストを拒否します。
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を取得します。
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 に保存)。
// 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分後に残ったゲストを全員退出させルームを削除します。
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と被らないよう衝突チェック付きです。
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件をインメモリに保持します。
// ルームごとにバッファ: 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 オブジェクト一つで管理しています。
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秒になります。
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 API(SpeechRecognition)を使います。音声データ自体はブラウザが処理し、テキストに変換されたものだけをサーバーに送ります。
⚠️
Web Speech API は Chrome での利用が安定しています。iOS Safari では動作しない場合があります(micDisabled フラグで制御)。
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 ループを組み合わせてスリープを防止しています。
// 方法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。
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 要素に元画像を描いた上から翻訳テキストを重ねます。背景色はピクセル平均から自動決定し、テキスト色はその輝度によって白/黒を選択します。
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 を使います。翻訳テキストが届くたびにキューに入れ、一つずつ順番に読み上げます(同時に複数話させない)。
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 は使わず、毎リクエストにシークレットを送る方式です。
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 を通知してから切断します。
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'));