feat: リアルタイム議事録システム初期リリース
Made-with: Cursor
This commit is contained in:
commit
24b07a7bb2
1
.cursorrules
Normal file
1
.cursorrules
Normal file
@ -0,0 +1 @@
|
||||
常に日本語で回答してください。
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
# ===== 必須 =====
|
||||
# 1P: ADS 議事録システム / Deepgram API Key
|
||||
DEEPGRAM_API_KEY={{ op://AI-Driven School/aymxccrse4mmlhunqej4ifjjme/password }}
|
||||
# 1P: ADS 議事録システム / Google AI API Key
|
||||
GOOGLE_GENERATIVE_AI_API_KEY={{ op://AI-Driven School/gj6ah5wib6to3pxydvx2xbthve/password }}
|
||||
|
||||
# ===== オプション =====
|
||||
# PORT=3001
|
||||
#
|
||||
# 会議設定(参加者・キーターム・置換ルール)は config.yaml で管理します
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
output/
|
||||
dist/
|
||||
.env
|
||||
pnpm-config.json
|
||||
214
README.md
Normal file
214
README.md
Normal file
@ -0,0 +1,214 @@
|
||||
# リアルタイム議事録システム
|
||||
|
||||
会議音声をリアルタイムで文字起こしし、タスクを自動抽出するツールです。
|
||||
|
||||
## 何ができるか
|
||||
|
||||
マイクで拾った会議の音声を、裏側でリアルタイムに文字起こしします。さらに 30 秒ごとに会話内容を AI が読み取り、「誰が/いつまでに/何をやるか」をタスクとして自動で抜き出します。
|
||||
|
||||
```
|
||||
マイク → 音声認識(Deepgram) → 文字起こしファイル(transcript.md)
|
||||
↓
|
||||
AI(Gemini) → タスク一覧ファイル(tasks.md)
|
||||
```
|
||||
|
||||
会議が終わると、手元に 2 つの Markdown ファイルが残ります。
|
||||
|
||||
- **transcript.md** — 会議の全文記録(タイムスタンプ付き)
|
||||
- **tasks.md** — 抽出されたタスクの一覧表(担当者・期限・根拠つき)
|
||||
|
||||
## 前提条件
|
||||
|
||||
以下のツールが必要です。
|
||||
|
||||
| ツール | バージョン | インストール方法 |
|
||||
|--------|-----------|-----------------|
|
||||
| Node.js | 20 以上 | https://nodejs.org/ |
|
||||
| pnpm | 最新 | `npm install -g pnpm` |
|
||||
| just | 最新 | `brew install just`(macOS) |
|
||||
|
||||
## セットアップ
|
||||
|
||||
### 1. API キーの取得
|
||||
|
||||
2つの API キーが必要です。
|
||||
|
||||
**Deepgram**(音声認識)
|
||||
|
||||
1. https://console.deepgram.com/ にアクセス
|
||||
2. アカウントを作成(または既存アカウントでログイン)
|
||||
3. 「API Keys」から新しいキーを作成
|
||||
4. キーをコピー
|
||||
|
||||
**Google AI**(タスク抽出)
|
||||
|
||||
1. https://aistudio.google.com/apikey にアクセス
|
||||
2. Google アカウントでログイン
|
||||
3. 「Create API key」でキーを作成
|
||||
4. キーをコピー
|
||||
|
||||
### 2. .env ファイルの作成
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
テキストエディタで `.env` を開き、取得した API キーを貼り付けてください。
|
||||
|
||||
```
|
||||
DEEPGRAM_API_KEY=ここにDeepgramのキーを貼り付け
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=ここにGoogle AIのキーを貼り付け
|
||||
```
|
||||
|
||||
### 3. 会議設定(config.yaml)
|
||||
|
||||
`config.yaml` を開いて、会議に合わせて編集します。設定しなくても動きますが、設定すると精度が上がります。
|
||||
|
||||
**参加者名**
|
||||
|
||||
会議の参加者名を登録すると、音声認識で名前が正しく聞き取られやすくなります。タスク抽出でも、担当者名の表記ミスを防げます。
|
||||
|
||||
```yaml
|
||||
participants:
|
||||
- 田中
|
||||
- 鈴木
|
||||
- 佐藤
|
||||
```
|
||||
|
||||
**キーターム(専門用語)**
|
||||
|
||||
会議で出てくる専門用語・プロジェクト名・ブランド名を登録すると、音声認識の精度が上がります。20〜50 語が目安です。
|
||||
|
||||
```yaml
|
||||
keyterms:
|
||||
- AI-Driven School
|
||||
- Deepgram
|
||||
- Linear
|
||||
- Slack
|
||||
```
|
||||
|
||||
**置換ルール(任意)**
|
||||
|
||||
音声認識でよくある誤変換パターンを自動修正できます。キータームで解決しない場合の補助手段です。
|
||||
|
||||
```yaml
|
||||
replacements:
|
||||
- find: 深グラム
|
||||
replace: Deepgram
|
||||
```
|
||||
|
||||
## 使い方
|
||||
|
||||
### 立ち上げ方
|
||||
|
||||
1. Cursor でこのプロジェクトフォルダを開く
|
||||
2. **Cmd + J** でターミナルを開く
|
||||
3. 以下を入力して Enter
|
||||
|
||||
```bash
|
||||
just start
|
||||
```
|
||||
|
||||
初回は必要なパッケージの自動インストールが走るため、数十秒ほどかかります。ターミナルに起動完了のメッセージが出たら準備完了です。
|
||||
|
||||
4. ブラウザで http://localhost:5173 を開く
|
||||
|
||||
> `just` はよく使うコマンドに名前を付けてまとめたものです。中身を知らなくても `just start` だけ覚えれば OK です。
|
||||
>
|
||||
> ターミナル操作に不安がある場合は、Cursor の AI に「このプロジェクトを立ち上げて」と頼んでも大丈夫です。
|
||||
|
||||
### 録音する
|
||||
|
||||
1. ブラウザで「**録音を開始**」ボタンを押す
|
||||
- 初回はマイクの使用許可を求められるので「許可」を選んでください
|
||||
2. 録音中は画面に最新の文字起こしが表示されます。裏では 30 秒ごとにタスクも自動抽出されています
|
||||
3. 会議が終わったら「**録音を停止**」ボタンを押す
|
||||
|
||||
### 停止
|
||||
|
||||
- ターミナルで **Ctrl+C** を押す
|
||||
- または別のターミナルで `just stop` を実行
|
||||
|
||||
### 出力ファイル
|
||||
|
||||
`output/` フォルダに、録音ごとに 2 つのファイルが生成されます。
|
||||
|
||||
```
|
||||
output/
|
||||
meeting-2026-04-17T14-00-00-transcript.md
|
||||
meeting-2026-04-17T14-00-00-tasks.md
|
||||
```
|
||||
|
||||
**transcript.md の例**
|
||||
|
||||
```markdown
|
||||
# 会議メモ 2026/04/17 14:00
|
||||
|
||||
## 参加者
|
||||
田中、鈴木、佐藤
|
||||
|
||||
---
|
||||
|
||||
**[14:00:05]** 今日は来週のリリースについて話しましょう
|
||||
|
||||
**[14:00:12]** デザインの最終確認がまだ終わっていないんですが
|
||||
|
||||
**[14:00:20]** 佐藤さん、明日までにレビューお願いできますか
|
||||
```
|
||||
|
||||
**tasks.md の例**
|
||||
|
||||
```markdown
|
||||
# 抽出タスク
|
||||
|
||||
| # | タスク | 担当 | 期限 | 根拠 |
|
||||
|---|--------|------|------|------|
|
||||
| 1 | デザインの最終確認レビュー | 佐藤 | 明日 | 佐藤さん、明日までにレビューお願いできますか |
|
||||
```
|
||||
|
||||
議事録としてチームに共有したり、タスクをスプレッドシートや Linear に転記して使えます。
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### 「.env ファイルが見つかりません」と表示される
|
||||
|
||||
上記の「.env ファイルの作成」の手順に従ってください。
|
||||
|
||||
### 「APIキーが無効です」と表示される
|
||||
|
||||
- API キーが正しくコピーされているか確認してください(前後にスペースが入っていないか)
|
||||
- Deepgram: https://console.deepgram.com/ でキーが有効か確認
|
||||
- Google AI: https://aistudio.google.com/apikey でキーが有効か確認
|
||||
|
||||
### 「ポート 3001 が使用中です」と表示される
|
||||
|
||||
前回のサーバーが残っている可能性があります。
|
||||
|
||||
```bash
|
||||
just stop
|
||||
```
|
||||
|
||||
を実行してから、再度 `just start` を試してください。
|
||||
|
||||
### マイクが使えない / ブロックされている
|
||||
|
||||
Chrome の場合: アドレスバーの鍵アイコン →「サイトの設定」→ マイク →「許可」に変更し、ページを再読み込みしてください。
|
||||
|
||||
### 「サーバーとの接続に失敗しました」と表示される
|
||||
|
||||
サーバーが起動していない可能性があります。ターミナルでエラーが出ていないか確認し、`just start` で再起動してください。
|
||||
|
||||
### 文字起こしの精度が低い
|
||||
|
||||
- `config.yaml` のキータームに、会議で出てくる専門用語を追加してください
|
||||
- マイクに近い位置で話すと精度が上がります
|
||||
- 複数人が同時に話すと認識精度が下がります
|
||||
|
||||
### タスクが抽出されない
|
||||
|
||||
- 会話が短すぎると(2 文以下)タスクは抽出されません
|
||||
- Google AI の無料枠を超えると一時的にタスク抽出が停止します。しばらく待つか、API キーのプランを確認してください
|
||||
|
||||
### 推奨ブラウザ
|
||||
|
||||
**Chrome** を推奨します。Safari や Firefox ではマイクの挙動が異なる場合があります。
|
||||
25
config.yaml
Normal file
25
config.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
# 会議設定
|
||||
# このファイルを編集して、音声認識の精度をチューニングできます
|
||||
|
||||
# 参加者名(タスク抽出の担当者候補として使用)
|
||||
# 参加者名はキータームにも自動追加され、名前の認識精度が上がります
|
||||
participants:
|
||||
- 田中
|
||||
- 鈴木
|
||||
- 佐藤
|
||||
|
||||
# 音声認識のキーターム
|
||||
# 会議で出てくる専門用語・プロジェクト名・ブランド名を指定すると認識精度が上がります
|
||||
# 上限: 全キータームで合計500トークン(20〜50語が目安)
|
||||
keyterms:
|
||||
- AI-Driven School
|
||||
- Deepgram
|
||||
- Linear
|
||||
- Slack
|
||||
|
||||
# 認識後の置換ルール(任意)
|
||||
# 既知の誤変換パターンを修正します
|
||||
# keyterms で解決しない場合の補助手段です
|
||||
replacements:
|
||||
- find: 深グラム
|
||||
replace: Deepgram
|
||||
12
index.html
Normal file
12
index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>リアルタイム議事録</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
justfile
Normal file
21
justfile
Normal file
@ -0,0 +1,21 @@
|
||||
# リアルタイム議事録システム
|
||||
|
||||
# サーバー起動
|
||||
start *args:
|
||||
#!/usr/bin/env bash
|
||||
command -v node >/dev/null 2>&1 || { echo "Node.js がインストールされていません。https://nodejs.org/ からインストールしてください"; exit 1; }
|
||||
command -v pnpm >/dev/null 2>&1 || { echo "pnpm がインストールされていません。npm install -g pnpm を実行してください"; exit 1; }
|
||||
[ -d node_modules ] || pnpm install
|
||||
pnpm tsx server/preflight.ts
|
||||
pnpm dev {{args}}
|
||||
|
||||
# サーバー停止
|
||||
stop:
|
||||
#!/usr/bin/env bash
|
||||
pids=$(lsof -ti :3001,:5173 2>/dev/null)
|
||||
[[ -n "$pids" ]] && kill $pids 2>/dev/null || true
|
||||
|
||||
# サーバー再起動
|
||||
restart *args:
|
||||
just stop
|
||||
just start {{args}}
|
||||
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "realtime-meeting-minutes",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others-on-fail \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"build": "tsc && vite build"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.60",
|
||||
"@deepgram/sdk": "^5.0.0",
|
||||
"ai": "^6.0.154",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"express": "^5.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"ws": "^8.20.0",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"postcss": "^8.5.9",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
2124
pnpm-lock.yaml
generated
Normal file
2124
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
postcss.config.js
Normal file
3
postcss.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: {},
|
||||
};
|
||||
155
server/config.ts
Normal file
155
server/config.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import fs from "node:fs";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { getEnv } from "./env.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export interface Replacement {
|
||||
find: string;
|
||||
replace: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
server: {
|
||||
port: number;
|
||||
};
|
||||
deepgram: {
|
||||
model: string;
|
||||
language: string;
|
||||
diarize: boolean;
|
||||
interimResults: boolean;
|
||||
endpointing: number | false;
|
||||
utteranceEndMs: number;
|
||||
smartFormat: boolean;
|
||||
punctuate: boolean;
|
||||
keyterms: string[];
|
||||
replacements: Replacement[];
|
||||
};
|
||||
keepAlive: {
|
||||
intervalMs: number;
|
||||
idleThresholdMs: number;
|
||||
};
|
||||
taskExtraction: {
|
||||
model: string;
|
||||
intervalMs: number;
|
||||
llmMaxRetries: number;
|
||||
maxConsecutiveFailures: number;
|
||||
maxBackoffMs: number;
|
||||
};
|
||||
reconnect: {
|
||||
maxAttempts: number;
|
||||
maxBufferBytes: number;
|
||||
};
|
||||
output: {
|
||||
dir: string;
|
||||
};
|
||||
participants: string[];
|
||||
}
|
||||
|
||||
// --- config.yaml 読み込み ---
|
||||
|
||||
const CONFIG_YAML_PATH = "config.yaml";
|
||||
|
||||
interface MeetingConfig {
|
||||
participants: string[];
|
||||
keyterms: string[];
|
||||
replacements: Replacement[];
|
||||
}
|
||||
|
||||
const DEFAULT_MEETING_CONFIG: MeetingConfig = {
|
||||
participants: [],
|
||||
keyterms: [],
|
||||
replacements: [],
|
||||
};
|
||||
|
||||
function filterStrings(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function isReplacement(v: unknown): v is Replacement {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const r = v as Record<string, unknown>;
|
||||
return typeof r.find === "string" && typeof r.replace === "string";
|
||||
}
|
||||
|
||||
function loadMeetingConfig(): MeetingConfig {
|
||||
if (!fs.existsSync(CONFIG_YAML_PATH)) {
|
||||
logger.info("config.yaml が見つかりません。デフォルト設定で起動します");
|
||||
return DEFAULT_MEETING_CONFIG;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_YAML_PATH, "utf-8");
|
||||
const parsed = parseYaml(raw) as Record<string, unknown>;
|
||||
|
||||
const config: MeetingConfig = {
|
||||
participants: filterStrings(parsed.participants),
|
||||
keyterms: filterStrings(parsed.keyterms),
|
||||
replacements: Array.isArray(parsed.replacements)
|
||||
? parsed.replacements.filter(isReplacement)
|
||||
: [],
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`config.yaml: 参加者 ${config.participants.length} 名, キーターム ${config.keyterms.length} 件, 置換ルール ${config.replacements.length} 件`,
|
||||
);
|
||||
|
||||
return config;
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
"config.yaml の読み込みに失敗しました。デフォルト設定で起動します",
|
||||
e,
|
||||
);
|
||||
return DEFAULT_MEETING_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
// プロセスライフタイムで1回だけ初期化される設定値(録音セッション状態とは無関係)
|
||||
let _config: Config | null = null;
|
||||
|
||||
export function getConfig(): Config {
|
||||
if (!_config) {
|
||||
const env = getEnv();
|
||||
const meeting = loadMeetingConfig();
|
||||
_config = {
|
||||
server: {
|
||||
port: env.PORT,
|
||||
},
|
||||
deepgram: {
|
||||
model: "nova-3",
|
||||
language: "ja",
|
||||
diarize: false,
|
||||
interimResults: true,
|
||||
endpointing: 500,
|
||||
utteranceEndMs: 1000,
|
||||
smartFormat: true,
|
||||
punctuate: true,
|
||||
keyterms: [
|
||||
...new Set([...meeting.participants, ...meeting.keyterms]),
|
||||
],
|
||||
replacements: meeting.replacements,
|
||||
},
|
||||
keepAlive: {
|
||||
intervalMs: 3_000,
|
||||
idleThresholdMs: 5_000,
|
||||
},
|
||||
taskExtraction: {
|
||||
model: "gemini-3-flash-preview",
|
||||
intervalMs: 30_000,
|
||||
llmMaxRetries: 0,
|
||||
maxConsecutiveFailures: 3,
|
||||
maxBackoffMs: 180_000,
|
||||
},
|
||||
reconnect: {
|
||||
maxAttempts: 3,
|
||||
maxBufferBytes: 2 * 1024 * 1024,
|
||||
},
|
||||
output: {
|
||||
dir: "output",
|
||||
},
|
||||
participants: meeting.participants,
|
||||
};
|
||||
}
|
||||
return _config;
|
||||
}
|
||||
9
server/constants.ts
Normal file
9
server/constants.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// preflight.ts と env.ts の両方から参照される定数。
|
||||
// 外部ライブラリに依存しないため、preflight.ts を単独実行しても安全。
|
||||
|
||||
export const REQUIRED_KEYS = [
|
||||
"DEEPGRAM_API_KEY",
|
||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_PORT = 3001;
|
||||
278
server/deepgram-relay.ts
Normal file
278
server/deepgram-relay.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import { DeepgramClient } from "@deepgram/sdk";
|
||||
import type { WebSocket } from "ws";
|
||||
import { getConfig, type Config, type Replacement } from "./config.js";
|
||||
import { TranscriptWriter } from "./transcript-writer.js";
|
||||
import type { ServerMessage } from "./types.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
type V1Socket = Awaited<
|
||||
ReturnType<InstanceType<typeof DeepgramClient>["listen"]["v1"]["connect"]>
|
||||
>;
|
||||
|
||||
function formatReplacement(r: Replacement): string {
|
||||
return `${r.find}:${r.replace}`;
|
||||
}
|
||||
|
||||
export class DeepgramRelay {
|
||||
readonly timestamp: string;
|
||||
readonly llmInputBuffer: string[] = [];
|
||||
readonly outputPath: string;
|
||||
|
||||
private readonly config: Config;
|
||||
private readonly writer: TranscriptWriter;
|
||||
private readonly meetingStartTime: Date;
|
||||
private readonly connectArgs: Parameters<InstanceType<typeof DeepgramClient>["listen"]["v1"]["connect"]>[0];
|
||||
|
||||
private connection: V1Socket | null = null;
|
||||
private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastDataSentAt = Date.now();
|
||||
private timestampOffsetMs = 0;
|
||||
|
||||
private reconnecting = false;
|
||||
private readonly audioBuffer: Buffer[] = [];
|
||||
private audioBufferBytes = 0;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly browserWs: WebSocket,
|
||||
) {
|
||||
this.config = getConfig();
|
||||
this.meetingStartTime = new Date();
|
||||
this.timestamp = this.meetingStartTime
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, 19);
|
||||
this.writer = new TranscriptWriter(this.timestamp);
|
||||
this.outputPath = this.writer.getOutputPath();
|
||||
|
||||
this.connectArgs = {
|
||||
model: this.config.deepgram.model,
|
||||
language: this.config.deepgram.language,
|
||||
encoding: "linear16",
|
||||
sample_rate: "16000",
|
||||
channels: "1",
|
||||
...(this.config.deepgram.diarize ? { diarize: "true" } : {}),
|
||||
interim_results: String(this.config.deepgram.interimResults),
|
||||
endpointing: String(this.config.deepgram.endpointing),
|
||||
utterance_end_ms: String(this.config.deepgram.utteranceEndMs),
|
||||
smart_format: String(this.config.deepgram.smartFormat),
|
||||
punctuate: String(this.config.deepgram.punctuate),
|
||||
Authorization: this.apiKey,
|
||||
queryParams: this.buildQueryParams(),
|
||||
};
|
||||
}
|
||||
|
||||
async start(participants: string[]): Promise<void> {
|
||||
await this.writer.init(participants);
|
||||
this.connection = await this.openConnection();
|
||||
this.startKeepAlive();
|
||||
}
|
||||
|
||||
sendAudio(chunk: Buffer): void {
|
||||
if (chunk.byteLength === 0) return;
|
||||
this.lastDataSentAt = Date.now();
|
||||
|
||||
if (this.reconnecting) {
|
||||
this.bufferChunk(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.connection?.sendMedia(chunk);
|
||||
} catch {
|
||||
logger.warn("音声送信失敗 — 再接続を開始");
|
||||
this.bufferChunk(chunk);
|
||||
void this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.sendToClient({ type: "stopping" });
|
||||
|
||||
try { this.connection?.sendFinalize({ type: "Finalize" }); } catch {}
|
||||
await this.wait(5000);
|
||||
try { this.connection?.sendCloseStream({ type: "CloseStream" }); } catch {}
|
||||
|
||||
this.clearKeepAlive();
|
||||
await this.writer.flush();
|
||||
|
||||
try { this.connection?.close(); } catch {}
|
||||
this.connection = null;
|
||||
}
|
||||
|
||||
private buildQueryParams(): Record<string, string[]> | undefined {
|
||||
const params: Record<string, string[]> = {};
|
||||
const { keyterms, replacements } = this.config.deepgram;
|
||||
|
||||
if (keyterms.length > 0) {
|
||||
params.keyterm = keyterms;
|
||||
logger.info(`Deepgram keyterms: ${keyterms.join(", ")}`);
|
||||
}
|
||||
|
||||
if (replacements.length > 0) {
|
||||
params.replace = replacements.map(formatReplacement);
|
||||
logger.info(
|
||||
`Deepgram replace: ${replacements.map(formatReplacement).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return Object.keys(params).length > 0 ? params : undefined;
|
||||
}
|
||||
|
||||
// --- Private: Connection lifecycle ---
|
||||
|
||||
private async openConnection(): Promise<V1Socket> {
|
||||
const client = new DeepgramClient({ apiKey: this.apiKey });
|
||||
const conn = await client.listen.v1.connect(this.connectArgs);
|
||||
|
||||
conn.on("open", () => {
|
||||
logger.info("Deepgram 接続確立");
|
||||
if (!this.reconnecting) {
|
||||
this.sendToClient({ type: "ready", outputPath: this.outputPath });
|
||||
}
|
||||
});
|
||||
|
||||
conn.on("message", (data) => this.handleTranscriptMessage(data));
|
||||
|
||||
conn.on("error", (error) => {
|
||||
logger.error("Deepgram エラー", error);
|
||||
this.sendToClient({ type: "error", code: "DEEPGRAM_ERROR", message: error.message });
|
||||
});
|
||||
|
||||
conn.on("close", () => {
|
||||
logger.info("Deepgram 接続終了");
|
||||
});
|
||||
|
||||
conn.connect();
|
||||
await conn.waitForOpen();
|
||||
return conn;
|
||||
}
|
||||
|
||||
private handleTranscriptMessage(data: { type: string }): void {
|
||||
if (data.type !== "Results") return;
|
||||
|
||||
const result = data as unknown as {
|
||||
is_final?: boolean;
|
||||
speech_final?: boolean;
|
||||
start: number;
|
||||
channel: { alternatives: { transcript: string; words: { speaker?: number }[] }[] };
|
||||
};
|
||||
|
||||
const alt = result.channel.alternatives[0];
|
||||
if (!alt) return;
|
||||
|
||||
const { transcript } = alt;
|
||||
const isFinal = result.is_final ?? false;
|
||||
const speechFinal = result.speech_final ?? false;
|
||||
|
||||
this.sendToClient({ type: "transcript", text: transcript, isFinal });
|
||||
|
||||
if (!isFinal || !transcript.trim()) return;
|
||||
|
||||
const ts = this.formatTimestamp(result.start + this.timestampOffsetMs / 1000);
|
||||
this.writer.appendUtterance(transcript, ts, speechFinal);
|
||||
this.llmInputBuffer.push(transcript);
|
||||
}
|
||||
|
||||
// --- Private: Reconnection ---
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
if (this.reconnecting) return;
|
||||
this.reconnecting = true;
|
||||
this.sendToClient({ type: "reconnecting" });
|
||||
this.clearKeepAlive();
|
||||
|
||||
const elapsedMs = Date.now() - this.meetingStartTime.getTime();
|
||||
|
||||
for (let attempt = 1; attempt <= this.config.reconnect.maxAttempts; attempt++) {
|
||||
const backoff = Math.pow(2, attempt - 1) * 1000;
|
||||
logger.info(`Deepgram 再接続 試行 ${attempt}/${this.config.reconnect.maxAttempts} (${backoff}ms後)`);
|
||||
await this.wait(backoff);
|
||||
|
||||
try {
|
||||
this.connection = await this.openConnection();
|
||||
this.timestampOffsetMs = elapsedMs;
|
||||
this.drainAudioBuffer();
|
||||
this.startKeepAlive();
|
||||
this.reconnecting = false;
|
||||
this.sendToClient({ type: "reconnected" });
|
||||
logger.info("Deepgram 再接続成功");
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.error(`再接続試行 ${attempt} 失敗`, e);
|
||||
}
|
||||
}
|
||||
|
||||
this.reconnecting = false;
|
||||
this.sendToClient({
|
||||
type: "error",
|
||||
code: "DEEPGRAM_RECONNECT_FAILED",
|
||||
message: `Deepgram への再接続に ${this.config.reconnect.maxAttempts} 回失敗しました`,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Private: KeepAlive ---
|
||||
|
||||
private startKeepAlive(): void {
|
||||
this.clearKeepAlive();
|
||||
this.keepAliveTimer = setInterval(() => {
|
||||
if (Date.now() - this.lastDataSentAt > this.config.keepAlive.idleThresholdMs) {
|
||||
try { this.connection?.sendKeepAlive({ type: "KeepAlive" }); } catch {
|
||||
logger.warn("KeepAlive 送信失敗");
|
||||
}
|
||||
}
|
||||
}, this.config.keepAlive.intervalMs);
|
||||
}
|
||||
|
||||
private clearKeepAlive(): void {
|
||||
if (this.keepAliveTimer) {
|
||||
clearInterval(this.keepAliveTimer);
|
||||
this.keepAliveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private: Audio buffer (reconnection) ---
|
||||
|
||||
private bufferChunk(chunk: Buffer): void {
|
||||
while (
|
||||
this.audioBufferBytes + chunk.byteLength > this.config.reconnect.maxBufferBytes &&
|
||||
this.audioBuffer.length > 0
|
||||
) {
|
||||
const dropped = this.audioBuffer.shift()!;
|
||||
this.audioBufferBytes -= dropped.byteLength;
|
||||
logger.warn("再接続バッファ上限超過: 古いチャンクを破棄");
|
||||
}
|
||||
this.audioBuffer.push(chunk);
|
||||
this.audioBufferBytes += chunk.byteLength;
|
||||
}
|
||||
|
||||
private drainAudioBuffer(): void {
|
||||
const chunks = this.audioBuffer.splice(0);
|
||||
this.audioBufferBytes = 0;
|
||||
for (const chunk of chunks) {
|
||||
try { this.connection?.sendMedia(chunk); } catch { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private: Utilities ---
|
||||
|
||||
private sendToClient(msg: ServerMessage): void {
|
||||
if (this.browserWs.readyState === this.browserWs.OPEN) {
|
||||
this.browserWs.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private formatTimestamp(startSeconds: number): string {
|
||||
const d = new Date(this.meetingStartTime.getTime() + startSeconds * 1000);
|
||||
return d.toLocaleTimeString("ja-JP", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
private wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
133
server/env.ts
Normal file
133
server/env.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { z } from "zod";
|
||||
import { REQUIRED_KEYS } from "./constants.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const envSchema = z.object({
|
||||
DEEPGRAM_API_KEY: z.string().min(1),
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: z.string().min(1),
|
||||
PORT: z
|
||||
.string()
|
||||
.default("3001")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().min(1).max(65535)),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export class EnvError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "EnvError";
|
||||
}
|
||||
}
|
||||
|
||||
// プロセスライフタイムで1回だけ初期化される設定値(録音セッション状態とは無関係)
|
||||
let _env: Env | null = null;
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (!_env) _env = loadEnv();
|
||||
return _env;
|
||||
}
|
||||
|
||||
function loadEnv(): Env {
|
||||
dotenvConfig();
|
||||
|
||||
const first = envSchema.safeParse(process.env);
|
||||
if (first.success) return first.data;
|
||||
|
||||
if (!fs.existsSync(".env") && tryOpInject()) {
|
||||
dotenvConfig({ override: true });
|
||||
const retry = envSchema.safeParse(process.env);
|
||||
if (retry.success) {
|
||||
logger.info("1Password CLI で .env を自動生成しました");
|
||||
return retry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const { fieldErrors } = z.flattenError(first.error);
|
||||
throw new EnvError(formatDiagnostics(fieldErrors));
|
||||
}
|
||||
|
||||
function tryOpInject(): boolean {
|
||||
try {
|
||||
execSync("op --version", { stdio: "ignore", timeout: 3_000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
logger.info("1Password CLI で .env を生成中...");
|
||||
execSync("op inject -i .env.example -o .env", {
|
||||
stdio: "inherit",
|
||||
timeout: 30_000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
logger.warn("1Password CLI での .env 生成に失敗しました");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasOpCli(): boolean {
|
||||
try {
|
||||
execSync("op --version", { stdio: "ignore", timeout: 3_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiagnostics(
|
||||
fieldErrors: Record<string, string[] | undefined>,
|
||||
): string {
|
||||
const issues = Object.keys(fieldErrors)
|
||||
.map((key) => {
|
||||
const val = process.env[key];
|
||||
const status =
|
||||
val === undefined ? "未設定" : val === "" ? "空です" : "値が不正です";
|
||||
return ` ${key}: ${status}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const opAvailable = hasOpCli();
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
"============================================================",
|
||||
" 環境変数が設定されていません",
|
||||
"============================================================",
|
||||
"",
|
||||
" 不足している変数:",
|
||||
issues,
|
||||
"",
|
||||
" -- 解決方法 -----------------------------------------------",
|
||||
"",
|
||||
];
|
||||
|
||||
if (opAvailable) {
|
||||
lines.push(
|
||||
" [方法1] 1Password CLI で自動生成(推奨)",
|
||||
" $ op inject -i .env.example -o .env",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
opAvailable
|
||||
? " [方法2] 手動で .env ファイルを作成"
|
||||
: " .env ファイルを手動で作成してください",
|
||||
"",
|
||||
...REQUIRED_KEYS.map((key) => ` ${key}=your-key-here`),
|
||||
"",
|
||||
" API キーの取得先:",
|
||||
" Deepgram : https://console.deepgram.com/",
|
||||
" Google AI : https://aistudio.google.com/apikey",
|
||||
"",
|
||||
"============================================================",
|
||||
"",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
195
server/index.ts
Normal file
195
server/index.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import http from "node:http";
|
||||
import express from "express";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { DeepgramClient } from "@deepgram/sdk";
|
||||
import { getEnv, EnvError } from "./env.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { getConfig } from "./config.js";
|
||||
import { DeepgramRelay } from "./deepgram-relay.js";
|
||||
import { taskExtractionLoop } from "./task-extractor.js";
|
||||
import type { ClientMessage, ServerMessage, TaskStatus } from "./types.js";
|
||||
|
||||
async function validateDeepgramKey(apiKey: string): Promise<void> {
|
||||
try {
|
||||
const client = new DeepgramClient({ apiKey });
|
||||
await client.manage.v1.projects.list();
|
||||
logger.info("Deepgram: OK");
|
||||
} catch (e) {
|
||||
if (hasHttpStatus(e, 401, 403)) {
|
||||
logger.error("Deepgram: APIキーが無効です。.env を確認してください");
|
||||
process.exit(1);
|
||||
}
|
||||
logger.warn("Deepgram: 検証リクエストに失敗しました(起動は続行)", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGoogleAiKey(apiKey: string): Promise<void> {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=1`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
logger.error("Google AI: APIキーが無効です。.env を確認してください");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!res.ok) {
|
||||
logger.warn(`Google AI: 検証リクエストが ${res.status} を返しました(起動は続行)`);
|
||||
return;
|
||||
}
|
||||
logger.info("Google AI: OK");
|
||||
} catch (e) {
|
||||
logger.warn("Google AI: 検証リクエストに失敗しました(起動は続行)", e);
|
||||
}
|
||||
}
|
||||
|
||||
function hasHttpStatus(e: unknown, ...codes: number[]): boolean {
|
||||
if (!(e instanceof Error)) return false;
|
||||
const record = e as Record<string, unknown>;
|
||||
const status = record["statusCode"] ?? record["status"];
|
||||
if (typeof status === "number") return codes.includes(status);
|
||||
const msg = e.message.toLowerCase();
|
||||
return codes.some(
|
||||
(code) => msg.includes(String(code)) || msg.includes(HTTP_STATUS_NAMES[code] ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
const HTTP_STATUS_NAMES: Record<number, string> = {
|
||||
401: "unauthorized",
|
||||
403: "forbidden",
|
||||
};
|
||||
|
||||
function sendToWs(ws: WebSocket, msg: ServerMessage): void {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseClientMessage(text: string): ClientMessage | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
if (parsed.type === "stop") return { type: "stop" };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = getEnv();
|
||||
const config = getConfig();
|
||||
|
||||
const participants = config.participants;
|
||||
logger.info(`参加者: ${participants.length > 0 ? participants.join(", ") : "(未指定)"}`);
|
||||
|
||||
await validateDeepgramKey(env.DEEPGRAM_API_KEY);
|
||||
await validateGoogleAiKey(env.GOOGLE_GENERATIVE_AI_API_KEY);
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
void handleSession(ws, env.DEEPGRAM_API_KEY, participants);
|
||||
});
|
||||
|
||||
const port = config.server.port;
|
||||
server.listen(port, () => {
|
||||
logger.info(`サーバー起動: http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSession(
|
||||
ws: WebSocket,
|
||||
apiKey: string,
|
||||
participants: string[],
|
||||
): Promise<void> {
|
||||
logger.info("ブラウザ WebSocket 接続");
|
||||
|
||||
const config = getConfig();
|
||||
const relay = new DeepgramRelay(apiKey, ws);
|
||||
|
||||
let stopping = false;
|
||||
let lastTaskStatus: TaskStatus = { taskCount: 0, failing: false };
|
||||
let taskAbortController: AbortController | null = null;
|
||||
let taskExtractionPromise: Promise<TaskStatus> | null = null;
|
||||
|
||||
try {
|
||||
await relay.start(participants);
|
||||
} catch (e) {
|
||||
logger.error("Deepgram リレー起動失敗", e);
|
||||
sendToWs(ws, {
|
||||
type: "error",
|
||||
code: "DEEPGRAM_CONNECT_FAILED",
|
||||
message: "Deepgram への接続に失敗しました",
|
||||
});
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
taskAbortController = new AbortController();
|
||||
taskExtractionPromise = taskExtractionLoop(
|
||||
{
|
||||
participants,
|
||||
outputDir: config.output.dir,
|
||||
timestamp: relay.timestamp,
|
||||
llmInputBuffer: relay.llmInputBuffer,
|
||||
onStatusChange: (status) => {
|
||||
lastTaskStatus = status;
|
||||
sendToWs(ws, { type: "task_status", ...status });
|
||||
},
|
||||
},
|
||||
taskAbortController.signal,
|
||||
);
|
||||
|
||||
async function gracefulStop(): Promise<void> {
|
||||
await relay.stop();
|
||||
|
||||
sendToWs(ws, { type: "stopped", ...lastTaskStatus });
|
||||
ws.close();
|
||||
|
||||
taskAbortController?.abort();
|
||||
if (taskExtractionPromise) {
|
||||
const result = await taskExtractionPromise.catch((): null => null);
|
||||
if (result && result.taskCount > 0) {
|
||||
logger.info(`タスク抽出完了: ${result.taskCount} 件`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.on("message", (data: Buffer | string, isBinary: boolean) => {
|
||||
if (isBinary) {
|
||||
relay.sendAudio(data as Buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = tryParseClientMessage(String(data));
|
||||
if (msg?.type === "stop" && !stopping) {
|
||||
stopping = true;
|
||||
logger.info("停止要求を受信");
|
||||
void gracefulStop();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
logger.info("ブラウザ WebSocket 切断");
|
||||
if (!stopping) {
|
||||
taskAbortController?.abort();
|
||||
taskExtractionPromise?.catch(() => {});
|
||||
void relay.stop().catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
if (e instanceof EnvError) {
|
||||
console.error(e.message);
|
||||
} else {
|
||||
logger.error("起動エラー", e);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
10
server/logger.ts
Normal file
10
server/logger.ts
Normal file
@ -0,0 +1,10 @@
|
||||
const timestamp = () => new Date().toISOString();
|
||||
|
||||
export const logger = {
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
console.log(`[${timestamp()}] INFO ${msg}`, ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
console.warn(`[${timestamp()}] WARN ${msg}`, ...args),
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
console.error(`[${timestamp()}] ERROR ${msg}`, ...args),
|
||||
};
|
||||
114
server/preflight.ts
Normal file
114
server/preflight.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import { REQUIRED_KEYS, DEFAULT_PORT } from "./constants.js";
|
||||
|
||||
const MIN_NODE_VERSION = 20;
|
||||
|
||||
function fail(label: string, ...lines: string[]): never {
|
||||
console.error(`\n[check] ${label} ... NG\n`);
|
||||
for (const line of lines) console.error(line);
|
||||
console.error();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function checkNodeVersion(): void {
|
||||
const major = parseInt(process.versions.node.split(".")[0]!, 10);
|
||||
if (major < MIN_NODE_VERSION) {
|
||||
fail(
|
||||
`Node.js v${process.versions.node}`,
|
||||
` Node.js v${MIN_NODE_VERSION} 以上が必要です(現在 v${process.versions.node})。`,
|
||||
" https://nodejs.org/ から最新版をインストールしてください。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkEnvFile(): void {
|
||||
if (!fs.existsSync(".env")) {
|
||||
fail(
|
||||
".env",
|
||||
" .env ファイルが見つかりません。",
|
||||
" 以下の手順で作成してください:",
|
||||
"",
|
||||
" 1. cp .env.example .env",
|
||||
' 2. テキストエディタで .env を開く(例: code .env)',
|
||||
" 3. API キーを貼り付ける",
|
||||
"",
|
||||
" API キーの取得先:",
|
||||
" Deepgram : https://console.deepgram.com/",
|
||||
" Google AI : https://aistudio.google.com/apikey",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRequiredKeys(): void {
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
const val = process.env[key];
|
||||
if (!val || val.trim() === "") {
|
||||
fail(
|
||||
key,
|
||||
` ${key} が設定されていません。`,
|
||||
" .env ファイルを開いて値を設定してください。",
|
||||
"",
|
||||
" API キーの取得先:",
|
||||
" Deepgram : https://console.deepgram.com/",
|
||||
" Google AI : https://aistudio.google.com/apikey",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(): number {
|
||||
const portStr = process.env["PORT"] ?? String(DEFAULT_PORT);
|
||||
const port = parseInt(portStr, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
fail("PORT", ` PORT の値が不正です: ${portStr}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function checkPortAvailable(port: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
fail(
|
||||
`ポート ${port}`,
|
||||
` ポート ${port} が使用中です。`,
|
||||
" 前回のサーバーが残っている場合: just stop を実行",
|
||||
" 他のアプリが使用中の場合: そのアプリを終了してください",
|
||||
);
|
||||
} else if (err.code === "EACCES") {
|
||||
fail(
|
||||
`ポート ${port}`,
|
||||
` ポート ${port} にアクセス権がありません。`,
|
||||
" 1024 以上のポート番号を .env の PORT に設定してください。",
|
||||
);
|
||||
} else {
|
||||
fail(`ポート ${port}`, ` ポートチェックでエラーが発生しました: ${err.message}`);
|
||||
}
|
||||
});
|
||||
server.listen({ port, host: "0.0.0.0" }, () => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
checkNodeVersion();
|
||||
checkEnvFile();
|
||||
dotenvConfig();
|
||||
checkRequiredKeys();
|
||||
|
||||
const port = resolvePort();
|
||||
await checkPortAvailable(port);
|
||||
|
||||
console.log(
|
||||
`[check] 環境チェック完了 (Node.js v${process.versions.node}, .env, APIキー, ポート${port})`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("プリフライトチェックで予期しないエラーが発生しました:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
15
server/schemas.ts
Normal file
15
server/schemas.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ExtractedTaskSchema = z.object({
|
||||
summary: z.string().describe("タスクの1行要約"),
|
||||
assignee: z.string().nullable().describe("担当者名(推定)"),
|
||||
deadline: z.string().nullable().describe("期限(言及があれば)"),
|
||||
evidence: z.string().describe("根拠となる発話の引用"),
|
||||
});
|
||||
|
||||
export const TaskExtractionResultSchema = z.object({
|
||||
tasks: z.array(ExtractedTaskSchema),
|
||||
});
|
||||
|
||||
export type ExtractedTask = z.infer<typeof ExtractedTaskSchema>;
|
||||
export type TaskExtractionResult = z.infer<typeof TaskExtractionResultSchema>;
|
||||
190
server/task-extractor.ts
Normal file
190
server/task-extractor.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { generateObject, APICallError } from "ai";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { getConfig } from "./config.js";
|
||||
import { TaskExtractionResultSchema, type ExtractedTask } from "./schemas.js";
|
||||
import type { TaskStatus } from "./types.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export interface TaskExtractorOptions {
|
||||
participants: string[];
|
||||
outputDir: string;
|
||||
timestamp: string;
|
||||
llmInputBuffer: string[];
|
||||
onStatusChange: (status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
type TaskWithId = ExtractedTask & { id: string };
|
||||
|
||||
function isQuotaOrRateLimitError(error: unknown): boolean {
|
||||
if (!APICallError.isInstance(error)) return false;
|
||||
if (error.statusCode === 429) return true;
|
||||
if (error.statusCode === 503) {
|
||||
const body = error.responseBody ?? "";
|
||||
return body.includes("RESOURCE_EXHAUSTED") || body.includes("quota");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildPrompt(participants: string[], transcriptText: string): string {
|
||||
const participantLine = participants.length > 0
|
||||
? `参加者は ${participants.join("、")} です。`
|
||||
: "参加者リストは未提供です。assignee は常に null にしてください。";
|
||||
|
||||
const assigneeRule = participants.length > 0
|
||||
? `- assignee は参加者リスト(${participants.join("、")})の中からのみ選ぶ。リストにない名前を担当者にしてはならない
|
||||
- 会話中で「〇〇さんお願い」「〇〇がやります」のように名前が明示されている場合のみ assignee を設定する
|
||||
- 名前が明示されていない場合は assignee を null にする(推測しない)`
|
||||
: "- assignee は常に null にする";
|
||||
|
||||
return `以下は会議の文字起こしの一部です。${participantLine}
|
||||
話者の区別はありません(すべての発話が話者ラベルなしで記録されています)。
|
||||
|
||||
---
|
||||
${transcriptText}
|
||||
---
|
||||
|
||||
上記の会話から以下を抽出してください。
|
||||
|
||||
タスク:
|
||||
- 誰かが何かをやると約束した、または依頼された内容を抽出する
|
||||
- 「検討します」「考えておきます」などの曖昧な表現もタスクとして抽出する
|
||||
- 明確な行動(「作る」「送る」「確認する」「レビューする」等)だけでなく、検討・調査系の意思表示も含める
|
||||
- evidence には、そのタスクの根拠となる発話を原文のまま引用する
|
||||
|
||||
担当者の推定ルール:
|
||||
${assigneeRule}
|
||||
- 期限の言及がない場合は deadline を null にする
|
||||
|
||||
エッジケース:
|
||||
- テキストが2文以下の場合は tasks を空配列で返す
|
||||
- タスクが見当たらない場合は tasks を空配列で返す(無理に抽出しない)`;
|
||||
}
|
||||
|
||||
function formatTasksMarkdown(tasks: TaskWithId[]): string {
|
||||
const rows = tasks.map(
|
||||
(t) =>
|
||||
`| ${t.id} | ${t.summary} | ${t.assignee ?? "-"} | ${t.deadline ?? "-"} | ${t.evidence} |`,
|
||||
);
|
||||
|
||||
return [
|
||||
"# 抽出タスク",
|
||||
"",
|
||||
"| # | タスク | 担当 | 期限 | 根拠 |",
|
||||
"|---|--------|------|------|------|",
|
||||
...rows,
|
||||
"",
|
||||
"---",
|
||||
`*最終更新: ${new Date().toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })}*`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await delay(ms, undefined, { signal });
|
||||
} catch {
|
||||
// AbortError on cancellation — expected
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskExtractionLoop(
|
||||
options: TaskExtractorOptions,
|
||||
signal: AbortSignal,
|
||||
): Promise<TaskStatus> {
|
||||
const { participants, outputDir, timestamp, llmInputBuffer, onStatusChange } = options;
|
||||
const cfg = getConfig().taskExtraction;
|
||||
const tasksFilePath = path.join(outputDir, `meeting-${timestamp}-tasks.md`);
|
||||
const allTasks: TaskWithId[] = [];
|
||||
let taskCounter = 0;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
function computeBackoffMs(): number {
|
||||
if (consecutiveFailures === 0) return cfg.intervalMs;
|
||||
return Math.min(
|
||||
cfg.intervalMs * Math.pow(2, consecutiveFailures - 1),
|
||||
cfg.maxBackoffMs,
|
||||
);
|
||||
}
|
||||
|
||||
async function extractAndWrite(lines: string[]): Promise<void> {
|
||||
const transcriptText = lines.join("\n");
|
||||
logger.info(`タスク抽出開始 (${lines.length} 行, ${transcriptText.length} 文字)`);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: google(cfg.model),
|
||||
schema: TaskExtractionResultSchema,
|
||||
prompt: buildPrompt(participants, transcriptText),
|
||||
maxRetries: cfg.llmMaxRetries,
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
if (object.tasks.length > 0) {
|
||||
const newTasks = object.tasks.map((t) => ({
|
||||
...t,
|
||||
id: String(++taskCounter),
|
||||
}));
|
||||
allTasks.push(...newTasks);
|
||||
await fs.writeFile(tasksFilePath, formatTasksMarkdown(allTasks), "utf-8");
|
||||
logger.info(`タスク ${newTasks.length} 件抽出 → ${tasksFilePath}`);
|
||||
} else {
|
||||
logger.info("タスクなし(今回のサイクル)");
|
||||
}
|
||||
}
|
||||
|
||||
function currentStatus(overrides?: Partial<TaskStatus>): TaskStatus {
|
||||
return {
|
||||
taskCount: allTasks.length,
|
||||
failing: consecutiveFailures > 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
while (!signal.aborted) {
|
||||
if (llmInputBuffer.length > 0) {
|
||||
const snapshot = llmInputBuffer.splice(0);
|
||||
try {
|
||||
await extractAndWrite(snapshot);
|
||||
consecutiveFailures = 0;
|
||||
onStatusChange(currentStatus());
|
||||
} catch (e) {
|
||||
consecutiveFailures++;
|
||||
const isQuota = isQuotaOrRateLimitError(e);
|
||||
|
||||
if (isQuota) {
|
||||
logger.warn(
|
||||
`タスク抽出失敗: API クォータ超過 (連続 ${consecutiveFailures} 回)。Google AI Studio でプランを確認してください`,
|
||||
);
|
||||
} else {
|
||||
logger.error(`タスク抽出失敗 (連続 ${consecutiveFailures} 回)`, e);
|
||||
}
|
||||
|
||||
logger.warn(`失敗した ${snapshot.length} 行を破棄(次サイクルの新規バッファで再試行)`);
|
||||
|
||||
const message = isQuota
|
||||
? "タスク抽出が API 制限で停止中"
|
||||
: "タスク抽出でエラーが発生中";
|
||||
onStatusChange(currentStatus({ message }));
|
||||
}
|
||||
}
|
||||
|
||||
await abortableSleep(computeBackoffMs(), signal);
|
||||
}
|
||||
|
||||
if (llmInputBuffer.length > 0 && consecutiveFailures < cfg.maxConsecutiveFailures) {
|
||||
try {
|
||||
logger.info("最終 flush: タスク抽出");
|
||||
await extractAndWrite(llmInputBuffer.splice(0));
|
||||
} catch {
|
||||
logger.warn("最終 flush 失敗");
|
||||
}
|
||||
} else if (llmInputBuffer.length > 0) {
|
||||
logger.warn(
|
||||
`最終 flush スキップ: API が ${consecutiveFailures} 回連続で失敗中のため`,
|
||||
);
|
||||
}
|
||||
|
||||
return currentStatus();
|
||||
}
|
||||
72
server/transcript-writer.ts
Normal file
72
server/transcript-writer.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getConfig } from "./config.js";
|
||||
|
||||
export class TranscriptWriter {
|
||||
private writeQueue = Promise.resolve();
|
||||
private readonly filePath: string;
|
||||
|
||||
constructor(timestamp: string) {
|
||||
this.filePath = path.join(
|
||||
getConfig().output.dir,
|
||||
`meeting-${timestamp}-transcript.md`,
|
||||
);
|
||||
}
|
||||
|
||||
async init(participants: string[]): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString("ja-JP", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const timeStr = now.toLocaleTimeString("ja-JP", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const header = [
|
||||
`# 会議メモ ${dateStr} ${timeStr}`,
|
||||
"",
|
||||
"## 参加者",
|
||||
participants.length > 0 ? participants.join("、") : "(未指定)",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await fs.writeFile(this.filePath, header, "utf-8");
|
||||
}
|
||||
|
||||
appendUtterance(
|
||||
text: string,
|
||||
timestamp: string,
|
||||
isSpeechFinal: boolean,
|
||||
): void {
|
||||
if (!text.trim()) return;
|
||||
|
||||
let content = `**[${timestamp}]** ${text}\n`;
|
||||
if (isSpeechFinal) {
|
||||
content += "\n";
|
||||
}
|
||||
|
||||
this.append(content);
|
||||
}
|
||||
|
||||
private append(text: string): void {
|
||||
this.writeQueue = this.writeQueue.then(() =>
|
||||
fs.appendFile(this.filePath, text, "utf-8"),
|
||||
);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.writeQueue;
|
||||
}
|
||||
|
||||
getOutputPath(): string {
|
||||
return this.filePath;
|
||||
}
|
||||
}
|
||||
17
server/types.ts
Normal file
17
server/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface TaskStatus {
|
||||
taskCount: number;
|
||||
failing: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "ready"; outputPath: string }
|
||||
| { type: "transcript"; text: string; isFinal: boolean }
|
||||
| { type: "reconnecting" }
|
||||
| { type: "reconnected" }
|
||||
| { type: "stopping" }
|
||||
| { type: "stopped" } & TaskStatus
|
||||
| { type: "task_status" } & TaskStatus
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
export type ClientMessage = { type: "stop" };
|
||||
9
src/App.tsx
Normal file
9
src/App.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { RecordingPanel } from "./components/recording-panel";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8">
|
||||
<RecordingPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/components/recording-panel.tsx
Normal file
146
src/components/recording-panel.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { Mic, Square, RotateCcw, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { useRecorder, type RecorderState, type CompletionNotice } from "../hooks/use-recorder";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
export function RecordingPanel() {
|
||||
const { state, start, stop, retry, completionNotice } = useRecorder();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-lg mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">リアルタイム議事録</h1>
|
||||
<StatusBadge status={state.status} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<ActionButton state={state} onStart={start} onStop={stop} onRetry={retry} />
|
||||
<SnippetArea state={state} />
|
||||
</div>
|
||||
|
||||
{state.status === "recording" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
出力先: {state.outputPath}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "error" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
録音済みの内容は保存されています
|
||||
</p>
|
||||
)}
|
||||
|
||||
{completionNotice && state.status === "idle" && (
|
||||
<CompletionBanner notice={completionNotice} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BADGE_CONFIG: Record<RecorderState["status"], { variant: "secondary" | "warning" | "success" | "destructive"; label: string; pulse?: boolean }> = {
|
||||
idle: { variant: "secondary", label: "待機中" },
|
||||
connecting: { variant: "warning", label: "接続中" },
|
||||
recording: { variant: "success", label: "録音中" },
|
||||
reconnecting: { variant: "warning", label: "再接続中", pulse: true },
|
||||
stopping: { variant: "secondary", label: "停止処理中" },
|
||||
error: { variant: "destructive", label: "エラー" },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: RecorderState["status"] }) {
|
||||
const cfg = BADGE_CONFIG[status];
|
||||
return (
|
||||
<Badge variant={cfg.variant} className={cfg.pulse ? "animate-pulse" : undefined}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
state,
|
||||
onStart,
|
||||
onStop,
|
||||
onRetry,
|
||||
}: {
|
||||
state: RecorderState;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
switch (state.status) {
|
||||
case "idle":
|
||||
return (
|
||||
<Button onClick={onStart} className="w-full">
|
||||
<Mic className="h-4 w-4" />
|
||||
録音を開始
|
||||
</Button>
|
||||
);
|
||||
case "connecting":
|
||||
case "stopping":
|
||||
return (
|
||||
<Button disabled className="w-full" variant="secondary">
|
||||
{state.status === "connecting" ? "接続中..." : "停止処理中..."}
|
||||
</Button>
|
||||
);
|
||||
case "recording":
|
||||
case "reconnecting":
|
||||
return (
|
||||
<Button onClick={onStop} className="w-full" variant="destructive">
|
||||
<Square className="h-4 w-4" />
|
||||
録音を停止
|
||||
</Button>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<Button onClick={onRetry} className="w-full">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
再試行
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SnippetArea({ state }: { state: RecorderState }) {
|
||||
switch (state.status) {
|
||||
case "idle":
|
||||
return null;
|
||||
case "connecting":
|
||||
return <p className="text-sm text-muted-foreground">接続しています...</p>;
|
||||
case "recording":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground/80 min-h-[2rem]">
|
||||
{state.lastTranscript ?? "音声を待っています..."}
|
||||
</p>
|
||||
{state.taskFailing && (
|
||||
<p className="text-xs text-yellow-700">
|
||||
{state.taskMessage ?? "タスク抽出でエラーが発生中"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "reconnecting":
|
||||
return (
|
||||
<p className="text-sm text-yellow-700">
|
||||
接続を回復しています。音声は記録されています
|
||||
</p>
|
||||
);
|
||||
case "stopping":
|
||||
return <p className="text-sm text-muted-foreground">停止処理中...</p>;
|
||||
case "error":
|
||||
return <p className="text-sm text-destructive">{state.message}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function CompletionBanner({ notice }: { notice: CompletionNotice }) {
|
||||
const Icon = notice.warning ? AlertTriangle : CheckCircle;
|
||||
const colors = notice.warning
|
||||
? { border: "border-yellow-200", bg: "bg-yellow-50", icon: "text-yellow-600", text: "text-yellow-800" }
|
||||
: { border: "border-green-200", bg: "bg-green-50", icon: "text-green-600", text: "text-green-800" };
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-2 rounded-lg border p-3 ${colors.border} ${colors.bg}`}>
|
||||
<Icon className={`h-4 w-4 mt-0.5 shrink-0 ${colors.icon}`} />
|
||||
<p className={`text-sm ${colors.text}`}>{notice.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/ui/badge.tsx
Normal file
27
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
destructive: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
success: "bg-green-100 text-green-800 border-green-200",
|
||||
warning: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: keyof typeof badgeVariants;
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
badgeVariants[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/button.tsx
Normal file
30
src/components/ui/button.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 border border-destructive/20",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: keyof typeof buttonVariants;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
buttonVariants[variant],
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
296
src/hooks/use-recorder.ts
Normal file
296
src/hooks/use-recorder.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
||||
export interface CompletionNotice {
|
||||
text: string;
|
||||
warning: boolean;
|
||||
}
|
||||
|
||||
export type RecorderState =
|
||||
| { status: "idle" }
|
||||
| { status: "connecting" }
|
||||
| { status: "recording"; outputPath: string; lastTranscript: string | null; taskCount: number; taskFailing: boolean; taskMessage?: string }
|
||||
| { status: "reconnecting" }
|
||||
| { status: "stopping" }
|
||||
| { status: "error"; code: string; message: string };
|
||||
|
||||
const WS_URL = `ws://localhost:3001`;
|
||||
const STOP_TIMEOUT_MS = 10_000;
|
||||
const TARGET_SAMPLE_RATE = 16000;
|
||||
const CHUNK_INTERVAL_MS = 250;
|
||||
|
||||
const MIC_ERRORS: Record<string, { code: string; message: string }> = {
|
||||
NotAllowedError: {
|
||||
code: "MIC_NOT_ALLOWED",
|
||||
message: "マイクがブロックされています。アドレスバーの鍵アイコン →「サイトの設定」→ マイク →「許可」に変更し、ページを再読み込みしてください",
|
||||
},
|
||||
NotFoundError: {
|
||||
code: "MIC_NOT_FOUND",
|
||||
message: "マイクが検出されません。マイクの接続を確認してください",
|
||||
},
|
||||
NotReadableError: {
|
||||
code: "MIC_IN_USE",
|
||||
message: "マイクが他のアプリに使用されています。他の通話アプリを終了してください",
|
||||
},
|
||||
};
|
||||
|
||||
function toMicError(err: unknown): RecorderState & { status: "error" } {
|
||||
if (err instanceof DOMException && err.name in MIC_ERRORS) {
|
||||
const mapped = MIC_ERRORS[err.name]!;
|
||||
return { status: "error", code: mapped.code, message: mapped.message };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { status: "error", code: "MIC_UNKNOWN", message: `マイクの初期化に失敗しました: ${message}` };
|
||||
}
|
||||
|
||||
function buildCompletionNotice(
|
||||
taskCount: number,
|
||||
failing: boolean,
|
||||
outputPath: string,
|
||||
): CompletionNotice {
|
||||
if (failing) {
|
||||
return {
|
||||
text: taskCount > 0
|
||||
? `録音を終了しました。タスク ${taskCount} 件抽出済み(一部失敗あり)`
|
||||
: "録音を終了しました。タスク抽出に失敗しています",
|
||||
warning: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: taskCount > 0
|
||||
? `録音を終了しました。タスク ${taskCount} 件抽出済み`
|
||||
: `録音を終了しました。ファイルは ${outputPath} に保存されています`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRecorder() {
|
||||
const [state, setState] = useState<RecorderState>({ status: "idle" });
|
||||
const [completionNotice, setCompletionNotice] = useState<CompletionNotice | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const stopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const outputPathRef = useRef<string>("");
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (stopTimeoutRef.current) {
|
||||
clearTimeout(stopTimeoutRef.current);
|
||||
stopTimeoutRef.current = null;
|
||||
}
|
||||
if (recorderRef.current && recorderRef.current.state !== "inactive") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
recorderRef.current = null;
|
||||
if (audioCtxRef.current && audioCtxRef.current.state !== "closed") {
|
||||
audioCtxRef.current.close().catch(() => {});
|
||||
}
|
||||
audioCtxRef.current = null;
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
if (wsRef.current && wsRef.current.readyState <= WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
setCompletionNotice(null);
|
||||
setState({ status: "connecting" });
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setState(toMicError(err));
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(event.data)) as Record<string, unknown>;
|
||||
switch (msg.type) {
|
||||
case "ready":
|
||||
outputPathRef.current = String(msg.outputPath ?? "");
|
||||
setState({
|
||||
status: "recording",
|
||||
outputPath: outputPathRef.current,
|
||||
lastTranscript: null,
|
||||
taskCount: 0,
|
||||
taskFailing: false,
|
||||
});
|
||||
break;
|
||||
case "transcript":
|
||||
if (msg.isFinal) {
|
||||
setState((prev) =>
|
||||
prev.status === "recording"
|
||||
? { ...prev, lastTranscript: String(msg.text ?? "") }
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "task_status":
|
||||
setState((prev) =>
|
||||
prev.status === "recording"
|
||||
? {
|
||||
...prev,
|
||||
taskCount: Number(msg.taskCount ?? prev.taskCount),
|
||||
taskFailing: Boolean(msg.failing),
|
||||
taskMessage: msg.message ? String(msg.message) : undefined,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
break;
|
||||
case "reconnecting":
|
||||
setState({ status: "reconnecting" });
|
||||
break;
|
||||
case "reconnected":
|
||||
setState((prev) => ({
|
||||
status: "recording",
|
||||
outputPath: outputPathRef.current,
|
||||
lastTranscript: null,
|
||||
taskCount: prev.status === "recording" ? prev.taskCount : 0,
|
||||
taskFailing: prev.status === "recording" ? prev.taskFailing : false,
|
||||
}));
|
||||
break;
|
||||
case "stopping":
|
||||
setState({ status: "stopping" });
|
||||
break;
|
||||
case "stopped": {
|
||||
if (stopTimeoutRef.current) {
|
||||
clearTimeout(stopTimeoutRef.current);
|
||||
stopTimeoutRef.current = null;
|
||||
}
|
||||
cleanup();
|
||||
setCompletionNotice(
|
||||
buildCompletionNotice(
|
||||
Number(msg.taskCount ?? 0),
|
||||
Boolean(msg.failing),
|
||||
outputPathRef.current,
|
||||
),
|
||||
);
|
||||
setState({ status: "idle" });
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
cleanup();
|
||||
setState({
|
||||
status: "error",
|
||||
code: String(msg.code ?? "UNKNOWN"),
|
||||
message: String(msg.message ?? "不明なエラーが発生しました"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed JSON */
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
cleanup();
|
||||
setState({
|
||||
status: "error",
|
||||
code: "WS_ERROR",
|
||||
message: "サーバーとの接続に失敗しました。サーバーが起動しているか確認してください",
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setState((prev) =>
|
||||
prev.status === "idle" || prev.status === "error" ? prev : { status: "idle" },
|
||||
);
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
const audioCtx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
|
||||
audioCtxRef.current = audioCtx;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
let pcmBuffer: Int16Array[] = [];
|
||||
let sendTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
const float32 = e.inputBuffer.getChannelData(0);
|
||||
const int16 = new Int16Array(float32.length);
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32[i]!));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
pcmBuffer.push(int16);
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioCtx.destination);
|
||||
|
||||
sendTimer = setInterval(() => {
|
||||
if (pcmBuffer.length === 0 || ws.readyState !== WebSocket.OPEN) return;
|
||||
const chunks = pcmBuffer;
|
||||
pcmBuffer = [];
|
||||
const totalLen = chunks.reduce((acc, c) => acc + c.length, 0);
|
||||
const merged = new Int16Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
ws.send(merged.buffer);
|
||||
}, CHUNK_INTERVAL_MS);
|
||||
|
||||
const dummyRecorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
|
||||
recorderRef.current = dummyRecorder;
|
||||
const origStop = dummyRecorder.stop.bind(dummyRecorder);
|
||||
dummyRecorder.stop = () => {
|
||||
if (sendTimer) { clearInterval(sendTimer); sendTimer = null; }
|
||||
processor.disconnect();
|
||||
source.disconnect();
|
||||
origStop();
|
||||
};
|
||||
dummyRecorder.start();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setState({ status: "stopping" });
|
||||
|
||||
const recorder = recorderRef.current;
|
||||
if (recorder && recorder.state !== "inactive") {
|
||||
recorder.stop();
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "stop" }));
|
||||
}
|
||||
|
||||
stopTimeoutRef.current = setTimeout(() => {
|
||||
cleanup();
|
||||
setCompletionNotice({
|
||||
text: "停止処理がタイムアウトしました。一部の発話が保存されなかった可能性があります",
|
||||
warning: true,
|
||||
});
|
||||
setState({ status: "idle" });
|
||||
}, STOP_TIMEOUT_MS);
|
||||
}, [cleanup]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
cleanup();
|
||||
setCompletionNotice(null);
|
||||
setState({ status: "idle" });
|
||||
}, [cleanup]);
|
||||
|
||||
return { state, start, stop, retry, completionNotice };
|
||||
}
|
||||
37
src/index.css
Normal file
37
src/index.css
Normal file
@ -0,0 +1,37 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--color-background: oklch(1 0 0);
|
||||
--color-foreground: oklch(0.145 0 0);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.145 0 0);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.145 0 0);
|
||||
--color-primary: oklch(0.205 0 0);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.965 0 0);
|
||||
--color-secondary-foreground: oklch(0.205 0 0);
|
||||
--color-muted: oklch(0.965 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-accent: oklch(0.965 0 0);
|
||||
--color-accent-foreground: oklch(0.205 0 0);
|
||||
--color-destructive: oklch(0.577 0.245 27.325);
|
||||
--color-destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--color-border: oklch(0.922 0 0);
|
||||
--color-input: oklch(0.922 0 0);
|
||||
--color-ring: oklch(0.708 0 0);
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
17
tsconfig.server.json
Normal file
17
tsconfig.server.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["server"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user