ryota commited on
Commit
018fcd7
·
0 Parent(s):

録音文字起こしシステムの初期版(ローカル版・Windows対応)

Browse files

spinthoughts.zip の Mac 向けコードを Windows で動くよう調整し、
テスト音声で文字起こしからCSV/Excel出力まで通ることを確認した状態。

- ffmpeg/ffprobe を PATH 外(winget の置き場)からも探す
- 文字起こしは faster-whisper、GPUがあれば自動でCUDAを使う
- CPU機では既定モデルを large-v3-turbo にする
- HuggingFaceキャッシュのシンボリックリンクを無効化(WinError 1314 回避)
- 話者分離オフのとき全行に要確認が付く不具合を修正

Files changed (9) hide show
  1. .claude/launch.json +11 -0
  2. .gitignore +3 -0
  3. README.md +204 -0
  4. app.py +248 -0
  5. index.html +575 -0
  6. pipeline.py +747 -0
  7. requirements.txt +12 -0
  8. setup.ps1 +23 -0
  9. start.cmd +12 -0
.claude/launch.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.0.1",
3
+ "configurations": [
4
+ {
5
+ "name": "spinthoughts",
6
+ "runtimeExecutable": ".venv\Scripts\python.exe",
7
+ "runtimeArgs": ["app.py"],
8
+ "port": 8000
9
+ }
10
+ ]
11
+ }
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ 出力/
README.md ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 録音文字起こし(Windows・ローカル完結)
2
+
3
+ 録音ZIPを投げると、指定秒数以上のファイルだけを文字起こしし、話者ごとに分けてCSVにするローカルWebアプリ。
4
+ 音声・文字起こし結果ともに外部へ送信しない(モデルの初回ダウンロードのみ通信あり)。
5
+
6
+ - 文字起こし: Whisper large-v3 系(Windows は faster-whisper、Mac の Apple Silicon は MLX)
7
+ - 話者分離: pyannote.audio 3.1
8
+ - 費用: 0円。すべてオープンソース+ローカル実行
9
+
10
+ ---
11
+
12
+ ## 1. セットアップ
13
+
14
+ ### ffmpeg
15
+
16
+ PowerShell で:
17
+
18
+ ```powershell
19
+ winget install Gyan.FFmpeg
20
+ ```
21
+
22
+ インストール直後は PATH に載っていないことがあるが、このアプリは winget の置き場も自分で探すのでそのまま動く。
23
+
24
+ ### Python環境
25
+
26
+ Python 3.12 を使う。フォルダの中で PowerShell を開き:
27
+
28
+ ```powershell
29
+ .\setup.ps1
30
+ ```
31
+
32
+ 仮想環境 `.venv` を作り、torch(CPU版)と残りのライブラリを入れる。数分かかる。
33
+ `python` ではなく `.venv\Scripts\python.exe` を使う点に注意(PATH 上の python.exe は Store のスタブ)。
34
+
35
+ ### HuggingFaceトークン(話者分離に必要)
36
+
37
+ 1. https://huggingface.co/join でアカウント作成(無料)
38
+ 2. 次の2つのページを開き、それぞれ利用条件に同意する
39
+ - https://huggingface.co/pyannote/speaker-diarization-3.1
40
+ - https://huggingface.co/pyannote/segmentation-3.0
41
+ 3. https://huggingface.co/settings/tokens で read 権限のトークンを作成
42
+
43
+ トークンは環境変数に入れておくと毎回の入力が不要になる。PowerShell で一度だけ:
44
+
45
+ ```powershell
46
+ setx HF_TOKEN "hf_xxxxxxxxxxxx"
47
+ ```
48
+
49
+ 設定後は PowerShell を開き直す。
50
+
51
+ **両方のページで同意しないと動かない。** ここを飛ばすとモデル読み込みで失敗する。
52
+ 話者分離をオフにすれば、トークン無しでも文字起こしだけは使える。その場合は話者列が空になる
53
+ (区間が無いことを誤認識と数えて全行に「要確認」が付かないようにしてある)。
54
+
55
+ ---
56
+
57
+ ## 2. 起動
58
+
59
+ エクスプローラーで `start.cmd` をダブルクリックする。ブラウザが開く。
60
+
61
+ コマンドから起動する場合:
62
+
63
+ ```powershell
64
+ .\.venv\Scripts\python.exe app.py
65
+ ```
66
+
67
+ `127.0.0.1:8000` にのみ待ち受けるので、同じPC以外からはアクセスできない。
68
+ 別のポートを使いたいときは `$env:PORT=8030` のように指定する。
69
+
70
+ 初回はWhisperと話者分離のモデルを取得するため数分かかる。2回目以降はキャッシュから読む
71
+ (`C:\Users\<ユーザー名>\.cache\huggingface`)。
72
+
73
+ ---
74
+
75
+ ## 3. 使い方
76
+
77
+ 1. ZIPを選ぶ(ドラッグでも可)
78
+ 2. しきい値(既定30秒)と話者人数を設定
79
+ 3. 「文字起こしを始める」
80
+
81
+ 処理が終わると、ファイルごとの書き起こしが画面に出て、CSVを保存できる。
82
+
83
+ ### 出力
84
+
85
+ **録音1本ごとに分ける**のを基本にしている。「一式をZIPで保存」を押すと次の構成で落ちてくる。
86
+
87
+ ```
88
+ 文字起こし_<ID>/
89
+ ├── 書き起こし/
90
+ │ ├── 001_訪問_田中様.txt ← 人が読む用。そのまま記録に貼れる
91
+ │ └── 002_訪問_佐藤様.txt
92
+ ├── データ/
93
+ │ ├── 001_訪問_田中様.csv ← システム取り込み用
94
+ │ └── 002_訪問_佐藤様.csv
95
+ ├── 録音ごと.xlsx ← 1録音=1シート+先頭に一覧シート
96
+ ├── 処理結果一覧.csv ← 何が処理され何が除外されたかの記録
97
+ └── 全発話.csv ← 横断で検索・集計したいとき用
98
+ ```
99
+
100
+ 画面上でも録音ごとの折りたたみカードになっていて、1本だけコピーしたり、
101
+ その録音のテキスト/CSVだけを個別に落とすこともできる。
102
+
103
+ **書き起こしテキスト(.txt)の形**
104
+
105
+ ```
106
+ ────────────────────────────────────────
107
+ 訪問_田中様.m4a
108
+ ────────────────────────────────────────
109
+ 録音日時 2026-08-12 10:32:04
110
+ 録音長 00:06:52(412秒)
111
+ 話者 SPEAKER_00、SPEAKER_01
112
+ 状態 書き起こし済み
113
+
114
+ 00:00:03 SPEAKER_00
115
+ 今日の体温は36度4分でした
116
+
117
+ 00:00:12 SPEAKER_01
118
+ ありがとう、変わりないね
119
+
120
+ 00:04:41 SPEAKER_00 ※要確認(Whisperの定型幻聴に一致)
121
+ ご視聴ありがとうございました
122
+
123
+ ────────────────────────────────────────
124
+ 全 3 発話 / 要確認 1 件
125
+ ```
126
+
127
+ **CSVの列**(録音ごとのCSV)
128
+
129
+ | 列 | 内容 |
130
+ |---|---|
131
+ | 開始 / 終了 | 発話のタイムコード |
132
+ | 話者 | SPEAKER_00, SPEAKER_01 … |
133
+ | 発話内容 | 書き起こし |
134
+ | 要確認 | 誤認識の疑いがある行に印 |
135
+ | 備考 | 疑わしいと判定した理由 |
136
+
137
+ `全発話.csv` はこれの先頭に「ファイル名・録音日時・録音長(秒)」が付く。
138
+ Excelで文字化けしないようBOM付きUTF-8で出力している。
139
+
140
+ 30秒未満で除外したファイルは発話CSVには出ないが、`処理結果一覧.csv` と
141
+ `書き起こし/` のテキストには理由付きで残る。何が処理されなかったかを黙って消さないため。
142
+
143
+ ---
144
+
145
+ ## 4. 知っておくべき制約
146
+
147
+ **話者の名前は自動では分からない。** `SPEAKER_00` のような番号が振られるだけで、
148
+ どれが職員でどれが利用者かは人が対応付ける必要がある。
149
+ 話者人数を事前に指定すると精度が目に見えて上がるので、1対1の訪問なら「2」を入れる。
150
+
151
+ **精度は録音環境に強く依存する。** レコーダーを机に置いた1対1の会話なら実用的だが、
152
+ 複数人が同時に話す、テレビや生活音が大きい環境では話者の切り替わりを取りこぼす。
153
+ 「完成品」ではなく「人が直す前提の下書き」として設計してある。
154
+
155
+ **Whisperは無音区間で幻の文を作る。** 「ご視聴ありがとうございました」のような定型句が典型。
156
+ 検出した行は削除せず「要確認」を立てるようにした。勝手に消すと、
157
+ 本当の発話まで消えたときに気づけないため。
158
+
159
+ **このPCはCPU処理になる。** NVIDIAのGPUが無いため、文字起こしも話者分離もCPUで回る。
160
+ 精度の既定を `large-v3` ではなく `large-v3-turbo` にしてあるのはそのため。
161
+ GPU搭載機で動かせば自動で `large-v3` が既定になり、速度も数倍になる。
162
+
163
+ **処理時間の目安(このPC / Ryzen 5 7530U・CPU処理)**
164
+ 実測: `large-v3-turbo`・話者分離オフで、合計91秒の録音3本がモデル読み込み込み102秒。
165
+ モデル読み込み(起動後の最初の1回だけ40秒前後)を除くと、録音1秒あたり0.6〜0.7秒。
166
+ つまり1時間の録音で文字起こし35〜45分程度。話者分離を入れると同程度の時間が上乗せされる。
167
+ `large-v3` を選ぶとさらに数倍かかる。長時間の録音は夜間にまとめて流すのが現実的。
168
+
169
+ **要配慮個人情報の扱い。** 介護の録音は個人情報保護法の要配慮個人情報にあたる可能性が高い。
170
+ ローカル完結にしてあるのはそのためだが、出力CSVの保存場所、保持期間、
171
+ アクセス権限は運用側で決める必要がある。作業用の一時ファイルは処理後に削除している。
172
+
173
+ ---
174
+
175
+ ## 5. ファイル構成
176
+
177
+ ```
178
+ koe-okoshi/
179
+ ├── app.py Webサーバー(アップロード・進捗・CSV出力)
180
+ ├── pipeline.py ZIP展開 → 長さフィルタ → 文字起こし → 話者分離 → 統合
181
+ ├── index.html 画面
182
+ ├── setup.ps1 初回セットアップ
183
+ ├── start.cmd 起動
184
+ ├── requirements.txt
185
+ └── README.md
186
+ ```
187
+
188
+ 処理の中身を変えたいときは `pipeline.py` を見る。
189
+ たとえば `HALLUCINATION_PATTERNS` に自分の環境で出やすい誤認識を足せる。
190
+ `DEFAULT_PROMPT` に事業所名や利用者様の呼称を足すと固有名詞の精度が上がる。
191
+
192
+ ---
193
+
194
+ ## 6. うまくいかないとき
195
+
196
+ | 症状 | 対処 |
197
+ |---|---|
198
+ | ffmpeg が見つからない | `winget install Gyan.FFmpeg` を実行し、PowerShellを開き直す |
199
+ | 話者分離モデルが読めない | HuggingFaceの2ページ両方で条件に同意したか、トークンが read 権限か確認 |
200
+ | 日本語ファイル名が化ける | Windows製ZIPのCP932は自動判別済み。それでも化けるなら元のZIPの作り方を確認 |
201
+ | 極端に遅い | 精度を `turbo` に落とす、または話者分離をオフにする |
202
+ | 発話が検出されない | 録音の音量が小さい可能性。`ffmpeg -i 元ファイル -af loudnorm 正規化後.wav` で正規化してから再投入 |
203
+ | ポート8000が使用中 | `$env:PORT=8030; .\.venv\Scripts\python.exe app.py` |
204
+ | モデル取得で WinError 1314 | HuggingFaceキャッシュのシンボリックリンクに管理者権限が要るのが原因。`pipeline.py` で実体コピーに切り替え済み。それでも出るなら `C:\Users\<ユーザー名>\.cache\huggingface` の該当モデルフォルダを消して再実行 |
app.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ローカルで動く文字起こしWebアプリ。
3
+
4
+ 起動:
5
+ python app.py
6
+ ブラウザ:
7
+ http://127.0.0.1:8000
8
+
9
+ 127.0.0.1 にのみ待ち受けるため、このPC以外からはアクセスできない。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import tempfile
17
+ import threading
18
+ import traceback
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
23
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
24
+
25
+ import pipeline as pl
26
+
27
+ APP_DIR = Path(__file__).parent
28
+ WORK_ROOT = Path(tempfile.gettempdir()) / "koe-okoshi"
29
+ WORK_ROOT.mkdir(parents=True, exist_ok=True)
30
+
31
+ app = FastAPI(title="録音文字起こし")
32
+ JOBS: dict[str, dict] = {}
33
+ LOCK = threading.Lock()
34
+
35
+
36
+ def update(job_id: str, **fields) -> None:
37
+ with LOCK:
38
+ if job_id in JOBS:
39
+ JOBS[job_id].update(fields)
40
+
41
+
42
+ def serialize(results: list[pl.FileResult]) -> list[dict]:
43
+ return [
44
+ {
45
+ "index": i,
46
+ "shortName": os.path.basename(r.original_name),
47
+ "name": r.original_name,
48
+ "duration": round(r.duration, 1),
49
+ "durationLabel": pl.hhmmss(r.duration),
50
+ "recordedAt": r.recorded_at,
51
+ "status": r.status,
52
+ "reason": r.reason,
53
+ "speakers": r.speakers,
54
+ "utterances": [
55
+ {
56
+ "start": pl.hhmmss(u.start),
57
+ "end": pl.hhmmss(u.end),
58
+ "speaker": u.speaker,
59
+ "text": u.text,
60
+ "needsReview": u.needs_review,
61
+ "reviewNote": u.review_note,
62
+ }
63
+ for u in r.utterances
64
+ ],
65
+ }
66
+ for i, r in enumerate(results, 1)
67
+ ]
68
+
69
+
70
+ def worker(job_id: str, zip_path: Path, options: dict) -> None:
71
+ workdir = WORK_ROOT / job_id
72
+ try:
73
+ def progress(message: str, done: int, total: int) -> None:
74
+ update(job_id, message=message, done=done, total=total)
75
+
76
+ results = pl.run(zip_path=zip_path, workdir=workdir, progress=progress, **options)
77
+
78
+ update(job_id, message="ファイルを書き出しています")
79
+ outputs = pl.build_outputs(results, workdir / "出力", job_id)
80
+
81
+ transcribed = sum(1 for r in results if r.status == "transcribed")
82
+ skipped = sum(1 for r in results if r.status == "skipped")
83
+ failed = sum(1 for r in results if r.status == "error")
84
+
85
+ update(
86
+ job_id,
87
+ state="done",
88
+ message="完了",
89
+ results=serialize(results),
90
+ summary={
91
+ "total": len(results),
92
+ "transcribed": transcribed,
93
+ "skipped": skipped,
94
+ "failed": failed,
95
+ "flagged": sum(
96
+ 1 for r in results for u in r.utterances if u.needs_review
97
+ ),
98
+ },
99
+ outputs=outputs,
100
+ hasXlsx=outputs.get("xlsx") is not None,
101
+ )
102
+ except Exception as exc:
103
+ traceback.print_exc()
104
+ update(job_id, state="error", message=str(exc))
105
+ finally:
106
+ shutil.rmtree(workdir / "audio", ignore_errors=True)
107
+ shutil.rmtree(workdir / "wav", ignore_errors=True)
108
+ zip_path.unlink(missing_ok=True)
109
+
110
+
111
+ @app.get("/", response_class=HTMLResponse)
112
+ def index() -> str:
113
+ return (APP_DIR / "index.html").read_text(encoding="utf-8")
114
+
115
+
116
+ @app.get("/environment")
117
+ def environment() -> JSONResponse:
118
+ return JSONResponse({
119
+ "appleSilicon": pl.is_apple_silicon(),
120
+ "ffmpeg": pl.tool_path("ffmpeg") is not None and pl.tool_path("ffprobe") is not None,
121
+ "ffmpegHint": "winget install Gyan.FFmpeg" if pl.IS_WINDOWS else "brew install ffmpeg",
122
+ "gpu": pl.gpu_label(),
123
+ "defaultModel": pl.default_model_size(),
124
+ "tokenFromEnv": bool(os.environ.get("HF_TOKEN")),
125
+ "defaultPrompt": pl.DEFAULT_PROMPT,
126
+ })
127
+
128
+
129
+ @app.post("/jobs")
130
+ async def create_job(
131
+ file: UploadFile = File(...),
132
+ minSeconds: float = Form(30.0),
133
+ modelSize: str = Form(""),
134
+ numSpeakers: int = Form(0),
135
+ diarization: bool = Form(True),
136
+ prompt: str = Form(pl.DEFAULT_PROMPT),
137
+ hfToken: str = Form(""),
138
+ ) -> JSONResponse:
139
+ if not file.filename.lower().endswith(".zip"):
140
+ raise HTTPException(400, "ZIPファイルを選んでください。")
141
+
142
+ token = hfToken.strip() or os.environ.get("HF_TOKEN", "")
143
+ if diarization and not token:
144
+ raise HTTPException(
145
+ 400,
146
+ "話者分離にはHuggingFaceのアクセストークンが必要です。"
147
+ "トークンを入力するか、話者分離をオフにしてください。",
148
+ )
149
+
150
+ job_id = uuid.uuid4().hex[:12]
151
+ workdir = WORK_ROOT / job_id
152
+ workdir.mkdir(parents=True, exist_ok=True)
153
+
154
+ zip_path = workdir / "upload.zip"
155
+ with open(zip_path, "wb") as out:
156
+ shutil.copyfileobj(file.file, out)
157
+
158
+ with LOCK:
159
+ JOBS[job_id] = {
160
+ "state": "running",
161
+ "message": "準備しています",
162
+ "done": 0,
163
+ "total": 0,
164
+ "results": [],
165
+ "summary": None,
166
+ "csv": None,
167
+ }
168
+
169
+ options = {
170
+ "min_seconds": max(0.0, minSeconds),
171
+ "model_size": modelSize or pl.default_model_size(),
172
+ "num_speakers": numSpeakers if numSpeakers > 0 else None,
173
+ "diarization_enabled": diarization,
174
+ "prompt": prompt,
175
+ "hf_token": token,
176
+ }
177
+
178
+ threading.Thread(target=worker, args=(job_id, zip_path, options), daemon=True).start()
179
+ return JSONResponse({"jobId": job_id})
180
+
181
+
182
+ @app.get("/jobs/{job_id}")
183
+ def job_status(job_id: str) -> JSONResponse:
184
+ with LOCK:
185
+ job = JOBS.get(job_id)
186
+ if not job:
187
+ raise HTTPException(404, "処理が見つかりません。")
188
+ return JSONResponse({k: v for k, v in job.items() if k != "outputs"})
189
+
190
+
191
+ def _outputs(job_id: str) -> dict:
192
+ with LOCK:
193
+ job = JOBS.get(job_id)
194
+ if not job or not job.get("outputs"):
195
+ raise HTTPException(404, "出力がまだありません。")
196
+ return job["outputs"]
197
+
198
+
199
+ @app.get("/jobs/{job_id}/bundle")
200
+ def download_bundle(job_id: str) -> FileResponse:
201
+ out = _outputs(job_id)
202
+ return FileResponse(
203
+ out["bundle"], media_type="application/zip", filename=f"文字起こし_{job_id}.zip"
204
+ )
205
+
206
+
207
+ @app.get("/jobs/{job_id}/xlsx")
208
+ def download_xlsx(job_id: str) -> FileResponse:
209
+ out = _outputs(job_id)
210
+ if not out.get("xlsx"):
211
+ raise HTTPException(404, "Excelを作成できませんでした。openpyxl を導入してください。")
212
+ return FileResponse(
213
+ out["xlsx"],
214
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
215
+ filename=f"録音ごと_{job_id}.xlsx",
216
+ )
217
+
218
+
219
+ @app.get("/jobs/{job_id}/all-csv")
220
+ def download_all_csv(job_id: str) -> FileResponse:
221
+ out = _outputs(job_id)
222
+ return FileResponse(
223
+ out["all_csv"], media_type="text/csv", filename=f"全発話_{job_id}.csv"
224
+ )
225
+
226
+
227
+ @app.get("/jobs/{job_id}/files/{index}.{kind}")
228
+ def download_one(job_id: str, index: int, kind: str) -> FileResponse:
229
+ out = _outputs(job_id)
230
+ record = next((r for r in out["per_file"] if r["index"] == index), None)
231
+ if not record:
232
+ raise HTTPException(404, "その録音が見つかりません。")
233
+ if kind == "txt":
234
+ return FileResponse(
235
+ record["txt"], media_type="text/plain; charset=utf-8",
236
+ filename=f"{record['stem']}.txt",
237
+ )
238
+ if kind == "csv" and record.get("csv"):
239
+ return FileResponse(
240
+ record["csv"], media_type="text/csv", filename=f"{record['stem']}.csv"
241
+ )
242
+ raise HTTPException(404, "その形式は用意されていません。")
243
+
244
+
245
+ if __name__ == "__main__":
246
+ import uvicorn
247
+
248
+ uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8000")))
index.html ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>録音文字起こし</title>
7
+ <style>
8
+ :root {
9
+ --paper: #e7eaec;
10
+ --card: #fbfbfc;
11
+ --ink: #171b20;
12
+ --ink-soft: #5a636b;
13
+ --ink-faint: #8b939a;
14
+ --rule: #ccd3d8;
15
+ --accent: #1f5f5b;
16
+ --accent-soft: #d9e5e3;
17
+ --warn: #8a5512;
18
+ --warn-soft: #f2e6d2;
19
+ --sp-0: #1f5f5b;
20
+ --sp-1: #9a4f22;
21
+ --sp-2: #4b4a86;
22
+ --sp-3: #8c2f4d;
23
+ --sp-4: #40682a;
24
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
25
+ --sans: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Yu Gothic Medium",
26
+ "Noto Sans JP", sans-serif;
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+
31
+ body {
32
+ margin: 0;
33
+ background: var(--paper);
34
+ color: var(--ink);
35
+ font-family: var(--sans);
36
+ font-size: 15px;
37
+ line-height: 1.7;
38
+ -webkit-font-smoothing: antialiased;
39
+ }
40
+
41
+ .wrap { max-width: 940px; margin: 0 auto; padding: 40px 20px 96px; }
42
+
43
+ /* ---------- header ---------- */
44
+ .masthead { border-bottom: 2px solid var(--ink); padding-bottom: 16px; margin-bottom: 32px; }
45
+ .masthead h1 {
46
+ margin: 0;
47
+ font-size: 27px;
48
+ font-weight: 700;
49
+ letter-spacing: 0.06em;
50
+ }
51
+ .masthead p { margin: 6px 0 0; color: var(--ink-soft); font-size: 13.5px; }
52
+
53
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
54
+ .chip {
55
+ font-family: var(--mono);
56
+ font-size: 11px;
57
+ letter-spacing: 0.04em;
58
+ padding: 3px 9px;
59
+ border: 1px solid var(--rule);
60
+ border-radius: 2px;
61
+ background: var(--card);
62
+ color: var(--ink-soft);
63
+ }
64
+ .chip.ok { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
65
+ .chip.bad { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
66
+
67
+ /* ---------- panels ---------- */
68
+ .panel {
69
+ background: var(--card);
70
+ border: 1px solid var(--rule);
71
+ border-radius: 3px;
72
+ padding: 22px;
73
+ margin-bottom: 20px;
74
+ }
75
+ .panel h2 {
76
+ margin: 0 0 18px;
77
+ font-size: 12px;
78
+ font-family: var(--mono);
79
+ letter-spacing: 0.14em;
80
+ text-transform: uppercase;
81
+ color: var(--ink-faint);
82
+ font-weight: 600;
83
+ }
84
+
85
+ /* ---------- drop zone ---------- */
86
+ .drop {
87
+ border: 1.5px dashed var(--rule);
88
+ border-radius: 3px;
89
+ padding: 30px 20px;
90
+ text-align: center;
91
+ cursor: pointer;
92
+ background: transparent;
93
+ transition: border-color .15s, background .15s;
94
+ display: block;
95
+ width: 100%;
96
+ font: inherit;
97
+ color: inherit;
98
+ }
99
+ .drop:hover, .drop.hot { border-color: var(--accent); background: var(--accent-soft); }
100
+ .drop:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
101
+ .drop strong { display: block; font-size: 16px; }
102
+ .drop span { color: var(--ink-soft); font-size: 13px; }
103
+ .drop .picked { font-family: var(--mono); font-size: 13px; color: var(--accent); word-break: break-all; }
104
+
105
+ /* ---------- form ---------- */
106
+ .grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 22px; }
107
+ .field { display: flex; flex-direction: column; gap: 5px; }
108
+ .field label { font-size: 12.5px; font-weight: 600; letter-spacing: .02em; }
109
+ .field .hint { font-size: 11.5px; color: var(--ink-faint); line-height: 1.5; }
110
+ .field input, .field select, .field textarea {
111
+ font: inherit;
112
+ font-size: 14px;
113
+ padding: 8px 10px;
114
+ border: 1px solid var(--rule);
115
+ border-radius: 2px;
116
+ background: #fff;
117
+ color: var(--ink);
118
+ width: 100%;
119
+ }
120
+ .field textarea { resize: vertical; min-height: 66px; line-height: 1.6; }
121
+ .field input:focus, .field select:focus, .field textarea:focus {
122
+ outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent);
123
+ }
124
+ .field.span2 { grid-column: 1 / -1; }
125
+ .check { display: flex; align-items: center; gap: 8px; }
126
+ .check input { width: auto; }
127
+
128
+ .actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; flex-wrap: wrap; }
129
+ button.go {
130
+ font: inherit; font-weight: 600; font-size: 14.5px;
131
+ background: var(--accent); color: #fff;
132
+ border: none; border-radius: 2px; padding: 11px 26px; cursor: pointer;
133
+ letter-spacing: .04em;
134
+ }
135
+ button.go:hover { background: #174a47; }
136
+ button.go:disabled { background: var(--ink-faint); cursor: not-allowed; }
137
+ button.go:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
138
+
139
+ a.dl {
140
+ font-family: var(--mono); font-size: 13px; letter-spacing: .03em;
141
+ color: var(--accent); border: 1px solid var(--accent);
142
+ padding: 9px 18px; border-radius: 2px; text-decoration: none;
143
+ }
144
+ a.dl:hover { background: var(--accent-soft); }
145
+
146
+ .error { color: var(--warn); font-size: 13px; }
147
+
148
+ /* ---------- progress ---------- */
149
+ .bar { height: 5px; background: var(--rule); border-radius: 3px; overflow: hidden; }
150
+ .bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .3s ease; }
151
+ .track { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 9px; font-size: 13px; }
152
+ .track .now { color: var(--ink-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
153
+ .track .count { font-family: var(--mono); color: var(--ink-faint); flex: none; }
154
+
155
+ /* ---------- summary ---------- */
156
+ .tally { display: flex; gap: 28px; flex-wrap: wrap; }
157
+ .tally div { display: flex; flex-direction: column; }
158
+ .tally b { font-family: var(--mono); font-size: 26px; font-weight: 600; line-height: 1.2; }
159
+ .tally span { font-size: 11.5px; color: var(--ink-faint); letter-spacing: .04em; }
160
+
161
+ /* ---------- per-recording cards ---------- */
162
+ .file {
163
+ border: 1px solid var(--rule);
164
+ border-radius: 3px;
165
+ margin-bottom: 12px;
166
+ background: #fff;
167
+ overflow: hidden;
168
+ }
169
+ .file[open] { border-color: var(--accent); }
170
+ .file > summary {
171
+ padding: 14px 16px;
172
+ cursor: pointer;
173
+ list-style: none;
174
+ display: block;
175
+ }
176
+ .file > summary::-webkit-details-marker { display: none; }
177
+ .file > summary:hover { background: var(--accent-soft); }
178
+ .file > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
179
+ .file[open] > summary { border-bottom: 1px solid var(--rule); background: var(--card); }
180
+ .file-body { padding: 4px 16px 16px; }
181
+
182
+ .no {
183
+ font-family: var(--mono); font-size: 11px; color: var(--ink-faint);
184
+ flex: none; padding-top: 2px;
185
+ }
186
+ .file-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
187
+ .file-name { font-size: 14.5px; font-weight: 600; word-break: break-all; flex: 1 1 200px; }
188
+ .file-meta { font-family: var(--mono); font-size: 11.5px; color: var(--ink-faint); flex: none; }
189
+ .preview {
190
+ margin: 6px 0 0; font-size: 13px; color: var(--ink-soft);
191
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
192
+ }
193
+ .file-tools { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 4px; }
194
+ .file-tools a, .file-tools button {
195
+ font-family: var(--mono); font-size: 11.5px; letter-spacing: .03em;
196
+ color: var(--ink-soft); background: transparent;
197
+ border: 1px solid var(--rule); border-radius: 2px;
198
+ padding: 5px 11px; text-decoration: none; cursor: pointer;
199
+ }
200
+ .file-tools a:hover, .file-tools button:hover { border-color: var(--accent); color: var(--accent); }
201
+ .len { position: relative; height: 6px; background: var(--rule); border-radius: 2px; margin-top: 9px; }
202
+ .len i { display: block; height: 100%; border-radius: 2px; background: var(--accent); }
203
+ .len.cut i { background: var(--ink-faint); opacity: .5; }
204
+ .len .gate { position: absolute; top: -3px; bottom: -3px; width: 1px; background: var(--warn); }
205
+ .tag {
206
+ font-family: var(--mono); font-size: 10.5px; letter-spacing: .05em;
207
+ padding: 2px 7px; border-radius: 2px; flex: none;
208
+ }
209
+ .tag.cut { background: var(--rule); color: var(--ink-soft); }
210
+ .tag.err { background: var(--warn-soft); color: var(--warn); }
211
+
212
+ /* ---------- transcript score ---------- */
213
+ .score { margin-top: 14px; }
214
+ .line { display: flex; gap: 12px; padding: 7px 0; border-top: 1px dotted var(--rule); }
215
+ .line:first-child { border-top: none; }
216
+ .time { font-family: var(--mono); font-size: 11.5px; color: var(--ink-faint); flex: none; width: 60px; padding-top: 3px; }
217
+ .spine { width: 3px; border-radius: 2px; flex: none; background: var(--ink-faint); }
218
+ .said { flex: 1; min-width: 0; }
219
+ .who {
220
+ font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em;
221
+ text-transform: uppercase; color: var(--ink-soft); display: block;
222
+ }
223
+ .what { font-size: 14.5px; line-height: 1.75; word-break: break-word; }
224
+ .flag {
225
+ display: inline-block; font-family: var(--mono); font-size: 10px;
226
+ color: var(--warn); background: var(--warn-soft);
227
+ padding: 1px 6px; border-radius: 2px; margin-left: 6px; vertical-align: 1px;
228
+ }
229
+ .sp0 { background: var(--sp-0); } .sp1 { background: var(--sp-1); }
230
+ .sp2 { background: var(--sp-2); } .sp3 { background: var(--sp-3); }
231
+ .sp4 { background: var(--sp-4); }
232
+
233
+ .empty { color: var(--ink-faint); font-size: 13px; }
234
+ [hidden] { display: none !important; }
235
+
236
+ @media (max-width: 620px) {
237
+ .wrap { padding: 24px 14px 60px; }
238
+ .grid { grid-template-columns: 1fr; }
239
+ .time { width: 52px; }
240
+ }
241
+ @media (prefers-reduced-motion: reduce) {
242
+ * { transition: none !important; }
243
+ }
244
+ </style>
245
+ </head>
246
+ <body>
247
+ <div class="wrap">
248
+
249
+ <header class="masthead">
250
+ <h1>録音文字起こし</h1>
251
+ <p>ZIPの中から一定の長さ以上の録音だけを選び、話者ごとに書き起こしてCSVにします。音声はこのPCの外に出ません。</p>
252
+ <div class="chips" id="chips"></div>
253
+ </header>
254
+
255
+ <section class="panel" id="setup">
256
+ <h2>音声を読み込む</h2>
257
+
258
+ <input type="file" id="picker" accept=".zip" hidden>
259
+ <button type="button" class="drop" id="drop">
260
+ <strong id="dropTitle">ZIPファイルを選ぶ</strong>
261
+ <span id="dropHint">ここにドラッグし��も読み込めます</span>
262
+ </button>
263
+
264
+ <div class="grid">
265
+ <div class="field">
266
+ <label for="minSeconds">この長さ以上だけ処理する</label>
267
+ <input type="number" id="minSeconds" value="30" min="0" step="1">
268
+ <span class="hint">秒。短い録音は文字起こしせずに一覧だけ残します。</span>
269
+ </div>
270
+
271
+ <div class="field">
272
+ <label for="modelSize">精度</label>
273
+ <select id="modelSize">
274
+ <option value="large-v3-turbo">標準(turbo・速くて実用精度)</option>
275
+ <option value="large-v3">最優先(large-v3・いちばん正確/数倍遅い)</option>
276
+ <option value="medium">軽量(medium)</option>
277
+ </select>
278
+ <span class="hint" id="modelHint">初回だけモデルの取得に数分かかります。</span>
279
+ </div>
280
+
281
+ <div class="field">
282
+ <label for="numSpeakers">話者の人数</label>
283
+ <input type="number" id="numSpeakers" value="2" min="0" max="10" step="1">
284
+ <span class="hint">分かっているなら指定するほど正確になります。0で自動判定。</span>
285
+ </div>
286
+
287
+ <div class="field">
288
+ <label for="hfToken">HuggingFaceトークン</label>
289
+ <input type="password" id="hfToken" placeholder="hf_..." autocomplete="off">
290
+ <span class="hint">話者分離に必要です。環境変数 HF_TOKEN があれば空欄で構いません。</span>
291
+ </div>
292
+
293
+ <div class="field span2 check">
294
+ <input type="checkbox" id="diarization" checked>
295
+ <label for="diarization">話者を分けて記録する</label>
296
+ </div>
297
+
298
+ <div class="field span2">
299
+ <label for="prompt">よく出る言葉</label>
300
+ <textarea id="prompt"></textarea>
301
+ <span class="hint">業務でよく使う語を書いておくと、固有名詞や専門用語の変換精度が上がります。</span>
302
+ </div>
303
+ </div>
304
+
305
+ <div class="actions">
306
+ <button type="button" class="go" id="start" disabled>文字起こしを始める</button>
307
+ <span class="error" id="formError"></span>
308
+ </div>
309
+ </section>
310
+
311
+ <section class="panel" id="progress" hidden>
312
+ <h2>処理中</h2>
313
+ <div class="track">
314
+ <span class="now" id="progressNow">準備しています</span>
315
+ <span class="count" id="progressCount"></span>
316
+ </div>
317
+ <div class="bar"><i id="progressBar"></i></div>
318
+ </section>
319
+
320
+ <section class="panel" id="summary" hidden>
321
+ <h2>結果</h2>
322
+ <div class="tally" id="tally"></div>
323
+ <div class="actions" id="downloads"></div>
324
+ <p class="hint" id="bundleNote"></p>
325
+ </section>
326
+
327
+ <section class="panel" id="output" hidden>
328
+ <h2>書き起こし</h2>
329
+ <div id="files"></div>
330
+ </section>
331
+
332
+ </div>
333
+
334
+ <script>
335
+ const $ = (id) => document.getElementById(id);
336
+ let chosenFile = null;
337
+ let poller = null;
338
+
339
+ /* ---------- environment ---------- */
340
+ fetch("/environment").then(r => r.json()).then(env => {
341
+ $("prompt").value = env.defaultPrompt || "";
342
+ if (env.defaultModel) $("modelSize").value = env.defaultModel;
343
+ if (!env.gpu) {
344
+ $("modelHint").textContent =
345
+ "初回だけモデルの取得に数分かかります。CPU処理なので、録音1時間あたり数十分かかります。";
346
+ }
347
+ const chips = [
348
+ env.ffmpeg
349
+ ? { text: "ffmpeg 利用可", cls: "ok" }
350
+ : { text: `ffmpeg 未検出 — ${env.ffmpegHint || ""}`, cls: "bad" },
351
+ env.gpu
352
+ ? { text: `${env.gpu} で高速処理`, cls: "ok" }
353
+ : { text: "CPU処理(時間がかかります)", cls: "" },
354
+ env.tokenFromEnv
355
+ ? { text: "HF_TOKEN 設定済み", cls: "ok" }
356
+ : { text: "HF_TOKEN 未設定", cls: "" },
357
+ ];
358
+ $("chips").innerHTML = chips
359
+ .map(c => `<span class="chip ${c.cls}">${c.text}</span>`)
360
+ .join("");
361
+ }).catch(() => {});
362
+
363
+ /* ---------- file selection ---------- */
364
+ const drop = $("drop");
365
+ drop.addEventListener("click", () => $("picker").click());
366
+ $("picker").addEventListener("change", (e) => setFile(e.target.files[0]));
367
+
368
+ ["dragenter", "dragover"].forEach(ev =>
369
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("hot"); }));
370
+ ["dragleave", "drop"].forEach(ev =>
371
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("hot"); }));
372
+ drop.addEventListener("drop", (e) => setFile(e.dataTransfer.files[0]));
373
+
374
+ function setFile(file) {
375
+ if (!file) return;
376
+ if (!file.name.toLowerCase().endsWith(".zip")) {
377
+ $("formError").textContent = "ZIPファイルを選んでください。";
378
+ return;
379
+ }
380
+ chosenFile = file;
381
+ $("formError").textContent = "";
382
+ $("dropTitle").textContent = file.name;
383
+ $("dropHint").className = "picked";
384
+ $("dropHint").textContent = (file.size / 1048576).toFixed(1) + " MB — 別のファイルに変えるにはもう一度クリック";
385
+ $("start").disabled = false;
386
+ }
387
+
388
+ /* ---------- start ---------- */
389
+ $("start").addEventListener("click", async () => {
390
+ if (!chosenFile) return;
391
+ $("formError").textContent = "";
392
+ $("start").disabled = true;
393
+ $("start").textContent = "アップロード中";
394
+
395
+ const body = new FormData();
396
+ body.append("file", chosenFile);
397
+ body.append("minSeconds", $("minSeconds").value || "30");
398
+ body.append("modelSize", $("modelSize").value);
399
+ body.append("numSpeakers", $("numSpeakers").value || "0");
400
+ body.append("diarization", $("diarization").checked ? "true" : "false");
401
+ body.append("prompt", $("prompt").value);
402
+ body.append("hfToken", $("hfToken").value);
403
+
404
+ try {
405
+ const res = await fetch("/jobs", { method: "POST", body });
406
+ const data = await res.json();
407
+ if (!res.ok) throw new Error(data.detail || "処理を開始できませんでした。");
408
+
409
+ $("progress").hidden = false;
410
+ $("summary").hidden = true;
411
+ $("output").hidden = true;
412
+ $("start").textContent = "処理中";
413
+ poller = setInterval(() => check(data.jobId), 1200);
414
+ check(data.jobId);
415
+ } catch (err) {
416
+ $("formError").textContent = err.message;
417
+ $("start").disabled = false;
418
+ $("start").textContent = "文字起こしを始める";
419
+ }
420
+ });
421
+
422
+ /* ---------- polling ---------- */
423
+ async function check(jobId) {
424
+ const res = await fetch("/jobs/" + jobId);
425
+ if (!res.ok) return;
426
+ const job = await res.json();
427
+
428
+ $("progressNow").textContent = job.message || "";
429
+ $("progressCount").textContent = job.total ? `${job.done} / ${job.total}` : "";
430
+ $("progressBar").style.width = job.total ? (job.done / job.total * 100) + "%" : "4%";
431
+
432
+ if (job.state === "running") return;
433
+ clearInterval(poller);
434
+ $("start").disabled = false;
435
+ $("start").textContent = "文字起こしを始める";
436
+
437
+ if (job.state === "error") {
438
+ $("progressNow").textContent = job.message;
439
+ $("progressBar").style.background = "var(--warn)";
440
+ return;
441
+ }
442
+
443
+ $("progress").hidden = true;
444
+ render(job, jobId);
445
+ }
446
+
447
+ /* ---------- render ---------- */
448
+ let currentJob = null;
449
+
450
+ function render(job, jobId) {
451
+ currentJob = job;
452
+ const s = job.summary || {};
453
+ $("tally").innerHTML = [
454
+ ["書き起こし", s.transcribed || 0],
455
+ ["長さ不足で除外", s.skipped || 0],
456
+ ["失敗", s.failed || 0],
457
+ ["要確認", s.flagged || 0],
458
+ ].map(([label, n]) => `<div><b>${n}</b><span>${label}</span></div>`).join("");
459
+
460
+ const links = [
461
+ [`/jobs/${jobId}/bundle`, "一式をZIPで保存"],
462
+ job.hasXlsx ? [`/jobs/${jobId}/xlsx`, "Excel(1録音=1シート)"] : null,
463
+ [`/jobs/${jobId}/all-csv`, "全発話をまとめたCSV"],
464
+ ].filter(Boolean);
465
+ $("downloads").innerHTML = links
466
+ .map(([href, label]) => `<a class="dl" href="${href}" download>${label}</a>`)
467
+ .join("");
468
+ $("bundleNote").textContent =
469
+ "ZIPの中身:書き起こし/(録音ごとのテキスト)、データ/(録音ごとのCSV)、"
470
+ + "処理結果一覧.csv、全発話.csv、録音ごと.xlsx";
471
+ $("summary").hidden = false;
472
+
473
+ const gate = parseFloat($("minSeconds").value || "30");
474
+ const longest = Math.max(1, ...job.results.map(r => r.duration));
475
+ const openByDefault = job.results.filter(r => r.status === "transcribed").length <= 3;
476
+
477
+ $("files").innerHTML = job.results
478
+ .map(r => card(r, jobId, gate, longest, openByDefault))
479
+ .join("");
480
+ $("output").hidden = false;
481
+ }
482
+
483
+ function card(r, jobId, gate, longest, openByDefault) {
484
+ const width = Math.min(100, r.duration / longest * 100);
485
+ const gatePos = Math.min(100, gate / longest * 100);
486
+ const done = r.status === "transcribed";
487
+
488
+ let tag = "";
489
+ if (r.status === "skipped") tag = `<span class="tag cut">${esc(r.reason)}</span>`;
490
+ if (r.status === "error") tag = `<span class="tag err">失敗</span>`;
491
+
492
+ const flagged = r.utterances.filter(u => u.needsReview).length;
493
+ const meta = [
494
+ r.durationLabel,
495
+ r.recordedAt,
496
+ done ? `${r.utterances.length}発話` : null,
497
+ done && r.speakers.length ? `話者${r.speakers.length}人` : null,
498
+ flagged ? `要確認${flagged}` : null,
499
+ ].filter(Boolean).join(" · ");
500
+
501
+ const preview = done && r.utterances.length
502
+ ? `<p class="preview">${esc(r.utterances[0].text)}</p>`
503
+ : "";
504
+
505
+ /* 話者の色は録音ごとに割り当てる。別の録音の SPEAKER_00 は別人なので、
506
+ 色を共有すると同一人物に見えてしまう。 */
507
+ const palette = {};
508
+ let next = 0;
509
+ let score = "";
510
+
511
+ if (done) {
512
+ score = r.utterances.length
513
+ ? `<div class="score">${r.utterances.map(u => {
514
+ if (!(u.speaker in palette)) palette[u.speaker] = next++ % 5;
515
+ const flag = u.needsReview
516
+ ? `<span class="flag" title="${esc(u.reviewNote)}">要確認</span>` : "";
517
+ return `<div class="line">
518
+ <span class="time">${u.start}</span>
519
+ <span class="spine sp${palette[u.speaker]}"></span>
520
+ <span class="said">
521
+ <span class="who">${esc(u.speaker)}</span>
522
+ <span class="what">${esc(u.text)}${flag}</span>
523
+ </span>
524
+ </div>`;
525
+ }).join("")}</div>`
526
+ : `<p class="empty">��話を検出できませんでした。</p>`;
527
+ } else {
528
+ score = `<p class="empty">${esc(r.reason || "書き起こしはありません。")}</p>`;
529
+ }
530
+
531
+ const tools = `<div class="file-tools">
532
+ <button type="button" onclick="copyOne(${r.index}, this)">本文をコピー</button>
533
+ <a href="/jobs/${jobId}/files/${r.index}.txt" download>テキスト</a>
534
+ ${done && r.utterances.length ? `<a href="/jobs/${jobId}/files/${r.index}.csv" download>CSV</a>` : ""}
535
+ </div>`;
536
+
537
+ return `<details class="file" ${done && openByDefault ? "open" : ""}>
538
+ <summary>
539
+ <div class="file-head">
540
+ <span class="no">${String(r.index).padStart(3, "0")}</span>
541
+ <span class="file-name">${esc(r.shortName)}</span>
542
+ ${tag}
543
+ <span class="file-meta">${esc(meta)}</span>
544
+ </div>
545
+ <div class="len ${done ? "" : "cut"}">
546
+ <i style="width:${width}%"></i>
547
+ <span class="gate" style="left:${gatePos}%" title="${gate}秒"></span>
548
+ </div>
549
+ ${preview}
550
+ </summary>
551
+ <div class="file-body">${tools}${score}</div>
552
+ </details>`;
553
+ }
554
+
555
+ function copyOne(index, btn) {
556
+ const r = (currentJob?.results || []).find(x => x.index === index);
557
+ if (!r) return;
558
+ const head = `${r.shortName}\n録音日時 ${r.recordedAt || "不明"} / 録音長 ${r.durationLabel}\n\n`;
559
+ const body = r.utterances
560
+ .map(u => `${u.start}${u.speaker ? " " + u.speaker : ""}${u.needsReview ? " ※要確認" : ""}\n ${u.text}`)
561
+ .join("\n\n");
562
+ navigator.clipboard.writeText(head + body).then(() => {
563
+ const original = btn.textContent;
564
+ btn.textContent = "コピーしました";
565
+ setTimeout(() => { btn.textContent = original; }, 1600);
566
+ });
567
+ }
568
+
569
+ function esc(str) {
570
+ return String(str ?? "").replace(/[&<>"']/g, c =>
571
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
572
+ }
573
+ </script>
574
+ </body>
575
+ </html>
pipeline.py ADDED
@@ -0,0 +1,747 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 録音ZIP → 30秒以上のファイルだけ文字起こし+話者分離 → CSV
3
+
4
+ すべてローカルで完結する。音声も文字起こし結果も外部に送信しない。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import os
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import zipfile
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Callable, Iterable
19
+
20
+ # HuggingFace のキャッシュは既定でシンボリックリンクを張るが、Windows では
21
+ # 管理者権限か開発者モードが無いと WinError 1314 で落ちる。実体コピーに切り替える。
22
+ # (torch / faster_whisper より先に効かせる必要があるので、ここで設定する)
23
+ if platform.system() == "Windows":
24
+ os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
25
+ os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
26
+
27
+
28
+ AUDIO_EXT = {
29
+ ".wav", ".mp3", ".m4a", ".mp4", ".aac", ".flac",
30
+ ".ogg", ".opus", ".wma", ".amr", ".3gp", ".mov", ".aif", ".aiff",
31
+ }
32
+
33
+ # Whisperが無音区間で作り出しやすい定型句。削除はせず「要確認」フラグを立てる。
34
+ HALLUCINATION_PATTERNS = [
35
+ "ご視聴ありがとうございました",
36
+ "ご覧いただきありがとうございます",
37
+ "最後までご視聴",
38
+ "チャンネル登録",
39
+ "高評価",
40
+ "字幕",
41
+ "Thank you for watching",
42
+ "Thanks for watching",
43
+ "Subscribe",
44
+ ]
45
+
46
+ MLX_MODELS = {
47
+ "large-v3": "mlx-community/whisper-large-v3-mlx",
48
+ "large-v3-turbo": "mlx-community/whisper-large-v3-turbo",
49
+ "medium": "mlx-community/whisper-medium-mlx",
50
+ }
51
+
52
+ DEFAULT_PROMPT = (
53
+ "訪問介護の記録です。バイタル、血圧、体温、脈拍、服薬、排泄、入浴介助、"
54
+ "デイサービス、ケアマネ、居宅介護支援、モニタリング、ADL、清拭、"
55
+ "利用者様、ご家族、といった言葉が出てきます。"
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------- utilities
60
+
61
+
62
+ IS_WINDOWS = platform.system() == "Windows"
63
+
64
+ # 変換のたびに黒いコンソール窓が開かないようにする(Windowsのみ)
65
+ _NO_WINDOW = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WINDOWS else {}
66
+
67
+ _tool_cache: dict[str, str] = {}
68
+
69
+
70
+ def is_apple_silicon() -> bool:
71
+ return platform.system() == "Darwin" and platform.machine() == "arm64"
72
+
73
+
74
+ def _ffmpeg_candidates(name: str) -> Iterable[Path]:
75
+ """PATH に無いときに ffmpeg / ffprobe を探す場所。"""
76
+ exe = f"{name}.exe" if IS_WINDOWS else name
77
+
78
+ yield Path(__file__).parent / "tools" / "ffmpeg" / "bin" / exe
79
+
80
+ local = os.environ.get("LOCALAPPDATA")
81
+ if local:
82
+ # winget で入れた直後は PATH に載っていないことがあるので実体も見に行く
83
+ yield Path(local) / "Microsoft" / "WinGet" / "Links" / exe
84
+ packages = Path(local) / "Microsoft" / "WinGet" / "Packages"
85
+ if packages.is_dir():
86
+ yield from packages.glob(f"*FFmpeg*/**/bin/{exe}")
87
+
88
+
89
+ def tool_path(name: str) -> str | None:
90
+ if name in _tool_cache:
91
+ return _tool_cache[name]
92
+ found = shutil.which(name)
93
+ if not found:
94
+ for candidate in _ffmpeg_candidates(name):
95
+ if candidate.is_file():
96
+ found = str(candidate)
97
+ break
98
+ if found:
99
+ _tool_cache[name] = found
100
+ return found
101
+
102
+
103
+ def ffmpeg_bin() -> str:
104
+ return tool_path("ffmpeg") or "ffmpeg"
105
+
106
+
107
+ def ffprobe_bin() -> str:
108
+ return tool_path("ffprobe") or "ffprobe"
109
+
110
+
111
+ def ensure_ffmpeg() -> None:
112
+ if tool_path("ffmpeg") and tool_path("ffprobe"):
113
+ return
114
+ how = (
115
+ "PowerShell で `winget install Gyan.FFmpeg` を実行し、開き直してください。"
116
+ if IS_WINDOWS
117
+ else "ターミナルで `brew install ffmpeg` を実行してください。"
118
+ )
119
+ raise RuntimeError(f"ffmpeg が見つかりません。{how}")
120
+
121
+
122
+ def gpu_label() -> str:
123
+ """使える計算資源の名前。画面表示と既定モデルの判断に使う。"""
124
+ if is_apple_silicon():
125
+ return "Apple Silicon"
126
+ try:
127
+ import torch
128
+
129
+ if torch.cuda.is_available():
130
+ return torch.cuda.get_device_name(0)
131
+ except Exception:
132
+ pass
133
+ return ""
134
+
135
+
136
+ def default_model_size() -> str:
137
+ """CPUだけの機械で large-v3 を既定にすると実用にならないので turbo を既定にする。"""
138
+ return "large-v3" if gpu_label() else "large-v3-turbo"
139
+
140
+
141
+ def hhmmss(seconds: float) -> str:
142
+ seconds = max(0.0, float(seconds))
143
+ h, rem = divmod(int(seconds), 3600)
144
+ m, s = divmod(rem, 60)
145
+ return f"{h:02d}:{m:02d}:{s:02d}"
146
+
147
+
148
+ def decode_zip_name(info: zipfile.ZipInfo) -> str:
149
+ """Windows製ZIPの日本語ファイル名(CP932)を復元する。
150
+
151
+ zipfile は UTF-8 フラグが立っていない名前を CP437 として読むため、
152
+ そのままだと日本語ファイル名が��字化けする。
153
+ """
154
+ if info.flag_bits & 0x800:
155
+ return info.filename
156
+ for enc in ("cp932", "utf-8"):
157
+ try:
158
+ return info.filename.encode("cp437").decode(enc)
159
+ except (UnicodeEncodeError, UnicodeDecodeError):
160
+ continue
161
+ return info.filename
162
+
163
+
164
+ # ---------------------------------------------------------------- data model
165
+
166
+
167
+ @dataclass
168
+ class Utterance:
169
+ start: float
170
+ end: float
171
+ speaker: str
172
+ text: str
173
+ needs_review: bool = False
174
+ review_note: str = ""
175
+
176
+
177
+ @dataclass
178
+ class FileResult:
179
+ original_name: str
180
+ duration: float
181
+ recorded_at: str = ""
182
+ status: str = "pending" # transcribed / skipped / error
183
+ reason: str = ""
184
+ utterances: list[Utterance] = field(default_factory=list)
185
+ speakers: list[str] = field(default_factory=list)
186
+
187
+
188
+ # ---------------------------------------------------------------- extraction
189
+
190
+
191
+ def extract_audio(zip_path: Path, dest: Path) -> list[tuple[str, Path, str]]:
192
+ """ZIPを展開し (元のパス, 展開先, 録音日時) を返す。"""
193
+ dest.mkdir(parents=True, exist_ok=True)
194
+ found: list[tuple[str, Path, str]] = []
195
+
196
+ with zipfile.ZipFile(zip_path) as zf:
197
+ for info in zf.infolist():
198
+ if info.is_dir():
199
+ continue
200
+ name = decode_zip_name(info)
201
+ base = os.path.basename(name)
202
+ if not base or base.startswith("._") or "__MACOSX" in name:
203
+ continue
204
+ if Path(base).suffix.lower() not in AUDIO_EXT:
205
+ continue
206
+
207
+ target = dest / base
208
+ counter = 1
209
+ while target.exists():
210
+ target = dest / f"{Path(base).stem}__{counter}{Path(base).suffix}"
211
+ counter += 1
212
+
213
+ with zf.open(info) as src, open(target, "wb") as out:
214
+ shutil.copyfileobj(src, out)
215
+
216
+ y, mo, d, h, mi, s = info.date_time
217
+ recorded_at = f"{y:04d}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}"
218
+ found.append((name, target, recorded_at))
219
+
220
+ found.sort(key=lambda item: item[0])
221
+ return found
222
+
223
+
224
+ def probe_duration(path: Path) -> float:
225
+ result = subprocess.run(
226
+ [
227
+ ffprobe_bin(), "-v", "error",
228
+ "-show_entries", "format=duration",
229
+ "-of", "default=noprint_wrappers=1:nokey=1",
230
+ str(path),
231
+ ],
232
+ capture_output=True,
233
+ text=True,
234
+ **_NO_WINDOW,
235
+ )
236
+ try:
237
+ return float(result.stdout.strip())
238
+ except ValueError:
239
+ return 0.0
240
+
241
+
242
+ def to_wav16k(src: Path, dst: Path) -> None:
243
+ """Whisperとpyannoteの両方が扱いやすい 16kHz モノラル WAV に変換する。"""
244
+ result = subprocess.run(
245
+ [ffmpeg_bin(), "-y", "-i", str(src), "-ac", "1", "-ar", "16000",
246
+ "-c:a", "pcm_s16le", str(dst)],
247
+ capture_output=True,
248
+ **_NO_WINDOW,
249
+ )
250
+ if result.returncode != 0:
251
+ tail = result.stderr.decode("utf-8", "replace").strip().splitlines()[-3:]
252
+ raise RuntimeError("音声を変換できませんでした: " + " / ".join(tail))
253
+
254
+
255
+ # ---------------------------------------------------------------- models
256
+
257
+
258
+ _whisper_cache: dict = {}
259
+ _diarizer_cache: dict = {}
260
+
261
+
262
+ def load_whisper(backend: str, model_size: str):
263
+ key = (backend, model_size)
264
+ if key in _whisper_cache:
265
+ return _whisper_cache[key]
266
+
267
+ if backend == "mlx":
268
+ import mlx_whisper # noqa: F401 読み込み確認のみ
269
+ obj = MLX_MODELS.get(model_size, MLX_MODELS["large-v3"])
270
+ else:
271
+ from faster_whisper import WhisperModel
272
+
273
+ device, compute_type = "cpu", "int8"
274
+ try:
275
+ import torch
276
+
277
+ if torch.cuda.is_available():
278
+ device, compute_type = "cuda", "float16"
279
+ except Exception:
280
+ pass
281
+ obj = WhisperModel(model_size, device=device, compute_type=compute_type)
282
+
283
+ _whisper_cache[key] = obj
284
+ return obj
285
+
286
+
287
+ def load_diarizer(hf_token: str):
288
+ if "pipeline" in _diarizer_cache:
289
+ return _diarizer_cache["pipeline"]
290
+
291
+ from pyannote.audio import Pipeline
292
+
293
+ pipe = Pipeline.from_pretrained(
294
+ "pyannote/speaker-diarization-3.1", use_auth_token=hf_token
295
+ )
296
+ if pipe is None:
297
+ raise RuntimeError(
298
+ "話者分離モデルを読み込めません。HuggingFaceで "
299
+ "pyannote/speaker-diarization-3.1 と pyannote/segmentation-3.0 の "
300
+ "利用条件に同意し、有効なトークンを設定してください。"
301
+ )
302
+
303
+ try: # GPU が使えれば使う(未対応の演算はCPUに落ちる)
304
+ import torch
305
+
306
+ if torch.cuda.is_available():
307
+ pipe.to(torch.device("cuda"))
308
+ elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
309
+ pipe.to(torch.device("mps"))
310
+ except Exception:
311
+ pass
312
+
313
+ _diarizer_cache["pipeline"] = pipe
314
+ return pipe
315
+
316
+
317
+ # ---------------------------------------------------------------- core steps
318
+
319
+
320
+ def transcribe(wav: Path, backend: str, model, prompt: str) -> list[dict]:
321
+ if backend == "mlx":
322
+ import mlx_whisper
323
+
324
+ result = mlx_whisper.transcribe(
325
+ str(wav),
326
+ path_or_hf_repo=model,
327
+ language="ja",
328
+ initial_prompt=prompt or None,
329
+ condition_on_previous_text=False,
330
+ no_speech_threshold=0.6,
331
+ compression_ratio_threshold=2.4,
332
+ verbose=None,
333
+ )
334
+ segments = result.get("segments", [])
335
+ return [
336
+ {"start": float(s["start"]), "end": float(s["end"]), "text": s["text"].strip()}
337
+ for s in segments
338
+ if s.get("text", "").strip()
339
+ ]
340
+
341
+ segments, _ = model.transcribe(
342
+ str(wav),
343
+ language="ja",
344
+ initial_prompt=prompt or None,
345
+ vad_filter=True,
346
+ condition_on_previous_text=False,
347
+ )
348
+ return [
349
+ {"start": float(s.start), "end": float(s.end), "text": s.text.strip()}
350
+ for s in segments
351
+ if s.text.strip()
352
+ ]
353
+
354
+
355
+ def diarize(wav: Path, hf_token: str, num_speakers: int | None) -> list[tuple[float, float, str]]:
356
+ pipe = load_diarizer(hf_token)
357
+ kwargs = {}
358
+ if num_speakers and num_speakers > 0:
359
+ kwargs["num_speakers"] = num_speakers
360
+ annotation = pipe(str(wav), **kwargs)
361
+ return [
362
+ (float(turn.start), float(turn.end), str(label))
363
+ for turn, _, label in annotation.itertracks(yield_label=True)
364
+ ]
365
+
366
+
367
+ def looks_like_hallucination(text: str) -> bool:
368
+ return any(p in text for p in HALLUCINATION_PATTERNS)
369
+
370
+
371
+ def merge(
372
+ segments: list[dict],
373
+ turns: list[tuple[float, float, str]],
374
+ speaker_names: dict[str, str] | None = None,
375
+ diarized: bool = True,
376
+ ) -> list[Utterance]:
377
+ """文字起こしの各セグメントに、最も重なりの長い話者を割り当てる。
378
+
379
+ 話者分離をオフにしたときは話者列を空にする。区間が無いことを
380
+ 「無音部の誤認識」と数えると全行に要確認が付き、印の意味が無くなるため。
381
+ """
382
+ speaker_names = speaker_names or {}
383
+ utterances: list[Utterance] = []
384
+
385
+ for seg in segments:
386
+ best_label, best_overlap = "", 0.0
387
+ for start, end, label in turns:
388
+ overlap = min(seg["end"], end) - max(seg["start"], start)
389
+ if overlap > best_overlap:
390
+ best_overlap, best_label = overlap, label
391
+
392
+ seg_len = max(0.01, seg["end"] - seg["start"])
393
+ coverage = best_overlap / seg_len
394
+
395
+ note = []
396
+ if diarized:
397
+ if not best_label:
398
+ note.append("発話区間と重ならない(無音部の誤認識の可能性)")
399
+ elif coverage < 0.35:
400
+ note.append("話者の重なりが少なく判定が不確実")
401
+ if looks_like_hallucination(seg["text"]):
402
+ note.append("Whisperの定型幻聴に一致")
403
+
404
+ display = speaker_names.get(best_label, best_label or "不明") if diarized else ""
405
+ utterances.append(
406
+ Utterance(
407
+ start=seg["start"],
408
+ end=seg["end"],
409
+ speaker=display,
410
+ text=seg["text"],
411
+ needs_review=bool(note),
412
+ review_note=" / ".join(note),
413
+ )
414
+ )
415
+
416
+ return utterances
417
+
418
+
419
+ # ---------------------------------------------------------------- orchestrator
420
+
421
+
422
+ def run(
423
+ zip_path: Path,
424
+ workdir: Path,
425
+ min_seconds: float = 30.0,
426
+ backend: str = "auto",
427
+ model_size: str = "",
428
+ hf_token: str = "",
429
+ num_speakers: int | None = None,
430
+ prompt: str = DEFAULT_PROMPT,
431
+ diarization_enabled: bool = True,
432
+ progress: Callable[[str, int, int], None] | None = None,
433
+ ) -> list[FileResult]:
434
+ ensure_ffmpeg()
435
+
436
+ if backend == "auto":
437
+ backend = "mlx" if is_apple_silicon() else "faster"
438
+ model_size = model_size or default_model_size()
439
+
440
+ audio_dir = workdir / "audio"
441
+ wav_dir = workdir / "wav"
442
+ wav_dir.mkdir(parents=True, exist_ok=True)
443
+
444
+ def notify(message: str, done: int, total: int) -> None:
445
+ if progress:
446
+ progress(message, done, total)
447
+
448
+ notify("ZIPを展開しています", 0, 1)
449
+ files = extract_audio(zip_path, audio_dir)
450
+ if not files:
451
+ raise RuntimeError("ZIPの中に音声ファイルが見つかりませんでした。")
452
+
453
+ # 長さで先に絞る。ffprobe はデコードしないので大量ファイルでも一瞬で終わる。
454
+ results: list[FileResult] = []
455
+ targets: list[tuple[FileResult, Path]] = []
456
+ for original, path, recorded_at in files:
457
+ duration = probe_duration(path)
458
+ entry = FileResult(original_name=original, duration=duration, recorded_at=recorded_at)
459
+ if duration < min_seconds:
460
+ entry.status = "skipped"
461
+ entry.reason = f"{min_seconds:.0f}秒未満"
462
+ else:
463
+ targets.append((entry, path))
464
+ results.append(entry)
465
+
466
+ total = len(targets)
467
+ if total == 0:
468
+ notify("条件に合う録音がありませんでした", 0, 0)
469
+ return results
470
+
471
+ notify("文字起こしモデルを読み込んでいます", 0, total)
472
+ model = load_whisper(backend, model_size)
473
+ if diarization_enabled:
474
+ notify("話者分離モデルを読み込んでいます", 0, total)
475
+ load_diarizer(hf_token)
476
+
477
+ for index, (entry, path) in enumerate(targets):
478
+ label = os.path.basename(entry.original_name)
479
+ try:
480
+ notify(f"変換中: {label}", index, total)
481
+ wav = wav_dir / f"{path.stem}.wav"
482
+ to_wav16k(path, wav)
483
+
484
+ notify(f"文字起こし中: {label}", index, total)
485
+ segments = transcribe(wav, backend, model, prompt)
486
+
487
+ turns: list[tuple[float, float, str]] = []
488
+ if diarization_enabled:
489
+ notify(f"話者を判定中: {label}", index, total)
490
+ turns = diarize(wav, hf_token, num_speakers)
491
+
492
+ entry.utterances = merge(segments, turns, diarized=diarization_enabled)
493
+ entry.speakers = sorted({u.speaker for u in entry.utterances if u.speaker})
494
+ entry.status = "transcribed"
495
+ wav.unlink(missing_ok=True)
496
+ except Exception as exc: # 1ファイルの失敗で全体を止めない
497
+ entry.status = "error"
498
+ entry.reason = str(exc)
499
+
500
+ notify(f"完了: {label}", index + 1, total)
501
+
502
+ return results
503
+
504
+
505
+ # ---------------------------------------------------------------- output
506
+ #
507
+ # 出力は「録音1本=1まとまり」を基本にする。
508
+ # 書き起こし/ … 人が読む用のテキスト(録音ごと)
509
+ # データ/ … システムに取り込む用のCSV(録音ごと)
510
+ # 処理結果一覧.csv … 何が処理され何が除外されたかの記録
511
+ # 全発話.csv … 横断で検索・集計したいとき用
512
+ # 録音ごと.xlsx … Excelで1録音=1シート
513
+
514
+ STATUS_LABEL = {"transcribed": "書き起こし済み", "skipped": "除外", "error": "失敗"}
515
+ UTTERANCE_HEADER = ["開始", "終了", "話者", "発話内容", "要確認", "備考"]
516
+ _INVALID_SHEET = re.compile(r"[\[\]:*?/\\]")
517
+ _INVALID_FILE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
518
+
519
+
520
+ def safe_stem(name: str, index: int) -> str:
521
+ base = _INVALID_FILE.sub("_", os.path.basename(name))
522
+ return f"{index:03d}_{Path(base).stem}"[:80]
523
+
524
+
525
+ def sheet_name(name: str, index: int, used: set[str]) -> str:
526
+ base = _INVALID_SHEET.sub("_", Path(os.path.basename(name)).stem) or "録音"
527
+ candidate = f"{index:02d}_{base}"[:31]
528
+ suffix = 1
529
+ while candidate in used:
530
+ candidate = f"{candidate[:28]}_{suffix}"
531
+ suffix += 1
532
+ used.add(candidate)
533
+ return candidate
534
+
535
+
536
+ def transcript_text(entry: FileResult) -> str:
537
+ """1録音分の、人がそのまま読める書き起こし。"""
538
+ rule = "─" * 52
539
+ flagged = sum(1 for u in entry.utterances if u.needs_review)
540
+
541
+ lines = [
542
+ rule,
543
+ os.path.basename(entry.original_name),
544
+ rule,
545
+ f"録音日時 {entry.recorded_at or '不明'}",
546
+ f"録音長 {hhmmss(entry.duration)}({entry.duration:.0f}秒)",
547
+ f"話者 {'、'.join(entry.speakers) if entry.speakers else '判定なし'}",
548
+ f"状態 {STATUS_LABEL.get(entry.status, entry.status)}"
549
+ + (f" — {entry.reason}" if entry.reason else ""),
550
+ "",
551
+ ]
552
+
553
+ if entry.status != "transcribed":
554
+ lines.append(entry.reason or "書き起こしはありません。")
555
+ return "\n".join(lines) + "\n"
556
+
557
+ if not entry.utterances:
558
+ lines.append("発話を検出できませんでした。")
559
+ return "\n".join(lines) + "\n"
560
+
561
+ for u in entry.utterances:
562
+ mark = f" ※要確認({u.review_note})" if u.needs_review else ""
563
+ who = f" {u.speaker}" if u.speaker else ""
564
+ lines.append(f"{hhmmss(u.start)}{who}{mark}")
565
+ lines.append(f" {u.text}")
566
+ lines.append("")
567
+
568
+ lines.append(rule)
569
+ lines.append(f"全 {len(entry.utterances)} 発話 / 要確認 {flagged} 件")
570
+ lines.append("※ 話者名は自動判定の仮ラベルです。担当者名への置き換えと内容の確認をしてください。")
571
+ return "\n".join(lines) + "\n"
572
+
573
+
574
+ def _write_rows(path: Path, header: list[str], rows: list[list]) -> Path:
575
+ # Excel(Mac版含む)で文字化けしないよう BOM 付き UTF-8 で書く
576
+ with open(path, "w", encoding="utf-8-sig", newline="") as f:
577
+ writer = csv.writer(f)
578
+ writer.writerow(header)
579
+ writer.writerows(rows)
580
+ return path
581
+
582
+
583
+ def write_file_csv(entry: FileResult, path: Path) -> Path:
584
+ rows = [
585
+ [
586
+ hhmmss(u.start), hhmmss(u.end), u.speaker, u.text,
587
+ "要確認" if u.needs_review else "", u.review_note,
588
+ ]
589
+ for u in entry.utterances
590
+ ]
591
+ return _write_rows(path, UTTERANCE_HEADER, rows)
592
+
593
+
594
+ def write_index_csv(results: Iterable[FileResult], path: Path) -> Path:
595
+ rows = []
596
+ for i, entry in enumerate(results, 1):
597
+ rows.append([
598
+ i,
599
+ entry.original_name,
600
+ entry.recorded_at,
601
+ hhmmss(entry.duration),
602
+ f"{entry.duration:.1f}",
603
+ STATUS_LABEL.get(entry.status, entry.status),
604
+ entry.reason,
605
+ len(entry.utterances),
606
+ sum(1 for u in entry.utterances if u.needs_review),
607
+ "、".join(entry.speakers),
608
+ ])
609
+ header = [
610
+ "No", "ファイル名", "録音日時", "録音長", "録音長(秒)",
611
+ "状態", "備考", "発話数", "要確認数", "話者",
612
+ ]
613
+ return _write_rows(path, header, rows)
614
+
615
+
616
+ def write_all_csv(results: Iterable[FileResult], path: Path) -> Path:
617
+ rows = []
618
+ for entry in results:
619
+ if entry.status != "transcribed":
620
+ continue
621
+ for u in entry.utterances:
622
+ rows.append([
623
+ entry.original_name, entry.recorded_at, f"{entry.duration:.1f}",
624
+ hhmmss(u.start), hhmmss(u.end), u.speaker, u.text,
625
+ "要確認" if u.needs_review else "", u.review_note,
626
+ ])
627
+ header = ["ファイル名", "録音日時", "録音長(秒)"] + UTTERANCE_HEADER
628
+ return _write_rows(path, header, rows)
629
+
630
+
631
+ def write_xlsx(results: list[FileResult], path: Path) -> Path | None:
632
+ """1録音=1シート。先頭に一覧シートを置く。openpyxl が無ければ何もしない。"""
633
+ try:
634
+ from openpyxl import Workbook
635
+ from openpyxl.styles import Alignment, Font, PatternFill
636
+ from openpyxl.utils import get_column_letter
637
+ except ImportError:
638
+ return None
639
+
640
+ head_font = Font(name="Arial", bold=True, color="FFFFFF")
641
+ head_fill = PatternFill("solid", fgColor="1F5F5B")
642
+ body_font = Font(name="Arial")
643
+ warn_font = Font(name="Arial", color="8A5512")
644
+ wrap = Alignment(vertical="top", wrap_text=True)
645
+ top = Alignment(vertical="top")
646
+
647
+ wb = Workbook()
648
+ overview = wb.active
649
+ overview.title = "一覧"
650
+
651
+ ov_header = ["No", "ファイル名", "録音日時", "録音長", "状態", "備考", "発話数", "要確認数", "話者"]
652
+ overview.append(ov_header)
653
+ for i, entry in enumerate(results, 1):
654
+ overview.append([
655
+ i, entry.original_name, entry.recorded_at, hhmmss(entry.duration),
656
+ STATUS_LABEL.get(entry.status, entry.status), entry.reason,
657
+ len(entry.utterances),
658
+ sum(1 for u in entry.utterances if u.needs_review),
659
+ "、".join(entry.speakers),
660
+ ])
661
+ for width, col in zip([5, 38, 20, 11, 15, 26, 9, 10, 22], range(1, 10)):
662
+ overview.column_dimensions[get_column_letter(col)].width = width
663
+ overview.freeze_panes = "A2"
664
+
665
+ used: set[str] = set()
666
+ for i, entry in enumerate(results, 1):
667
+ if entry.status != "transcribed":
668
+ continue
669
+ ws = wb.create_sheet(sheet_name(entry.original_name, i, used))
670
+
671
+ ws["A1"] = os.path.basename(entry.original_name)
672
+ ws["A1"].font = Font(name="Arial", bold=True, size=13)
673
+ ws["A2"] = f"録音日時 {entry.recorded_at or '不明'} / 録音長 {hhmmss(entry.duration)}"
674
+ ws["A3"] = f"話者 {'、'.join(entry.speakers) or '判定なし'}"
675
+ ws["A4"] = "※ 話者名は自動判定の仮ラベルです。要確認の行は内容を確認してください。"
676
+ for row in ("A2", "A3", "A4"):
677
+ ws[row].font = Font(name="Arial", size=10, color="5A636B")
678
+
679
+ ws.append([])
680
+ ws.append(UTTERANCE_HEADER)
681
+ for cell in ws[6]:
682
+ cell.font, cell.fill = head_font, head_fill
683
+
684
+ for u in entry.utterances:
685
+ ws.append([
686
+ hhmmss(u.start), hhmmss(u.end), u.speaker, u.text,
687
+ "要確認" if u.needs_review else "", u.review_note,
688
+ ])
689
+
690
+ for row in ws.iter_rows(min_row=7):
691
+ flagged = row[4].value == "要確認"
692
+ for cell in row:
693
+ cell.font = warn_font if flagged else body_font
694
+ cell.alignment = wrap if cell.column == 4 else top
695
+
696
+ for width, col in zip([10, 10, 15, 76, 10, 30], range(1, 7)):
697
+ ws.column_dimensions[get_column_letter(col)].width = width
698
+ ws.freeze_panes = "A7"
699
+
700
+ for cell in overview[1]:
701
+ cell.font, cell.fill = head_font, head_fill
702
+
703
+ wb.save(path)
704
+ return path
705
+
706
+
707
+ def build_outputs(results: list[FileResult], outdir: Path, job_id: str) -> dict[str, Path]:
708
+ """録音ごとのファイル群を作り、まとめてZIPにする。"""
709
+ outdir.mkdir(parents=True, exist_ok=True)
710
+ text_dir = outdir / "書き起こし"
711
+ data_dir = outdir / "データ"
712
+ text_dir.mkdir(exist_ok=True)
713
+ data_dir.mkdir(exist_ok=True)
714
+
715
+ per_file: list[dict] = []
716
+ for i, entry in enumerate(results, 1):
717
+ stem = safe_stem(entry.original_name, i)
718
+ txt = text_dir / f"{stem}.txt"
719
+ txt.write_text(transcript_text(entry), encoding="utf-8")
720
+ record = {"index": i, "stem": stem, "txt": txt, "csv": None}
721
+ if entry.status == "transcribed" and entry.utterances:
722
+ record["csv"] = write_file_csv(entry, data_dir / f"{stem}.csv")
723
+ per_file.append(record)
724
+
725
+ index_csv = write_index_csv(results, outdir / "処理結果一覧.csv")
726
+ all_csv = write_all_csv(results, outdir / "全発話.csv")
727
+ xlsx = write_xlsx(results, outdir / "録音ごと.xlsx")
728
+
729
+ bundle = outdir.parent / f"文字起こし_{job_id}.zip"
730
+ with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as zf:
731
+ root = f"文字起こし_{job_id}"
732
+ zf.write(index_csv, f"{root}/処理結果一覧.csv")
733
+ zf.write(all_csv, f"{root}/全発話.csv")
734
+ if xlsx:
735
+ zf.write(xlsx, f"{root}/録音ごと.xlsx")
736
+ for record in per_file:
737
+ zf.write(record["txt"], f"{root}/書き起こし/{record['stem']}.txt")
738
+ if record["csv"]:
739
+ zf.write(record["csv"], f"{root}/データ/{record['stem']}.csv")
740
+
741
+ return {
742
+ "bundle": bundle,
743
+ "index_csv": index_csv,
744
+ "all_csv": all_csv,
745
+ "xlsx": xlsx,
746
+ "per_file": per_file,
747
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ python-multipart>=0.0.9
4
+
5
+ # 文字起こし(Windows は CPU で faster-whisper を使う)
6
+ faster-whisper>=1.1
7
+
8
+ # 話者分離。torch / torchaudio も一緒に入る。
9
+ # torch は setup.ps1 が先に CPU 版を入れておくので、ここでは CUDA 版を引かない。
10
+ pyannote.audio>=3.1
11
+
12
+ openpyxl>=3.1
setup.ps1 ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 録音文字起こし(Windows)セットアップ
2
+ # 使い方: PowerShell で .\setup.ps1
3
+ $ErrorActionPreference = "Stop"
4
+ Set-Location $PSScriptRoot
5
+
6
+ if (-not (Test-Path ".venv")) {
7
+ Write-Host "[1/4] Python 仮想環境を作ります"
8
+ & "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe" -m venv .venv
9
+ }
10
+
11
+ $py = ".\.venv\Scripts\python.exe"
12
+
13
+ Write-Host "[2/4] pip を更新します"
14
+ & $py -m pip install --upgrade pip --quiet
15
+
16
+ Write-Host "[3/4] torch(CPU版) を入れます。数分かかります"
17
+ & $py -m pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
18
+
19
+ Write-Host "[4/4] 残りのライブラリを入れます。数分かかります"
20
+ & $py -m pip install -r requirements.txt
21
+
22
+ Write-Host ""
23
+ Write-Host "完了。起動は .\start.cmd" -ForegroundColor Green
start.cmd ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ chcp 65001 > nul
3
+ cd /d "%~dp0"
4
+
5
+ if not exist ".venv\Scripts\python.exe" (
6
+ echo セットアップがまだです。PowerShell で setup.ps1 を実行してください。
7
+ pause
8
+ exit /b 1
9
+ )
10
+
11
+ start "" http://127.0.0.1:8000
12
+ ".venv\Scripts\python.exe" app.py