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