feat: リアルタイム議事録システム初期リリース

Made-with: Cursor
This commit is contained in:
shin-fukasawa1 2026-04-17 16:11:31 +09:00
commit 24b07a7bb2
33 changed files with 4254 additions and 0 deletions

1
.cursorrules Normal file
View File

@ -0,0 +1 @@
常に日本語で回答してください。

10
.env.example Normal file
View 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
View File

@ -0,0 +1,5 @@
node_modules/
output/
dist/
.env
pnpm-config.json

214
README.md Normal file
View File

@ -0,0 +1,214 @@
# リアルタイム議事録システム
会議音声をリアルタイムで文字起こしし、タスクを自動抽出するツールです。
## 何ができるか
マイクで拾った会議の音声を、裏側でリアルタイムに文字起こしします。さらに 30 秒ごとに会話内容を AI が読み取り、「誰が/いつまでに/何をやるか」をタスクとして自動で抜き出します。
```
マイク → 音声認識Deepgram → 文字起こしファイルtranscript.md
AIGemini → タスク一覧ファイル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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

3
postcss.config.js Normal file
View File

@ -0,0 +1,3 @@
export default {
plugins: {},
};

155
server/config.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
tsconfig.json Normal file
View 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
View 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
View 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()],
});