ryota commited on
Commit
5b4386a
·
1 Parent(s): 6779a79

Gradio に作り直して ZeroGPU を使えるようにする

Browse files

ZeroGPU は Gradio SDK 専用で、Docker Space からは使えない。
PRO に付いているGPU枠を追加費用なしで使うため、画面を Gradio で組み直す。

- app.py を Gradio 版に置き換え(FastAPI + index.html は廃止)
- 文字起こしと話者分離を差し替え可能にし、ZeroGPU では GPU 付きの関数を渡す
- GPU は呼び出しのたびに別プロセスへ割り当てられるため、
cuda のときはモデルを使い回さず毎回作る
- faster-whisper が GPU を使うのに要る cuDNN / cuBLAS を先読みする
(site-packages にあるが動的リンカからは見えないため)
- 時刻クリックでの頭出し、設定の記憶、パスワード、自動削除は引き継いだ
- packages.txt で ffmpeg を入れる(Gradio SDK には Dockerfile が無いため)

実測(自分のPC・CPU): 51秒の音声2本を46.5秒で処理、出力4種とも生成、
再生用音声も Gradio 経由で配信できることを確認。GPU 側は Space で要確認。

Files changed (11) hide show
  1. .dockerignore +0 -6
  2. Dockerfile +0 -37
  3. README.md +27 -14
  4. app.py +354 -314
  5. index.html +0 -793
  6. packages.txt +1 -0
  7. pipeline.py +80 -23
  8. requirements.txt +6 -6
  9. tests/test_app.py +111 -0
  10. tests/test_diarization.py +3 -3
  11. tests/test_web.py +0 -80
.dockerignore DELETED
@@ -1,6 +0,0 @@
1
- .venv/
2
- __pycache__/
3
- *.pyc
4
- .git/
5
- .claude/
6
- 出力/
 
 
 
 
 
 
 
Dockerfile DELETED
@@ -1,37 +0,0 @@
1
- # HuggingFace Spaces / その他のコンテナ環境で動かすための定義。
2
- # 画面もモデルもこの1つの箱に入るので、どこに置いても同じ挙動になる。
3
- FROM python:3.12-slim
4
-
5
- # 音声の変換に ffmpeg(と長さを測る ffprobe)が要る
6
- RUN apt-get update \
7
- && apt-get install -y --no-install-recommends ffmpeg \
8
- && rm -rf /var/lib/apt/lists/*
9
-
10
- # Spaces は root で動かさない決まりなので、一般ユーザーを作る
11
- RUN useradd -m -u 1000 user
12
- USER user
13
- ENV HOME=/home/user \
14
- PATH=/home/user/.local/bin:$PATH \
15
- HF_HOME=/home/user/.cache/huggingface
16
-
17
- WORKDIR /home/user/app
18
-
19
- COPY --chown=user requirements.txt ./
20
- RUN pip install --no-cache-dir --upgrade pip \
21
- && pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu \
22
- && pip install --no-cache-dir -r requirements.txt
23
-
24
- # 文字起こしモデルを箱の中に焼いておく。
25
- # こうしないと、起動のたびに約1.6GBを取りに行って数分待たされる。
26
- RUN python -c "from faster_whisper import WhisperModel; WhisperModel('large-v3-turbo', device='cpu', compute_type='int8')"
27
-
28
- COPY --chown=user . ./
29
-
30
- # コンテナの外から届くようにする(自分のPCで動かすときは app.py の既定=127.0.0.1)
31
- ENV HOST=0.0.0.0 \
32
- PORT=7860
33
-
34
- ENV HOSTED=1
35
-
36
- EXPOSE 7860
37
- CMD ["python", "app.py"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -3,8 +3,9 @@ title: 商談文字起こし
3
  emoji: 🎙️
4
  colorFrom: gray
5
  colorTo: indigo
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: false
9
  short_description: 商談録音のZIPを文字起こしして話者ごとにCSVにする
10
  ---
@@ -15,9 +16,13 @@ short_description: 商談録音のZIPを文字起こしして話者ごとにCSV
15
 
16
  - 文字起こし: Whisper large-v3 系(faster-whisper。GPUがあれば自動で使う)
17
  - 話者分離: pyannote.audio 3.1(任意。使うにはHuggingFaceのトークンが要る)
18
- - 動く場所: HuggingFace Spaces(URLでどのPCからでも)/ 自分のPC(ローカル完結)
19
 
20
- 同じコードが両方で動く。は環境変数だけ。
 
 
 
 
21
 
22
  ---
23
 
@@ -27,7 +32,7 @@ short_description: 商談録音のZIPを文字起こしして話者ごとにCSV
27
 
28
  1. https://huggingface.co/new-space を開く
29
  2. **Space name** に `shodan-mojiokoshi` など。**Owner** は個人でもOrganizationでも可
30
- 3. **License** は任意。**Space SDK** は **Docker** → **Blank** を選ぶ
31
  4. **Public** を選ぶ(理由は下記)
32
  5. **Create Space**
33
 
@@ -54,7 +59,14 @@ Space の **Settings** → **Variables and secrets** → **New secret**
54
  `APP_PASSWORD` を設定しないと、**URLを知っている人は誰でも使えます**(その状態のときは画面に赤い警告が出ます)。
55
  Public にするなら、コードを送る前にここを設定しておく。
56
 
57
- ### 1-3. ードを
 
 
 
 
 
 
 
58
 
59
  Space のページに出ている git のURLを使って、このフォルダから送る。
60
 
@@ -83,9 +95,9 @@ Spaces の無料枠は 2 vCPU。**動くが、速くはない。**
83
 
84
  | ハードウェア | 料金 | 1時間の商談を文字起こしする時間 |
85
  |---|---|---|
86
- | CPU Basic無料・初期設定) | ¥0 | 1〜2時間 |
 
87
  | CPU Upgrade(8 vCPU) | $0.03/時 | 20〜30分 |
88
- | Nvidia T4 small | $0.40/時 | 3〜5分 |
89
 
90
  **話者分離は既定でオフにしてある。** オンにすると処理時間が倍以上になるため。
91
  どちらが話したかが要るときだけチェックを入れる。
@@ -268,10 +280,9 @@ ffmpeg が要る。PowerShell で `winget install Gyan.FFmpeg`。
268
 
269
  ```
270
  spinthoughts/
271
- ├── app.py Webサーバーアップロード・認証・進捗・出力
272
  ├── pipeline.py ZIP展開 → 長さフィルタ → 文字起こし → 話者分離 → 統合
273
- ├── index.html 画面
274
- ├── Dockerfile Spaces / コンテナ用
275
  ├── setup.ps1 自分のPCで動かすための初回セットアップ
276
  ├── start.cmd 自分のPCでの起動
277
  ├── requirements.txt
@@ -293,9 +304,9 @@ spinthoughts/
293
  | `APP_PASSWORD` | 空(=認証なし) | 設定すると閲覧にパスワードが要る |
294
  | `APP_USER` | `spin` | ログイン名 |
295
  | `HF_TOKEN` | 空 | 話者分離に使う |
296
- | `HOST` | `127.0.0.1` | コンテナでは `0.0.0.0` |
297
- | `PORT` | `8000` | Spaces では `7860` |
298
- | `HOSTED` | | 設定すると画面説明文が「サーバー処理」向けに変わる |
299
 
300
  ---
301
 
@@ -304,6 +315,8 @@ spinthoughts/
304
  | 症状 | 対処 |
305
  |---|---|
306
  | Space が Build error | ログの最後を見る。多くは requirements.txt の綴りかネットワーク。再ビルドで直ることもある |
 
 
307
  | Space が寝ている | 無料枠は48時間で寝る。開いて数分待つ |
308
  | パスワードを聞かれない | `APP_PASSWORD` が未設定。Settings → Secrets を確認 |
309
  | 話者分離モデルが読めない | HuggingFaceの2ページ両方で条件に同意したか、トークンが read 権限か確認 |
 
3
  emoji: 🎙️
4
  colorFrom: gray
5
  colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 6.25.0
8
+ app_file: app.py
9
  pinned: false
10
  short_description: 商談録音のZIPを文字起こしして話者ごとにCSVにする
11
  ---
 
16
 
17
  - 文字起こし: Whisper large-v3 系(faster-whisper。GPUがあれば自動で使う)
18
  - 話者分離: pyannote.audio 3.1(任意。使うにはHuggingFaceのトークンが要る)
19
+ - 動く場所: HuggingFace Spaces の **ZeroGPU**(URLでどのPCからでも)/ 自分のPC(CPUで完結)
20
 
21
+ 同じコードが両方で動く。GPUがあれば使、無ればCPUに落ちる
22
+
23
+ **ZeroGPU について。** PRO(月$9)に付いてくるGPU枠で、追加費用なしで使える。
24
+ 呼び出しのたびにGPUが割り当てられ、終わると解放される。
25
+ Gradio SDK 専用の仕組みなので、このアプリは Gradio で作ってある(Docker Space では使えない)。
26
 
27
  ---
28
 
 
32
 
33
  1. https://huggingface.co/new-space を開く
34
  2. **Space name** に `shodan-mojiokoshi` など。**Owner** は個人でもOrganizationでも可
35
+ 3. **License** は任意。**Space SDK** は **Gradio** を選ぶ(ZeroGPU は Gradio 専用)
36
  4. **Public** を選ぶ(理由は下記)
37
  5. **Create Space**
38
 
 
59
  `APP_PASSWORD` を設定しないと、**URLを知っている人は誰でも使えます**(その状態のときは画面に赤い警告が出ます)。
60
  Public にするなら、コードを送る前にここを設定しておく。
61
 
62
+ ### 1-3. ードウェア ZeroGPU にす
63
+
64
+ Space の **Settings** → **Space hardware** → **ZeroGPU** を選ぶ。
65
+ PRO を契約していれば一覧に出る。追加費用はかからない。
66
+
67
+ CPU basic のままでも動くが、録音1時間あたり30〜60分かかる。
68
+
69
+ ### 1-4. コードを送る
70
 
71
  Space のページに出ている git のURLを使って、このフォルダから送る。
72
 
 
95
 
96
  | ハードウェア | 料金 | 1時間の商談を文字起こしする時間 |
97
  |---|---|---|
98
+ | **ZeroGPUPRO に付属** | **¥0**($9のPRO内) | **数分** |
99
+ | CPU Basic(無料) | ¥0 | 1〜2時間 |
100
  | CPU Upgrade(8 vCPU) | $0.03/時 | 20〜30分 |
 
101
 
102
  **話者分離は既定でオフにしてある。** オンにすると処理時間が倍以上になるため。
103
  どちらが話したかが要るときだけチェックを入れる。
 
280
 
281
  ```
282
  spinthoughts/
283
+ ├── app.py 画面と実行Gradio。ZeroGPU の割り当てもここ
284
  ├── pipeline.py ZIP展開 → 長さフィルタ → 文字起こし → 話者分離 → 統合
285
+ ├── packages.txt Spaces に入れる OS 側のもの(ffmpeg)
 
286
  ├── setup.ps1 自分のPCで動かすための初回セットアップ
287
  ├── start.cmd 自分のPCでの起動
288
  ├── requirements.txt
 
304
  | `APP_PASSWORD` | 空(=認証なし) | 設定すると閲覧にパスワードが要る |
305
  | `APP_USER` | `spin` | ログイン名 |
306
  | `HF_TOKEN` | 空 | 話者分離に使う |
307
+ | `HOST` | `127.0.0.1` | 外から繋ぐときは `0.0.0.0` |
308
+ | `PORT` | `7860` | 待ち受けるポート |
309
+ | `GPU_SECONDS` | `180` | ZeroGPU を1回の処理で確保す秒数 |
310
 
311
  ---
312
 
 
315
  | 症状 | 対処 |
316
  |---|---|
317
  | Space が Build error | ログの最後を見る。多くは requirements.txt の綴りかネットワーク。再ビルドで直ることもある |
318
+ | GPUなのに遅い | Settings のハードウェアが ZeroGPU になっているか確認。CPU basic のままだと当然遅い |
319
+ | `libcudnn` が無いと出る | faster-whisper が GPU を使うのに要るライブラリ。`pipeline.py` が先読みして回避しているが、それでも出るな��ログを見る |
320
  | Space が寝ている | 無料枠は48時間で寝る。開いて数分待つ |
321
  | パスワードを聞かれない | `APP_PASSWORD` が未設定。Settings → Secrets を確認 |
322
  | 話者分離モデルが読めない | HuggingFaceの2ページ両方で条件に同意したか、トークンが read 権限か確認 |
app.py CHANGED
@@ -1,378 +1,418 @@
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 base64
15
- import hmac
16
  import os
17
- import platform
18
  import shutil
19
  import tempfile
20
- import threading
21
  import time
22
  import traceback
23
- import uuid
24
  from pathlib import Path
25
 
26
- from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
27
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
28
 
29
  import pipeline as pl
30
 
31
- APP_DIR = Path(__file__).parent
32
- WORK_ROOT = Path(tempfile.gettempdir()) / "koe-okoshi"
33
- WORK_ROOT.mkdir(parents=True, exist_ok=True)
34
 
35
- app = FastAPI(title="録音文字起こし")
36
- JOBS: dict[str, dict] = {}
37
- LOCK = threading.Lock()
 
38
 
39
- # インターネットに置くときは APP_PASSWORD を設定する。
40
- # 未設定なら素通し(自分のPCで動かすときはこれで困らない)。
41
- APP_USER = os.environ.get("APP_USER", "spin")
42
- APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
43
 
 
 
44
 
45
- @app.middleware("http")
46
- async def require_password(request: Request, call_next):
47
- if not APP_PASSWORD:
48
- return await call_next(request)
49
 
50
- header = request.headers.get("authorization", "")
51
- if header.startswith("Basic "):
52
- try:
53
- user, _, password = base64.b64decode(header[6:]).decode("utf-8").partition(":")
54
- # 文字列比較の時間差から答えが漏れないように compare_digest を使う
55
- if hmac.compare_digest(user, APP_USER) and hmac.compare_digest(password, APP_PASSWORD):
56
- return await call_next(request)
57
- except Exception:
58
- pass
59
-
60
- return Response(
61
- "パスワードが必要です。",
62
- status_code=401,
63
- headers={"WWW-Authenticate": 'Basic realm="spinthoughts"'},
64
- )
65
 
 
 
 
 
 
66
 
67
- # 聞き返す用の音声を残すので、放っておくとディスクが埋まる。
68
- # 商談の音声を必要以上に置いておかないためでもある。
69
- JOB_TTL_SECONDS = 6 * 3600
70
 
 
 
 
71
 
72
- def sweep_old_jobs() -> None:
73
- """古い処理の音声と出力を消す。新しい処理を始めるたびに呼ぶ。"""
74
- now = time.time()
75
 
76
- with LOCK:
77
- stale = [
78
- job_id for job_id, job in JOBS.items()
79
- if now - job.get("createdAt", now) > JOB_TTL_SECONDS
80
- ]
81
- for job_id in stale:
82
- JOBS.pop(job_id, None)
83
 
84
- for job_id in stale:
85
- shutil.rmtree(WORK_ROOT / job_id, ignore_errors=True)
86
 
87
- # 再起動をまたいで残った置き場所も片付ける
88
- for path in WORK_ROOT.iterdir() if WORK_ROOT.exists() else []:
 
 
 
 
 
 
 
 
89
  try:
90
- if path.is_dir() and path.name not in JOBS and now - path.stat().st_mtime > JOB_TTL_SECONDS:
91
  shutil.rmtree(path, ignore_errors=True)
92
  except OSError:
93
  continue
94
 
95
 
96
- def update(job_id: str, **fields) -> None:
97
- with LOCK:
98
- if job_id in JOBS:
99
- JOBS[job_id].update(fields)
100
-
101
-
102
- def serialize(results: list[pl.FileResult]) -> list[dict]:
103
- return [
104
- {
105
- "index": i,
106
- "shortName": os.path.basename(r.original_name),
107
- "name": r.original_name,
108
- "duration": round(r.duration, 1),
109
- "durationLabel": pl.hhmmss(r.duration),
110
- "recordedAt": r.recorded_at,
111
- "status": r.status,
112
- "reason": r.reason,
113
- "speakers": r.speakers,
114
- "hasAudio": r.playback is not None,
115
- "talk": [
116
- {"name": name, "seconds": round(secs, 1), "count": count}
117
- for name, secs, count in pl.speaking_time(r)
118
- ],
119
- "utterances": [
120
- {
121
- "start": pl.hhmmss(u.start),
122
- "startSeconds": round(u.start, 2),
123
- "end": pl.hhmmss(u.end),
124
- "speaker": u.speaker,
125
- "text": u.text,
126
- "needsReview": u.needs_review,
127
- "reviewNote": u.review_note,
128
- }
129
- for u in r.utterances
130
- ],
131
- }
132
- for i, r in enumerate(results, 1)
133
- ]
134
-
135
-
136
- def worker(job_id: str, sources: list[Path], options: dict) -> None:
137
- workdir = WORK_ROOT / job_id
138
- try:
139
- def progress(message: str, done: int, total: int) -> None:
140
- update(job_id, message=message, done=done, total=total)
141
-
142
- results = pl.run(sources=sources, workdir=workdir, progress=progress, **options)
143
-
144
- update(job_id, message="ファイルを書き出しています")
145
- outputs = pl.build_outputs(results, workdir / "出力", job_id)
146
-
147
- transcribed = sum(1 for r in results if r.status == "transcribed")
148
- skipped = sum(1 for r in results if r.status == "skipped")
149
- failed = sum(1 for r in results if r.status == "error")
150
-
151
- update(
152
- job_id,
153
- state="done",
154
- message="完了",
155
- results=serialize(results),
156
- summary={
157
- "total": len(results),
158
- "transcribed": transcribed,
159
- "skipped": skipped,
160
- "failed": failed,
161
- "flagged": sum(
162
- 1 for r in results for u in r.utterances if u.needs_review
163
- ),
164
- },
165
- outputs=outputs,
166
- hasXlsx=outputs.get("xlsx") is not None,
167
- )
168
- except Exception as exc:
169
- traceback.print_exc()
170
- update(job_id, state="error", message=str(exc))
171
- finally:
172
- # 元の音声と作業用ファイルは消す。聞き返す用の音声(再生用)だけ残す。
173
- shutil.rmtree(workdir / "audio", ignore_errors=True)
174
- shutil.rmtree(workdir / "wav", ignore_errors=True)
175
- shutil.rmtree(workdir / "受け取り", ignore_errors=True)
176
-
177
-
178
- # 画面を開いた時点で読み込みを始めておく。最初の1本を待たせないため。
179
- threading.Thread(target=pl.preload, daemon=True).start()
180
-
181
-
182
- @app.get("/", response_class=HTMLResponse)
183
- def index() -> str:
184
- return (APP_DIR / "index.html").read_text(encoding="utf-8")
185
-
186
-
187
- def ffmpeg_hint() -> str:
188
- if pl.IS_WINDOWS:
189
- return "winget install Gyan.FFmpeg"
190
- if platform.system() == "Darwin":
191
- return "brew install ffmpeg"
192
- return "apt-get install ffmpeg"
193
-
194
-
195
- @app.get("/environment")
196
- def environment(request: Request) -> JSONResponse:
197
- # 自分のPCから開いているのでなければ、公開先とみなす。
198
- # HOSTED を立て忘れても警告が消えないように、接続元から判断する。
199
- client = request.client.host if request.client else ""
200
- hosted = bool(os.environ.get("HOSTED")) or client not in ("127.0.0.1", "::1", "localhost")
201
-
202
- return JSONResponse({
203
- "appleSilicon": pl.is_apple_silicon(),
204
- "ffmpeg": pl.tool_path("ffmpeg") is not None and pl.tool_path("ffprobe") is not None,
205
- "ffmpegHint": ffmpeg_hint(),
206
- "gpu": pl.gpu_label(),
207
- "cpuThreads": pl.cpu_threads(),
208
- "modelReady": pl.is_model_ready(),
209
- "defaultModel": pl.default_model_size(),
210
- "tokenFromEnv": bool(os.environ.get("HF_TOKEN")),
211
- "passwordSet": bool(APP_PASSWORD),
212
- "hosted": hosted,
213
- "defaultPrompt": pl.DEFAULT_PROMPT,
214
- })
215
-
216
-
217
- @app.post("/jobs")
218
- async def create_job(
219
- files: list[UploadFile] = File(...),
220
- minSeconds: float = Form(60.0),
221
- modelSize: str = Form(""),
222
- numSpeakers: int = Form(0),
223
- diarization: bool = Form(False),
224
- fast: bool = Form(True),
225
- prompt: str = Form(pl.DEFAULT_PROMPT),
226
- hfToken: str = Form(""),
227
- ) -> JSONResponse:
228
- accepted = {".zip"} | pl.AUDIO_EXT
229
- for item in files:
230
- if Path(item.filename or "").suffix.lower() not in accepted:
231
- raise HTTPException(
232
- 400,
233
- f"{item.filename} は扱えません。ZIP、または音声ファイルを選んでください。",
234
  )
235
 
236
- token = hfToken.strip() or os.environ.get("HF_TOKEN", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  if diarization and not token:
238
- raise HTTPException(
239
- 400,
240
- "話者分離にはHuggingFaceのアクセストークンが必要です。"
241
- "トークンを入力するか、話者分離をオフにしてください。",
242
  )
243
 
244
  sweep_old_jobs()
 
245
 
246
- job_id = uuid.uuid4().hex[:12]
247
- workdir = WORK_ROOT / job_id
248
- workdir.mkdir(parents=True, exist_ok=True)
249
-
250
  upload_dir = workdir / "受け取り"
251
  upload_dir.mkdir(parents=True, exist_ok=True)
252
-
253
- sources: list[Path] = []
254
  for item in files:
255
- # 送られてきた名前をそのまま置き場所にしない(パスに化ける文字を落とす)
256
- name = pl.safe_filename(item.filename or "音声")
257
- target = upload_dir / name
258
  counter = 1
259
  while target.exists():
260
- target = upload_dir / f"{Path(name).stem}__{counter}{Path(name).suffix}"
261
  counter += 1
262
- with open(target, "wb") as out:
263
- shutil.copyfileobj(item.file, out)
264
  sources.append(target)
265
 
266
- with LOCK:
267
- JOBS[job_id] = {
268
- "state": "running",
269
- "message": "準備しています",
270
- "createdAt": time.time(),
271
- "done": 0,
272
- "total": 0,
273
- "results": [],
274
- "summary": None,
275
- "csv": None,
276
- }
277
-
278
- options = {
279
- "min_seconds": max(0.0, minSeconds),
280
- "model_size": modelSize or pl.default_model_size(),
281
- "num_speakers": numSpeakers if numSpeakers > 0 else None,
282
- "diarization_enabled": diarization,
283
- "fast": fast,
284
- "prompt": prompt,
285
- "hf_token": token,
286
- }
287
-
288
- threading.Thread(target=worker, args=(job_id, sources, options), daemon=True).start()
289
- return JSONResponse({"jobId": job_id})
290
-
291
-
292
- @app.get("/jobs/{job_id}")
293
- def job_status(job_id: str) -> JSONResponse:
294
- with LOCK:
295
- job = JOBS.get(job_id)
296
- if not job:
297
- raise HTTPException(404, "処理が見つかりません。")
298
- return JSONResponse({k: v for k, v in job.items() if k != "outputs"})
299
-
300
-
301
- def _outputs(job_id: str) -> dict:
302
- with LOCK:
303
- job = JOBS.get(job_id)
304
- if not job or not job.get("outputs"):
305
- raise HTTPException(404, "出力がまだありません。")
306
- return job["outputs"]
307
-
308
-
309
- @app.get("/jobs/{job_id}/audio/{index}")
310
- def play_audio(job_id: str, index: int) -> FileResponse:
311
- """文字起こしした音声を聞き返すためのもの。"""
312
- with LOCK:
313
- job = JOBS.get(job_id)
314
- if not job:
315
- raise HTTPException(404, "処理が見つかりません。")
316
-
317
- path = WORK_ROOT / job_id / "再生用" / f"{index - 1:03d}.mp3"
318
- if not path.exists():
319
- raise HTTPException(404, "この録音の音声は残っていません。")
320
- return FileResponse(path, media_type="audio/mpeg")
321
-
322
-
323
- @app.get("/jobs/{job_id}/bundle")
324
- def download_bundle(job_id: str) -> FileResponse:
325
- out = _outputs(job_id)
326
- return FileResponse(
327
- out["bundle"], media_type="application/zip", filename=f"文字起こし_{job_id}.zip"
328
- )
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
- @app.get("/jobs/{job_id}/xlsx")
332
- def download_xlsx(job_id: str) -> FileResponse:
333
- out = _outputs(job_id)
334
- if not out.get("xlsx"):
335
- raise HTTPException(404, "Excelを作成できませんでした。openpyxl を導入してください。")
336
- return FileResponse(
337
- out["xlsx"],
338
- media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
339
- filename=f"録音ごと_{job_id}.xlsx",
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  )
341
 
342
 
343
- @app.get("/jobs/{job_id}/all-csv")
344
- def download_all_csv(job_id: str) -> FileResponse:
345
- out = _outputs(job_id)
346
- return FileResponse(
347
- out["all_csv"], media_type="text/csv", filename=f"全発話_{job_id}.csv"
348
  )
349
 
 
350
 
351
- @app.get("/jobs/{job_id}/files/{index}.{kind}")
352
- def download_one(job_id: str, index: int, kind: str) -> FileResponse:
353
- out = _outputs(job_id)
354
- record = next((r for r in out["per_file"] if r["index"] == index), None)
355
- if not record:
356
- raise HTTPException(404, "その録音が見つかりません。")
357
- if kind == "txt":
358
- return FileResponse(
359
- record["txt"], media_type="text/plain; charset=utf-8",
360
- filename=f"{record['stem']}.txt",
 
 
 
 
 
 
361
  )
362
- if kind == "csv" and record.get("csv"):
363
- return FileResponse(
364
- record["csv"], media_type="text/csv", filename=f"{record['stem']}.csv"
365
  )
366
- raise HTTPException(404, "その形式は用意されていません。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
 
369
  if __name__ == "__main__":
370
- import uvicorn
371
-
372
- # 既定は 127.0.0.1(自分のPCからしか開けない)。
373
- # コンテナで動かすときだけ HOST=0.0.0.0 を渡す。
374
- uvicorn.run(
375
- app,
376
- host=os.environ.get("HOST", "127.0.0.1"),
377
- port=int(os.environ.get("PORT", "8000")),
 
378
  )
 
1
+ """商談文字起こし(Gradio / ZeroGPU)
2
+
3
+ HuggingFace Spaces の Gradio SDK で動かす。PRO に付いている ZeroGPU を使うと、
4
+ 文字起こしと話者分離が GPU で走る。GPU が無い環境(自分のPCなど)でも
5
+ そのまま CPU で動く。
6
 
7
  起動:
8
  python app.py
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
 
 
13
  import os
 
14
  import shutil
15
  import tempfile
 
16
  import time
17
  import traceback
 
18
  from pathlib import Path
19
 
20
+ import gradio as gr
 
21
 
22
  import pipeline as pl
23
 
24
+ # ---------------------------------------------------------------- ZeroGPU
 
 
25
 
26
+ # Spaces では spaces パッケージが入っている。自分のPCには無いので、
27
+ # 無ければ「何もしない飾り」に差し替えて同じコードが動くようにする。
28
+ try:
29
+ import spaces # type: ignore
30
 
31
+ HAS_SPACES = True
32
+ except ImportError: # 自分のPCで動かすとき
33
+ spaces = None
34
+ HAS_SPACES = False
35
 
36
+ ON_ZERO_GPU = HAS_SPACES and os.environ.get("SPACES_ZERO_GPU") is not None
37
+ GPU_DEVICE = "cuda" if ON_ZERO_GPU else ""
38
 
39
+ # 1回のGPU割り当てで使える時間。長い録音でも足りるように多めに取る。
40
+ GPU_SECONDS = int(os.environ.get("GPU_SECONDS", "180"))
 
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
+ def on_gpu(fn):
44
+ """ZeroGPU のときだけ GPU を割り当てる。それ以外は素通し。"""
45
+ if not ON_ZERO_GPU:
46
+ return fn
47
+ return spaces.GPU(duration=GPU_SECONDS)(fn)
48
 
 
 
 
49
 
50
+ @on_gpu
51
+ def transcribe_on_gpu(wav, model_size, prompt, fast):
52
+ return pl.transcribe_file(Path(wav), model_size, prompt, fast, device=GPU_DEVICE)
53
 
 
 
 
54
 
55
+ @on_gpu
56
+ def diarize_on_gpu(wav, hf_token, num_speakers):
57
+ return pl.diarize(Path(wav), hf_token, num_speakers, device=GPU_DEVICE)
58
+
 
 
 
59
 
60
+ # ---------------------------------------------------------------- 置き場所
 
61
 
62
+ WORK_ROOT = Path(tempfile.gettempdir()) / "spinthoughts"
63
+ WORK_ROOT.mkdir(parents=True, exist_ok=True)
64
+
65
+ # 商談の音声を必要以上に置いておかないため、古いものは消す
66
+ JOB_TTL_SECONDS = 6 * 3600
67
+
68
+
69
+ def sweep_old_jobs() -> None:
70
+ now = time.time()
71
+ for path in WORK_ROOT.iterdir():
72
  try:
73
+ if path.is_dir() and now - path.stat().st_mtime > JOB_TTL_SECONDS:
74
  shutil.rmtree(path, ignore_errors=True)
75
  except OSError:
76
  continue
77
 
78
 
79
+ # ---------------------------------------------------------------- 画面に出す形
80
+
81
+ STATUS_MARK = {"transcribed": "", "skipped": "除外", "error": "失敗"}
82
+
83
+
84
+ def esc(text) -> str:
85
+ return (
86
+ str(text)
87
+ .replace("&", "&")
88
+ .replace("<", "&lt;")
89
+ .replace(">", "&gt;")
90
+ .replace('"', "&quot;")
91
+ )
92
+
93
+
94
+ def audio_url(path: Path) -> str:
95
+ return "/gradio_api/file=" + Path(path).as_posix()
96
+
97
+
98
+ def render(results) -> str:
99
+ """録音ごとのカード。時刻を押すとその位置から音声が鳴る。"""
100
+ if not results:
101
+ return ""
102
+
103
+ cards = []
104
+ for i, entry in enumerate(results, 1):
105
+ audio_id = "audio-%d" % i
106
+ tag = STATUS_MARK.get(entry.status, "")
107
+ meta = " · ".join(x for x in [
108
+ pl.hhmmss(entry.duration),
109
+ entry.recorded_at,
110
+ ("%d発話" % len(entry.utterances)) if entry.status == "transcribed" else "",
111
+ entry.reason or "",
112
+ ] if x)
113
+
114
+ player = ""
115
+ if entry.playback:
116
+ player = (
117
+ '<audio id="%s" class="player" controls preload="none" src="%s"></audio>'
118
+ % (audio_id, audio_url(entry.playback))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  )
120
 
121
+ talk = pl.speaking_time(entry)
122
+ talk_html = ""
123
+ if len(talk) > 1:
124
+ parts = [
125
+ "<span><b>%s</b> %d分%02d秒・%d回</span>"
126
+ % (esc(name), int(secs // 60), int(secs % 60), count)
127
+ for name, secs, count in talk
128
+ ]
129
+ talk_html = '<div class="talk">%s</div>' % "".join(parts)
130
+
131
+ palette = {}
132
+ lines = []
133
+ for u in entry.utterances:
134
+ if u.speaker not in palette:
135
+ palette[u.speaker] = len(palette) % 5
136
+ flag = ""
137
+ if u.needs_review:
138
+ flag = '<span class="flag" title="%s">要確認</span>' % esc(u.review_note)
139
+ who = '<span class="who">%s</span>' % esc(u.speaker) if u.speaker else ""
140
+ lines.append(
141
+ '<div class="line">'
142
+ '<button class="seek" data-audio="%s" data-t="%.2f">%s</button>'
143
+ '<span class="spine sp%d"></span>'
144
+ '<span class="said">%s<span class="what">%s%s</span></span>'
145
+ "</div>"
146
+ % (audio_id, u.start, pl.hhmmss(u.start), palette[u.speaker],
147
+ who, esc(u.text), flag)
148
+ )
149
+
150
+ if lines:
151
+ body = '<div class="score">%s</div>' % "".join(lines)
152
+ else:
153
+ body = '<p class="empty">%s</p>' % esc(
154
+ entry.reason or "発話を検出できませんでした。"
155
+ )
156
+
157
+ cards.append(
158
+ '<details class="file"%s>'
159
+ '<summary><span class="no">%03d</span>'
160
+ '<span class="file-name">%s</span>%s'
161
+ '<span class="file-meta">%s</span></summary>'
162
+ '<div class="file-body">%s%s%s</div>'
163
+ "</details>"
164
+ % (
165
+ " open" if entry.status == "transcribed" else "",
166
+ i,
167
+ esc(os.path.basename(entry.original_name)),
168
+ ('<span class="tag">%s</span>' % tag) if tag else "",
169
+ esc(meta),
170
+ player, talk_html, body,
171
+ )
172
+ )
173
+
174
+ return '<div class="results">%s</div>' % "".join(cards)
175
+
176
+
177
+ def summarize(results) -> str:
178
+ transcribed = sum(1 for r in results if r.status == "transcribed")
179
+ skipped = sum(1 for r in results if r.status == "skipped")
180
+ failed = sum(1 for r in results if r.status == "error")
181
+ flagged = sum(1 for r in results for u in r.utterances if u.needs_review)
182
+ return (
183
+ "**書き起こし %d** / 長さ不足で除外 %d / 失敗 %d / 要確認 %d"
184
+ % (transcribed, skipped, failed, flagged)
185
+ )
186
+
187
+
188
+ # ---------------------------------------------------------------- 実行
189
+
190
+
191
+ def process(files, min_seconds, model_size, num_speakers, diarization, fast, prompt,
192
+ hf_token, progress=gr.Progress()):
193
+ if not files:
194
+ raise gr.Error("音声ファイルかZIPを選んでください。")
195
+
196
+ token = (hf_token or "").strip() or os.environ.get("HF_TOKEN", "")
197
  if diarization and not token:
198
+ raise gr.Error(
199
+ "話者分離にはHuggingFaceのトークンが必要です。"
200
+ "トークンを入れるか、話者分離をオフしてください。"
 
201
  )
202
 
203
  sweep_old_jobs()
204
+ workdir = Path(tempfile.mkdtemp(prefix="job-", dir=WORK_ROOT))
205
 
206
+ # Gradio が置く一時ファイルは名前が変わることがある���で、元の名前で置き直す
 
 
 
207
  upload_dir = workdir / "受け取り"
208
  upload_dir.mkdir(parents=True, exist_ok=True)
209
+ sources = []
 
210
  for item in files:
211
+ src = Path(item if isinstance(item, str) else item.name)
212
+ target = upload_dir / pl.safe_filename(src.name)
 
213
  counter = 1
214
  while target.exists():
215
+ target = upload_dir / ("%s__%d%s" % (target.stem, counter, target.suffix))
216
  counter += 1
217
+ shutil.copyfile(src, target)
 
218
  sources.append(target)
219
 
220
+ def notify(message, done, total):
221
+ progress((done, total) if total else 0, desc=message)
222
+
223
+ try:
224
+ results = pl.run(
225
+ sources=sources,
226
+ workdir=workdir,
227
+ min_seconds=max(0.0, float(min_seconds or 0)),
228
+ model_size=model_size,
229
+ num_speakers=int(num_speakers) if num_speakers and int(num_speakers) > 0 else None,
230
+ diarization_enabled=bool(diarization),
231
+ fast=bool(fast),
232
+ prompt=prompt or "",
233
+ hf_token=token,
234
+ progress=notify,
235
+ transcriber=transcribe_on_gpu if ON_ZERO_GPU else None,
236
+ diarizer=diarize_on_gpu if ON_ZERO_GPU else None,
237
+ )
238
+ except Exception as exc:
239
+ traceback.print_exc()
240
+ raise gr.Error(str(exc))
241
+
242
+ progress(0, desc="ファイルを書き出しています")
243
+ outputs = pl.build_outputs(results, workdir / "出力", workdir.name)
244
+
245
+ # 元の音声と作業用ファイルは消す。聞き返す用(再生用)だけ残す。
246
+ shutil.rmtree(workdir / "audio", ignore_errors=True)
247
+ shutil.rmtree(workdir / "wav", ignore_errors=True)
248
+ shutil.rmtree(upload_dir, ignore_errors=True)
249
+
250
+ downloads = [outputs["bundle"]]
251
+ if outputs.get("xlsx"):
252
+ downloads.append(outputs["xlsx"])
253
+ downloads += [outputs["all_csv"], outputs["index_csv"]]
254
+
255
+ return summarize(results), render(results), [str(p) for p in downloads]
256
+
257
+
258
+ # ---------------------------------------------------------------- 画面
259
+
260
+ HEAD = """
261
+ <script>
262
+ /* 時刻を押したら、その位置から音声を鳴らす。
263
+ 結果は後から差し込まれるので、document 側でまとめて受ける。 */
264
+ document.addEventListener("click", function (e) {
265
+ var btn = e.target.closest("button.seek");
266
+ if (!btn) return;
267
+ e.preventDefault();
268
+ var audio = document.getElementById(btn.dataset.audio);
269
+ if (!audio) return;
270
+ audio.currentTime = parseFloat(btn.dataset.t || "0");
271
+ audio.play().catch(function () {});
272
+ });
273
+ </script>
274
+ """
 
 
 
 
 
 
 
 
275
 
276
+ CSS = """
277
+ .results { --rule: #ccd3d8; --soft: #5a636b; --faint: #8b939a; --accent: #1f5f5b; }
278
+ .results .file { border: 1px solid var(--rule); border-radius: 8px; margin-bottom: 10px; }
279
+ .results summary { padding: 12px 14px; cursor: pointer; display: flex; gap: 10px;
280
+ align-items: baseline; flex-wrap: wrap; }
281
+ .results .no { font-family: ui-monospace, monospace; font-size: 11px; color: var(--faint); }
282
+ .results .file-name { font-weight: 600; }
283
+ .results .file-meta { font-size: 12px; color: var(--faint); margin-left: auto; }
284
+ .results .tag { font-size: 11px; padding: 1px 7px; border-radius: 3px;
285
+ background: #eceff1; color: var(--soft); }
286
+ .results .file-body { padding: 0 14px 14px; }
287
+ .results .player { width: 100%; height: 36px; margin-bottom: 10px; }
288
+ .results .talk { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px;
289
+ color: var(--soft); margin-bottom: 8px; }
290
+ .results .line { display: flex; gap: 10px; padding: 8px 0; border-top: 1px dotted var(--rule); }
291
+ .results button.seek { font-family: ui-monospace, monospace; font-size: 11.5px;
292
+ color: var(--faint); background: none; border: none; padding: 3px 0 0;
293
+ cursor: pointer; text-decoration: underline; flex: none; width: 62px; text-align: left; }
294
+ .results button.seek:hover { color: var(--accent); }
295
+ .results .spine { width: 3px; border-radius: 2px; flex: none; background: var(--faint); }
296
+ .results .sp0 { background: #1f5f5b; } .results .sp1 { background: #9a4f22; }
297
+ .results .sp2 { background: #4b4a86; } .results .sp3 { background: #8c2f4d; }
298
+ .results .sp4 { background: #40682a; }
299
+ .results .said { flex: 1; min-width: 0; }
300
+ .results .who { display: block; font-family: ui-monospace, monospace; font-size: 10.5px;
301
+ color: var(--soft); }
302
+ .results .what { font-size: 14.5px; line-height: 1.75; word-break: break-word; }
303
+ .results .flag { font-size: 10px; color: #8a5512; background: #f2e6d2;
304
+ padding: 1px 6px; border-radius: 2px; margin-left: 6px; }
305
+ .results .empty { color: var(--faint); font-size: 13px; }
306
+ """
307
 
308
+ AUDIO_TYPES = [
309
+ ".zip", ".wav", ".mp3", ".m4a", ".mp4", ".aac", ".flac", ".ogg", ".opus",
310
+ ".wma", ".amr", ".3gp", ".mov", ".aif", ".aiff",
311
+ ]
312
+
313
+ DEFAULTS = {
314
+ "min_seconds": 60,
315
+ "model_size": "large-v3-turbo",
316
+ "num_speakers": 2,
317
+ "diarization": False,
318
+ "fast": True,
319
+ }
320
+
321
+
322
+ def environment_note() -> str:
323
+ if ON_ZERO_GPU:
324
+ return "**ZeroGPU で動作中。** 処理のたびにGPUが割り当てられます。"
325
+ if pl.gpu_label():
326
+ return "**%s で動作中。**" % pl.gpu_label()
327
+ return (
328
+ "**CPU処理(%dスレッド)。** 録音1時間あたり30〜60分かかります。"
329
+ % pl.cpu_threads()
330
  )
331
 
332
 
333
+ with gr.Blocks(title="商談文字起こし") as demo:
334
+ gr.Markdown("# 商談文字起こし")
335
+ gr.Markdown(
336
+ "音声ファイルやZIPを入れると、一定の長さ以上の録音だけを文字起こししてCSVにします。 \n"
337
+ + environment_note()
338
  )
339
 
340
+ saved = gr.BrowserState(DEFAULTS, storage_key="spinthoughts.settings.v3")
341
 
342
+ files = gr.File(
343
+ label="音声ファイル または ZIP(複数可)",
344
+ file_count="multiple",
345
+ file_types=AUDIO_TYPES,
346
+ )
347
+
348
+ with gr.Row():
349
+ min_seconds = gr.Number(
350
+ DEFAULTS["min_seconds"], label="この長さ以上だけ処理する(秒)",
351
+ info="これより短い録音は文字起こしせず、一覧にだけ残します",
352
+ )
353
+ model_size = gr.Dropdown(
354
+ [("標準(turbo・速くて実用精度)", "large-v3-turbo"),
355
+ ("速度優先(small・誤変換が増えます)", "small"),
356
+ ("精度優先(large-v3・遅い)", "large-v3")],
357
+ value=DEFAULTS["model_size"], label="精度",
358
  )
359
+ num_speakers = gr.Number(
360
+ DEFAULTS["num_speakers"], label="話者の人数",
361
+ info="話者を分けるときだけ使います。0で自動判定",
362
  )
363
+
364
+ with gr.Row():
365
+ diarization = gr.Checkbox(DEFAULTS["diarization"], label="話者を分けて記録する")
366
+ fast = gr.Checkbox(DEFAULTS["fast"], label="速さを優先する")
367
+
368
+ prompt = gr.Textbox(
369
+ pl.DEFAULT_PROMPT, label="よく出る言葉", lines=3,
370
+ info="業務でよく使う語を書いておくと、固有名詞や専門用語の精度が上がります",
371
+ )
372
+
373
+ hf_token = gr.Textbox(
374
+ "", label="HuggingFaceトークン", type="password",
375
+ visible=not os.environ.get("HF_TOKEN"),
376
+ info="話者分離に必要です。Space の Secret に HF_TOKEN があれば表示されません",
377
+ )
378
+
379
+ run = gr.Button("文字起こしを始める", variant="primary")
380
+
381
+ summary = gr.Markdown()
382
+ downloads = gr.Files(label="出力(ZIP一式・Excel・全発話CSV・処理結果一覧)")
383
+ results = gr.HTML()
384
+
385
+ run.click(
386
+ process,
387
+ inputs=[files, min_seconds, model_size, num_speakers, diarization, fast,
388
+ prompt, hf_token],
389
+ outputs=[summary, results, downloads],
390
+ api_name='transcribe',
391
+ )
392
+
393
+ # --- 設定を覚える --------------------------------------------------------
394
+ settings = [min_seconds, model_size, num_speakers, diarization, fast]
395
+
396
+ def restore(store):
397
+ store = store or {}
398
+ return [store.get(key, value) for key, value in DEFAULTS.items()]
399
+
400
+ def remember(*values):
401
+ return dict(zip(DEFAULTS.keys(), values))
402
+
403
+ demo.load(restore, inputs=saved, outputs=settings)
404
+ for component in settings:
405
+ component.change(remember, inputs=settings, outputs=saved)
406
 
407
 
408
  if __name__ == "__main__":
409
+ password = os.environ.get("APP_PASSWORD")
410
+ demo.launch(
411
+ server_name=os.environ.get("HOST", "127.0.0.1"),
412
+ server_port=int(os.environ.get("PORT", "7860")),
413
+ auth=(os.environ.get("APP_USER", "spin"), password) if password else None,
414
+ allowed_paths=[str(WORK_ROOT)],
415
+ head=HEAD,
416
+ css=CSS,
417
+ theme=gr.themes.Soft(primary_hue="teal"),
418
  )
index.html DELETED
@@ -1,793 +0,0 @@
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
- /* トークンが設定済みのときの表示 */
234
- .settled {
235
- padding: 9px 12px; font-size: 13px; color: var(--accent);
236
- background: var(--accent-soft); border-radius: 6px;
237
- }
238
- .hint button {
239
- padding: 0; font-size: 12px; font-family: var(--sans);
240
- border: none; background: none; color: var(--ink-soft);
241
- text-decoration: underline; cursor: pointer;
242
- }
243
- .hint button:hover { color: var(--accent); }
244
-
245
- /* 設定の保存についての案内 */
246
- .saved-note {
247
- display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
248
- margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--rule);
249
- font-size: 12px; color: var(--ink-faint);
250
- }
251
- .saved-note button {
252
- padding: 4px 10px; font-size: 11.5px; font-family: var(--sans);
253
- border: 1px solid var(--rule); border-radius: 4px;
254
- background: transparent; color: var(--ink-soft); cursor: pointer;
255
- }
256
- .saved-note button:hover { border-color: var(--accent); color: var(--accent); }
257
-
258
- /* 誰がどれだけ喋ったか */
259
- .talk { display: flex; flex-wrap: wrap; gap: 14px; margin: 10px 2px 0; font-size: 12px; color: var(--ink-soft); }
260
- .talk b { color: var(--ink); font-weight: 600; }
261
-
262
- /* 聞き返すための再生機 */
263
- .player { width: 100%; height: 34px; margin: 12px 0 4px; }
264
- button.time {
265
- font-family: var(--mono); font-size: 11.5px; color: var(--ink-faint);
266
- background: none; border: none; padding: 3px 0 0; text-align: left;
267
- cursor: pointer; text-decoration: underline; text-underline-offset: 2px;
268
- }
269
- button.time:hover { color: var(--accent); }
270
-
271
- .empty { color: var(--ink-faint); font-size: 13px; }
272
- [hidden] { display: none !important; }
273
-
274
- @media (max-width: 620px) {
275
- .wrap { padding: 24px 14px 60px; }
276
- .grid { grid-template-columns: 1fr; }
277
- .time { width: 52px; }
278
- }
279
- @media (prefers-reduced-motion: reduce) {
280
- * { transition: none !important; }
281
- }
282
- </style>
283
- </head>
284
- <body>
285
- <div class="wrap">
286
-
287
- <header class="masthead">
288
- <h1>録音文字起こし</h1>
289
- <p id="lede">ZIPの中から一定の長さ以上の録音だけを選び、話者ごとに書き起こしてCSVにします。</p>
290
- <div class="chips" id="chips"></div>
291
- </header>
292
-
293
- <section class="panel" id="setup">
294
- <h2>音声を読み込む</h2>
295
-
296
- <input type="file" id="picker" multiple hidden
297
- accept=".zip,.wav,.mp3,.m4a,.mp4,.aac,.flac,.ogg,.opus,.wma,.amr,.3gp,.mov,.aif,.aiff,audio/*">
298
- <button type="button" class="drop" id="drop">
299
- <strong id="dropTitle">音声ファイル または ZIP を選ぶ</strong>
300
- <span id="dropHint">複数まとめて選べます。ここにドラッグしても読み込めます</span>
301
- </button>
302
-
303
- <div class="grid">
304
- <div class="field">
305
- <label for="minSeconds">この長さ以上だけ処理する</label>
306
- <input type="number" id="minSeconds" value="60" min="0" step="1">
307
- <span class="hint">秒。これより短い録音は文字起こしせず、一覧にだけ残します。</span>
308
- </div>
309
-
310
- <div class="field">
311
- <label for="modelSize">精度</label>
312
- <select id="modelSize">
313
- <option value="large-v3-turbo">標準(turbo・速くて実用精度)</option>
314
- <option value="small">速度優先(small・約3割速い/誤変換が増えます)</option>
315
- <option value="large-v3">精度優先(large-v3・数倍遅い)</option>
316
- </select>
317
- <span class="hint" id="modelHint">初回だけモデルの取得に数分かかります。</span>
318
- </div>
319
-
320
- <div class="field">
321
- <label for="numSpeakers">話者の人数</label>
322
- <input type="number" id="numSpeakers" value="2" min="0" max="10" step="1">
323
- <span class="hint">話者を分けるときだけ使います。分かっているなら指定するほど正確になります。0で自動判定。</span>
324
- </div>
325
-
326
- <div class="field" id="tokenField">
327
- <label for="hfToken">HuggingFaceトークン</label>
328
- <input type="password" id="hfToken" placeholder="hf_..." autocomplete="off">
329
- <span class="hint" id="tokenHint">話者分離に必要です。環境変数 HF_TOKEN があれば空欄で構いません。
330
- 鍵なので、この欄だけは保存しません。</span>
331
- </div>
332
-
333
- <div class="field" id="tokenSet" hidden>
334
- <label>HuggingFaceトークン</label>
335
- <p class="settled">サーバー側に設定済みです。入力は要りません。</p>
336
- <span class="hint"><button type="button" id="useOtherToken">別のトークンを使う</button></span>
337
- </div>
338
-
339
- <div class="field span2 check">
340
- <input type="checkbox" id="diarization">
341
- <label for="diarization">話者を分けて記録する(処理時間が倍以上になります)</label>
342
- </div>
343
-
344
- <div class="field span2 check">
345
- <input type="checkbox" id="fast" checked>
346
- <label for="fast">速さを優先する(1割ほど速くなります。句読点が少し減ることがあります)</label>
347
- </div>
348
-
349
- <div class="field span2">
350
- <label for="prompt">よく出る言葉</label>
351
- <textarea id="prompt"></textarea>
352
- <span class="hint">業務でよく使う語を書いておくと、固有名詞や専門用語の変換精度が上がります。</span>
353
- </div>
354
- </div>
355
-
356
- <div class="saved-note">
357
- <span id="savedState">この設定はこのブラウザに保存され、次に開いたときも使われます。</span>
358
- <button type="button" id="resetSettings">既定に戻す</button>
359
- </div>
360
-
361
- <div class="actions">
362
- <button type="button" class="go" id="start" disabled>文字起こしを始める</button>
363
- <span class="error" id="formError"></span>
364
- </div>
365
- </section>
366
-
367
- <section class="panel" id="progress" hidden>
368
- <h2>処理中</h2>
369
- <div class="track">
370
- <span class="now" id="progressNow">準備しています</span>
371
- <span class="count" id="progressCount"></span>
372
- </div>
373
- <div class="bar"><i id="progressBar"></i></div>
374
- </section>
375
-
376
- <section class="panel" id="summary" hidden>
377
- <h2>結果</h2>
378
- <div class="tally" id="tally"></div>
379
- <div class="actions" id="downloads"></div>
380
- <p class="hint" id="bundleNote"></p>
381
- </section>
382
-
383
- <section class="panel" id="output" hidden>
384
- <h2>書き起こし</h2>
385
- <div id="files"></div>
386
- </section>
387
-
388
- </div>
389
-
390
- <script>
391
- const $ = (id) => document.getElementById(id);
392
- let chosenFiles = [];
393
- let poller = null;
394
-
395
- /* ---------- 設定の保存 ----------
396
- このブラウザに覚えさせる。サーバーに置かないのは、Spaces の保存領域が
397
- 再起動で消えることと、使う人ごとに設定を変えられるようにするため。
398
- HuggingFaceトークンだけは保存しない(鍵をブラウザに置かないため)。 */
399
- /* 既定を変えたときは番号を上げる。そうしないと、前に保存した設定が
400
- 新しい既定を上書きしてしまい、変えたつもりが変わらない。 */
401
- const SETTINGS_KEY = "spinthoughts.settings.v2";
402
- const OLD_KEYS = ["spinthoughts.settings.v1"];
403
- const SAVED_FIELDS = ["minSeconds", "modelSize", "numSpeakers", "prompt"];
404
- let defaults = {};
405
-
406
- function saveSettings() {
407
- const data = {};
408
- SAVED_FIELDS.forEach(id => { data[id] = $(id).value; });
409
- data.diarization = $("diarization").checked;
410
- data.fast = $("fast").checked;
411
- try {
412
- localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
413
- note("設定を保存しました。次に開いたときもこの内容で始まります。");
414
- } catch (err) {
415
- note("この設定は保存できませんでした(ブラウザの設定で保存が無効の可能性)。");
416
- }
417
- }
418
-
419
- function loadSettings() {
420
- let data = null;
421
- try {
422
- data = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "null");
423
-
424
- /* 古い設定からは「よく出る言葉」だけ引き継ぐ。
425
- 手で書き足した語彙を捨てないため。他は新しい既定に従う。 */
426
- if (!data) {
427
- for (const key of OLD_KEYS) {
428
- const old = JSON.parse(localStorage.getItem(key) || "null");
429
- localStorage.removeItem(key);
430
- if (old && old.prompt) {
431
- data = { prompt: old.prompt };
432
- break;
433
- }
434
- }
435
- }
436
- } catch (err) {
437
- data = null;
438
- }
439
- if (!data) return false;
440
-
441
- SAVED_FIELDS.forEach(id => {
442
- if (typeof data[id] === "string") $(id).value = data[id];
443
- });
444
- if (typeof data.diarization === "boolean") $("diarization").checked = data.diarization;
445
- if (typeof data.fast === "boolean") $("fast").checked = data.fast;
446
- return true;
447
- }
448
-
449
- function note(message) {
450
- $("savedState").textContent = message;
451
- }
452
-
453
- function resetSettings() {
454
- try { localStorage.removeItem(SETTINGS_KEY); } catch (err) { /* 消せなくても続行 */ }
455
- $("minSeconds").value = defaults.minSeconds;
456
- $("numSpeakers").value = defaults.numSpeakers;
457
- $("diarization").checked = defaults.diarization;
458
- $("fast").checked = defaults.fast;
459
- $("modelSize").value = defaults.modelSize || $("modelSize").value;
460
- $("prompt").value = defaults.prompt || "";
461
- note("既定に戻しました。");
462
- }
463
-
464
- /* 触ったその場で保存する。保存ボタンを押し忘れて消えるのを防ぐため。 */
465
- SAVED_FIELDS.concat("diarization", "fast").forEach(id =>
466
- $(id).addEventListener("change", saveSettings));
467
- $("resetSettings").addEventListener("click", resetSettings);
468
-
469
- /* サーバーのトークンが使えないときの逃げ道。普段は畳んでおく。 */
470
- $("useOtherToken").addEventListener("click", () => {
471
- $("tokenSet").hidden = true;
472
- $("tokenField").hidden = false;
473
- $("hfToken").focus();
474
- });
475
-
476
- /* ---------- environment ---------- */
477
- fetch("/environment").then(r => r.json()).then(env => {
478
- $("prompt").value = env.defaultPrompt || "";
479
- $("lede").textContent = env.hosted
480
- ? "ZIPの中から一定の長さ以上の録音だけを選び、話者ごとに書き起こしてCSVにします。"
481
- + "音声はサーバー上で処理され、処理が終わると消えます。"
482
- : "ZIPの中から一定の長さ以上の録音だけを選び、話者ごとに書き起こしてCSVにします。"
483
- + "音声はこのPCの外に出ません。";
484
- /* サーバーにトークンがあるなら入力欄は出さない。
485
- 誤って別の鍵を入れて、原因の分かりにくい失敗をするのを防ぐ。 */
486
- $("tokenField").hidden = env.tokenFromEnv;
487
- $("tokenSet").hidden = !env.tokenFromEnv;
488
-
489
- if (env.defaultModel) $("modelSize").value = env.defaultModel;
490
- if (!env.gpu) {
491
- $("modelHint").textContent =
492
- "初回だけモデルの取得に数分かかります。CPU処理なので、録音1時間あたり数十分かかります。";
493
- }
494
- const chips = [
495
- env.ffmpeg
496
- ? { text: "ffmpeg 利用可", cls: "ok" }
497
- : { text: `ffmpeg 未検出 — ${env.ffmpegHint || ""}`, cls: "bad" },
498
- env.gpu
499
- ? { text: `${env.gpu} で高速処理`, cls: "ok" }
500
- : { text: `CPU処理(${env.cpuThreads || "?"}スレッド・時間がかかります)`, cls: "" },
501
- // 起動直後はモデルを読み込んでいる。最初の1本だけ待ちが増える理由を出す
502
- env.modelReady
503
- ? { text: "モデル準備済み", cls: "ok" }
504
- : { text: "モデル読込中 — 最初の1本は少し余分に待ちます", cls: "" },
505
- env.tokenFromEnv
506
- ? { text: "HF_TOKEN 設定済み", cls: "ok" }
507
- : { text: "HF_TOKEN 未設定 — 話者分離のたびに入力が要ります", cls: "" },
508
- // 公開先でパスワードを掛け忘れると、URLを知る全員が使えてしまう
509
- env.hosted && !env.passwordSet
510
- ? { text: "パスワード未設定 — URLを知る人は誰でも使えます", cls: "bad" }
511
- : null,
512
- ].filter(Boolean);
513
- $("chips").innerHTML = chips
514
- .map(c => `<span class="chip ${c.cls}">${c.text}</span>`)
515
- .join("");
516
-
517
- /* 「既定に戻す」で戻れるよう、初期値を控えておく */
518
- defaults = {
519
- minSeconds: "60",
520
- numSpeakers: "2",
521
- diarization: false,
522
- fast: true,
523
- modelSize: env.defaultModel || $("modelSize").value,
524
- prompt: env.defaultPrompt || "",
525
- };
526
-
527
- /* 保存済みの設定は、既定より後に当てて上書きする */
528
- if (loadSettings()) {
529
- note("前回の設定を読み込みました。変えるとその場で保存されます。");
530
- }
531
- }).catch(() => { loadSettings(); });
532
-
533
- /* ---------- file selection ---------- */
534
- const AUDIO_EXT = [
535
- ".zip", ".wav", ".mp3", ".m4a", ".mp4", ".aac", ".flac", ".ogg", ".opus",
536
- ".wma", ".amr", ".3gp", ".mov", ".aif", ".aiff",
537
- ];
538
-
539
- const drop = $("drop");
540
- drop.addEventListener("click", () => $("picker").click());
541
- $("picker").addEventListener("change", (e) => setFiles(e.target.files));
542
-
543
- ["dragenter", "dragover"].forEach(ev =>
544
- drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("hot"); }));
545
- ["dragleave", "drop"].forEach(ev =>
546
- drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("hot"); }));
547
- drop.addEventListener("drop", (e) => setFiles(e.dataTransfer.files));
548
-
549
- function setFiles(list) {
550
- const picked = [...(list || [])];
551
- if (!picked.length) return;
552
-
553
- const bad = picked.filter(f => !AUDIO_EXT.some(ext => f.name.toLowerCase().endsWith(ext)));
554
- if (bad.length) {
555
- $("formError").textContent =
556
- `${bad.map(f => f.name).join("、")} は音声ファイルではありません。`;
557
- return;
558
- }
559
-
560
- chosenFiles = picked;
561
- $("formError").textContent = "";
562
-
563
- const size = picked.reduce((sum, f) => sum + f.size, 0) / 1048576;
564
- $("dropTitle").textContent = picked.length === 1
565
- ? picked[0].name
566
- : `${picked.length} 個のファイル`;
567
- $("dropHint").className = "picked";
568
- $("dropHint").textContent =
569
- `${size.toFixed(1)} MB — 選び直すにはもう一度クリック`;
570
- $("start").disabled = false;
571
- }
572
-
573
- /* ---------- start ---------- */
574
- $("start").addEventListener("click", async () => {
575
- if (!chosenFiles.length) return;
576
- $("formError").textContent = "";
577
- $("start").disabled = true;
578
- $("start").textContent = "アップロード中";
579
-
580
- const body = new FormData();
581
- chosenFiles.forEach(f => body.append("files", f));
582
- body.append("minSeconds", $("minSeconds").value || "60");
583
- body.append("modelSize", $("modelSize").value);
584
- body.append("numSpeakers", $("numSpeakers").value || "0");
585
- body.append("diarization", $("diarization").checked ? "true" : "false");
586
- body.append("fast", $("fast").checked ? "true" : "false");
587
- body.append("prompt", $("prompt").value);
588
- body.append("hfToken", $("hfToken").value);
589
-
590
- try {
591
- const res = await fetch("/jobs", { method: "POST", body });
592
- const data = await res.json();
593
- if (!res.ok) throw new Error(data.detail || "処理を開始できませんでした。");
594
-
595
- $("progress").hidden = false;
596
- $("summary").hidden = true;
597
- $("output").hidden = true;
598
- $("start").textContent = "処理中";
599
- poller = setInterval(() => check(data.jobId), 1200);
600
- check(data.jobId);
601
- } catch (err) {
602
- $("formError").textContent = err.message;
603
- $("start").disabled = false;
604
- $("start").textContent = "文字起こしを始める";
605
- }
606
- });
607
-
608
- /* ---------- polling ---------- */
609
- async function check(jobId) {
610
- const res = await fetch("/jobs/" + jobId);
611
- if (!res.ok) return;
612
- const job = await res.json();
613
-
614
- $("progressNow").textContent = job.message || "";
615
- $("progressCount").textContent = job.total ? `${job.done} / ${job.total}` : "";
616
- $("progressBar").style.width = job.total ? (job.done / job.total * 100) + "%" : "4%";
617
-
618
- if (job.state === "running") return;
619
- clearInterval(poller);
620
- $("start").disabled = false;
621
- $("start").textContent = "文字起こしを始める";
622
-
623
- if (job.state === "error") {
624
- $("progressNow").textContent = job.message;
625
- $("progressBar").style.background = "var(--warn)";
626
- return;
627
- }
628
-
629
- $("progress").hidden = true;
630
- render(job, jobId);
631
- }
632
-
633
- /* ---------- render ---------- */
634
- let currentJob = null;
635
- let currentJobId = null;
636
-
637
- function render(job, jobId) {
638
- currentJob = job;
639
- currentJobId = jobId;
640
- const s = job.summary || {};
641
- $("tally").innerHTML = [
642
- ["書き起こし", s.transcribed || 0],
643
- ["長さ不足で除外", s.skipped || 0],
644
- ["失敗", s.failed || 0],
645
- ["要確認", s.flagged || 0],
646
- ].map(([label, n]) => `<div><b>${n}</b><span>${label}</span></div>`).join("");
647
-
648
- const links = [
649
- [`/jobs/${jobId}/bundle`, "一式をZIPで保存"],
650
- job.hasXlsx ? [`/jobs/${jobId}/xlsx`, "Excel(1録音=1シート)"] : null,
651
- [`/jobs/${jobId}/all-csv`, "全発話をまとめたCSV"],
652
- ].filter(Boolean);
653
- $("downloads").innerHTML = links
654
- .map(([href, label]) => `<a class="dl" href="${href}" download>${label}</a>`)
655
- .join("");
656
- $("bundleNote").textContent =
657
- "ZIPの中身:書き起こし/(録音ごとのテキスト)、データ/(録音ごとのCSV)、"
658
- + "処理結果一覧.csv、全発話.csv、録音ごと.xlsx";
659
- $("summary").hidden = false;
660
-
661
- const gate = parseFloat($("minSeconds").value || "30");
662
- const longest = Math.max(1, ...job.results.map(r => r.duration));
663
- const openByDefault = job.results.filter(r => r.status === "transcribed").length <= 3;
664
-
665
- $("files").innerHTML = job.results
666
- .map(r => card(r, jobId, gate, longest, openByDefault))
667
- .join("");
668
- $("output").hidden = false;
669
- }
670
-
671
- function card(r, jobId, gate, longest, openByDefault) {
672
- const width = Math.min(100, r.duration / longest * 100);
673
- const gatePos = Math.min(100, gate / longest * 100);
674
- const done = r.status === "transcribed";
675
-
676
- let tag = "";
677
- if (r.status === "skipped") tag = `<span class="tag cut">${esc(r.reason)}</span>`;
678
- if (r.status === "error") tag = `<span class="tag err">失敗</span>`;
679
-
680
- const flagged = r.utterances.filter(u => u.needsReview).length;
681
- const meta = [
682
- r.durationLabel,
683
- r.recordedAt,
684
- done ? `${r.utterances.length}発話` : null,
685
- done && r.speakers.length ? `話者${r.speakers.length}人` : null,
686
- flagged ? `要確認${flagged}` : null,
687
- ].filter(Boolean).join(" · ");
688
-
689
- const preview = done && r.utterances.length
690
- ? `<p class="preview">${esc(r.utterances[0].text)}</p>`
691
- : "";
692
-
693
- /* 話者の色は録音ごとに割り当てる。別の録音の SPEAKER_00 は別人なので、
694
- 色を共有すると同一人物に見えてしまう。 */
695
- const palette = {};
696
- let next = 0;
697
- let score = "";
698
-
699
- if (done) {
700
- score = r.utterances.length
701
- ? `<div class="score">${r.utterances.map(u => {
702
- if (!(u.speaker in palette)) palette[u.speaker] = next++ % 5;
703
- const flag = u.needsReview
704
- ? `<span class="flag" title="${esc(u.reviewNote)}">要確認</span>` : "";
705
- const seek = r.hasAudio
706
- ? `<button type="button" class="time seek" onclick="seekTo(${r.index}, ${u.startSeconds})"
707
- title="ここから再生">${u.start}</button>`
708
- : `<span class="time">${u.start}</span>`;
709
- return `<div class="line">
710
- ${seek}
711
- <span class="spine sp${palette[u.speaker]}"></span>
712
- <span class="said">
713
- <span class="who">${esc(u.speaker)}</span>
714
- <span class="what">${esc(u.text)}${flag}</span>
715
- </span>
716
- </div>`;
717
- }).join("")}</div>`
718
- : `<p class="empty">発話を検出できませんでした。</p>`;
719
- } else {
720
- score = `<p class="empty">${esc(r.reason || "書き起こしはありません。")}</p>`;
721
- }
722
-
723
- /* 誰がどれだけ喋ったか。商談だと相手の話量が分かると役に立つ。 */
724
- const talkBox = (done && (r.talk || []).length > 1)
725
- ? `<div class="talk">${r.talk
726
- .map(t => `<span><b>${esc(t.name)}</b> ${mmss(t.seconds)}・${t.count}回</span>`)
727
- .join("")}</div>`
728
- : "";
729
-
730
- /* 聞き返すための音声。時刻を押すとその位置から鳴る。 */
731
- const player = r.hasAudio
732
- ? `<audio class="player" id="audio-${r.index}" controls preload="none"
733
- src="/jobs/${jobId}/audio/${r.index}"></audio>`
734
- : "";
735
-
736
- const tools = `<div class="file-tools">
737
- <button type="button" onclick="copyOne(${r.index}, this)">本文をコピー</button>
738
- <a href="/jobs/${jobId}/files/${r.index}.txt" download>テキスト</a>
739
- ${done && r.utterances.length ? `<a href="/jobs/${jobId}/files/${r.index}.csv" download>CSV</a>` : ""}
740
- </div>`;
741
-
742
- return `<details class="file" ${done && openByDefault ? "open" : ""}>
743
- <summary>
744
- <div class="file-head">
745
- <span class="no">${String(r.index).padStart(3, "0")}</span>
746
- <span class="file-name">${esc(r.shortName)}</span>
747
- ${tag}
748
- <span class="file-meta">${esc(meta)}</span>
749
- </div>
750
- <div class="len ${done ? "" : "cut"}">
751
- <i style="width:${width}%"></i>
752
- <span class="gate" style="left:${gatePos}%" title="${gate}秒"></span>
753
- </div>
754
- ${preview}
755
- </summary>
756
- <div class="file-body">${tools}${player}${talkBox}${score}</div>
757
- </details>`;
758
- }
759
-
760
- /* 押された時刻から音声を鳴らす。聞き返しながら文字を直せるように。 */
761
- function seekTo(index, seconds) {
762
- const audio = $(`audio-${index}`);
763
- if (!audio) return;
764
- audio.currentTime = seconds;
765
- audio.play().catch(() => { /* 自動再生を止める設定なら、利用者が再生を押す */ });
766
- }
767
-
768
- function mmss(seconds) {
769
- const s = Math.round(seconds || 0);
770
- return `${Math.floor(s / 60)}分${String(s % 60).padStart(2, "0")}秒`;
771
- }
772
-
773
- function copyOne(index, btn) {
774
- const r = (currentJob?.results || []).find(x => x.index === index);
775
- if (!r) return;
776
- const head = `${r.shortName}\n録音日時 ${r.recordedAt || "不明"} / 録音長 ${r.durationLabel}\n\n`;
777
- const body = r.utterances
778
- .map(u => `${u.start}${u.speaker ? " " + u.speaker : ""}${u.needsReview ? " ※要確認" : ""}\n ${u.text}`)
779
- .join("\n\n");
780
- navigator.clipboard.writeText(head + body).then(() => {
781
- const original = btn.textContent;
782
- btn.textContent = "コピーしました";
783
- setTimeout(() => { btn.textContent = original; }, 1600);
784
- });
785
- }
786
-
787
- function esc(str) {
788
- return String(str ?? "").replace(/[&<>"']/g, c =>
789
- ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
790
- }
791
- </script>
792
- </body>
793
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
pipeline.py CHANGED
@@ -346,42 +346,92 @@ _diarizer_cache: dict = {}
346
  _model_lock = threading.Lock()
347
 
348
 
349
- def load_whisper(backend: str, model_size: str):
350
- key = (backend, model_size)
 
 
 
 
 
 
 
 
351
  if key in _whisper_cache:
352
  return _whisper_cache[key]
353
 
354
  with _model_lock:
355
  if key in _whisper_cache: # 待っている間に別のスレッドが読み終えている
356
  return _whisper_cache[key]
357
- return _build_whisper(key, backend, model_size)
358
 
359
 
360
- def _build_whisper(key, backend: str, model_size: str):
361
  if backend == "mlx":
362
  import mlx_whisper # noqa: F401 読み込み確認のみ
363
  obj = MLX_MODELS.get(model_size, MLX_MODELS["large-v3"])
364
  else:
365
  from faster_whisper import WhisperModel
366
 
367
- device, compute_type = "cpu", "int8"
368
- try:
369
- import torch
 
 
 
370
 
371
- if torch.cuda.is_available():
372
- device, compute_type = "cuda", "float16"
373
- except Exception:
374
- pass
375
  # スレッド数を指定しないと既定の4本しか使わず、実測で倍近く遅かった
376
  obj = WhisperModel(
377
  model_size, device=device, compute_type=compute_type,
378
  cpu_threads=cpu_threads(),
379
  )
380
 
381
- _whisper_cache[key] = obj
 
382
  return obj
383
 
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  def default_backend() -> str:
386
  return "mlx" if is_apple_silicon() else "faster"
387
 
@@ -402,8 +452,8 @@ def is_model_ready() -> bool:
402
  return (default_backend(), default_model_size()) in _whisper_cache
403
 
404
 
405
- def load_diarizer(hf_token: str):
406
- if "pipeline" in _diarizer_cache:
407
  return _diarizer_cache["pipeline"]
408
 
409
  from pyannote.audio import Pipeline
@@ -433,14 +483,15 @@ def load_diarizer(hf_token: str):
433
  try: # GPU が使えれば使う(未対応の演算はCPUに落ちる)
434
  import torch
435
 
436
- if torch.cuda.is_available():
437
  pipe.to(torch.device("cuda"))
438
  elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
439
  pipe.to(torch.device("mps"))
440
  except Exception:
441
  pass
442
 
443
- _diarizer_cache["pipeline"] = pipe
 
444
  return pipe
445
 
446
 
@@ -509,8 +560,9 @@ def load_waveform(wav: Path) -> dict:
509
  return {"waveform": torch.from_numpy(samples.copy()), "sample_rate": rate}
510
 
511
 
512
- def diarize(wav: Path, hf_token: str, num_speakers: int | None) -> list[tuple[float, float, str]]:
513
- pipe = load_diarizer(hf_token)
 
514
  kwargs = {}
515
  if num_speakers and num_speakers > 0:
516
  kwargs["num_speakers"] = num_speakers
@@ -613,6 +665,8 @@ def run(
613
  diarization_enabled: bool = False,
614
  fast: bool = True,
615
  progress: Callable[[str, int, int], None] | None = None,
 
 
616
  ) -> list[FileResult]:
617
  ensure_ffmpeg()
618
 
@@ -658,9 +712,12 @@ def run(
658
  notify("条件に合う録音がありませんでした", 0, 0)
659
  return results
660
 
661
- notify("文字起しモデルを読み込んでいす", 0, total)
662
- model = load_whisper(backend, model_size)
663
- if diarization_enabled:
 
 
 
664
  notify("話者分離モデルを読み込んでいます", 0, total)
665
  load_diarizer(hf_token)
666
 
@@ -672,12 +729,12 @@ def run(
672
  to_wav16k(path, wav)
673
 
674
  notify(f"文字起こし中: {label}", index, total)
675
- segments = transcribe(wav, backend, model, prompt, fast=fast)
676
 
677
  turns: list[tuple[float, float, str]] = []
678
  if diarization_enabled:
679
  notify(f"話者を判定中: {label}", index, total)
680
- turns = diarize(wav, hf_token, num_speakers)
681
 
682
  entry.utterances = merge(segments, turns, diarized=diarization_enabled)
683
  entry.speakers = sorted({u.speaker for u in entry.utterances if u.speaker})
 
346
  _model_lock = threading.Lock()
347
 
348
 
349
+ def load_whisper(backend: str, model_size: str, device: str = ""):
350
+ """文字起こしモデルを用意する。
351
+
352
+ device に "cuda" を渡したときは使い回さない。ZeroGPU では呼び出しのたびに
353
+ 別のプロセスへGPUが割り当てられるため、前回のモデルは引き継げない。
354
+ """
355
+ if device == "cuda":
356
+ return _build_whisper(None, backend, model_size, device)
357
+
358
+ key = (backend, model_size, device)
359
  if key in _whisper_cache:
360
  return _whisper_cache[key]
361
 
362
  with _model_lock:
363
  if key in _whisper_cache: # 待っている間に別のスレッドが読み終えている
364
  return _whisper_cache[key]
365
+ return _build_whisper(key, backend, model_size, device)
366
 
367
 
368
+ def _build_whisper(key, backend: str, model_size: str, device: str = ""):
369
  if backend == "mlx":
370
  import mlx_whisper # noqa: F401 読み込み確認のみ
371
  obj = MLX_MODELS.get(model_size, MLX_MODELS["large-v3"])
372
  else:
373
  from faster_whisper import WhisperModel
374
 
375
+ if not device:
376
+ device = "cuda" if _cuda_ready() else "cpu"
377
+ compute_type = "float16" if device == "cuda" else "int8"
378
+
379
+ if device == "cuda":
380
+ _preload_cuda_libs()
381
 
 
 
 
 
382
  # スレッド数を指定しないと既定の4本しか使わず、実測で倍近く遅かった
383
  obj = WhisperModel(
384
  model_size, device=device, compute_type=compute_type,
385
  cpu_threads=cpu_threads(),
386
  )
387
 
388
+ if key is not None:
389
+ _whisper_cache[key] = obj
390
  return obj
391
 
392
 
393
+ def _preload_cuda_libs() -> None:
394
+ """faster-whisper が GPU を使うのに要る cuDNN / cuBLAS を先に読み込む。
395
+
396
+ これらは torch と一緒に site-packages へ入るが、動的リンカの探索路には
397
+ 載っていない。ctranslate2 が自力で見つけられず「libcudnn が無い」と
398
+ 言って落ちるので、こちらで先に開いておく。
399
+ """
400
+ if platform.system() != "Linux":
401
+ return
402
+
403
+ import ctypes
404
+ import glob
405
+ import site
406
+
407
+ roots = list(site.getsitepackages()) + [site.getusersitepackages()]
408
+ for root in roots:
409
+ for pattern in ("nvidia/**/libcudnn*.so*", "nvidia/**/libcublas*.so*",
410
+ "nvidia/**/libcublasLt*.so*"):
411
+ for path in glob.glob(os.path.join(root, pattern), recursive=True):
412
+ try:
413
+ ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
414
+ except OSError:
415
+ continue
416
+
417
+
418
+ def _cuda_ready() -> bool:
419
+ try:
420
+ import torch
421
+
422
+ return bool(torch.cuda.is_available())
423
+ except Exception:
424
+ return False
425
+
426
+
427
+ def transcribe_file(wav: Path, model_size: str, prompt: str, fast: bool,
428
+ device: str = "") -> list[dict]:
429
+ """1ファイル分の文字起こし。GPUで動かすときはこの関数ごと外から包む。"""
430
+ backend = default_backend()
431
+ model = load_whisper(backend, model_size, device)
432
+ return transcribe(wav, backend, model, prompt, fast=fast)
433
+
434
+
435
  def default_backend() -> str:
436
  return "mlx" if is_apple_silicon() else "faster"
437
 
 
452
  return (default_backend(), default_model_size()) in _whisper_cache
453
 
454
 
455
+ def load_diarizer(hf_token: str, device: str = ""):
456
+ if device != "cuda" and "pipeline" in _diarizer_cache:
457
  return _diarizer_cache["pipeline"]
458
 
459
  from pyannote.audio import Pipeline
 
483
  try: # GPU が使えれば使う(未対応の演算はCPUに落ちる)
484
  import torch
485
 
486
+ if device == "cuda" or (not device and torch.cuda.is_available()):
487
  pipe.to(torch.device("cuda"))
488
  elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
489
  pipe.to(torch.device("mps"))
490
  except Exception:
491
  pass
492
 
493
+ if device != "cuda":
494
+ _diarizer_cache["pipeline"] = pipe
495
  return pipe
496
 
497
 
 
560
  return {"waveform": torch.from_numpy(samples.copy()), "sample_rate": rate}
561
 
562
 
563
+ def diarize(wav: Path, hf_token: str, num_speakers: int | None,
564
+ device: str = "") -> list[tuple[float, float, str]]:
565
+ pipe = load_diarizer(hf_token, device)
566
  kwargs = {}
567
  if num_speakers and num_speakers > 0:
568
  kwargs["num_speakers"] = num_speakers
 
665
  diarization_enabled: bool = False,
666
  fast: bool = True,
667
  progress: Callable[[str, int, int], None] | None = None,
668
+ transcriber: Callable[..., list[dict]] | None = None,
669
+ diarizer: Callable[..., list[tuple[float, float, str]]] | None = None,
670
  ) -> list[FileResult]:
671
  ensure_ffmpeg()
672
 
 
712
  notify("条件に合う録音がありませんでした", 0, 0)
713
  return results
714
 
715
+ # GPU側で動かすときは、の場で読み込まない。
716
+ # ZeroGPU は呼び出しのたびに別プロセスへ割り当てるので、ここで読んでも無駄になる。
717
+ if transcriber is None:
718
+ notify("文字起こしモデルを読み込んでいます", 0, total)
719
+ load_whisper(backend, model_size)
720
+ if diarization_enabled and diarizer is None:
721
  notify("話者分離モデルを読み込んでいます", 0, total)
722
  load_diarizer(hf_token)
723
 
 
729
  to_wav16k(path, wav)
730
 
731
  notify(f"文字起こし中: {label}", index, total)
732
+ segments = (transcriber or transcribe_file)(wav, model_size, prompt, fast)
733
 
734
  turns: list[tuple[float, float, str]] = []
735
  if diarization_enabled:
736
  notify(f"話者を判定中: {label}", index, total)
737
+ turns = (diarizer or diarize)(wav, hf_token, num_speakers)
738
 
739
  entry.utterances = merge(segments, turns, diarized=diarization_enabled)
740
  entry.speakers = sorted({u.speaker for u in entry.utterances if u.speaker})
requirements.txt CHANGED
@@ -1,12 +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
 
1
+ gradio>=6.0
 
 
2
 
3
+ # ZeroGPU の割り当てに使う(Spaces 以外では入らなくてよい
4
+ spaces>=0.30 ; sys_platform == "linux"
5
+
6
+ # 文字起こし。GPUがあれば CUDA、無ければ CPU で動く
7
  faster-whisper>=1.1
8
 
9
+ # 話者分離。torch / torchaudio も一緒に入る
 
10
  pyannote.audio>=3.1
11
 
12
  openpyxl>=3.1
tests/test_app.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """画面まわり。文字起こし本体は動かさず、組み立てと片付けだけを確かめる。"""
2
+
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+
11
+ import app as web # noqa: E402
12
+ import pipeline as pl # noqa: E402
13
+
14
+
15
+ SEGMENTS = [
16
+ {"start": 0.0, "end": 8.0, "text": "本日はありがとうございます"},
17
+ {"start": 9.0, "end": 14.0, "text": "よろしくお願いします"},
18
+ ]
19
+ TURNS = [(0.0, 8.5, "SPEAKER_00"), (8.5, 14.5, "SPEAKER_01")]
20
+
21
+
22
+ def build(playback: Path | None = None) -> pl.FileResult:
23
+ entry = pl.FileResult(
24
+ original_name="A社_初回訪問.wav", duration=95.0,
25
+ recorded_at="2026-08-22 14:00:00", status="transcribed", playback=playback,
26
+ )
27
+ entry.utterances = pl.merge(SEGMENTS, TURNS)
28
+ entry.speakers = sorted({u.speaker for u in entry.utterances})
29
+ return entry
30
+
31
+
32
+ def test_発話の時刻に再生ボタンが付く(tmp_path):
33
+ audio = tmp_path / "000.mp3"
34
+ audio.write_bytes(b"x")
35
+
36
+ html = web.render([build(audio)])
37
+ assert '<audio id="audio-1"' in html
38
+ assert 'data-audio="audio-1"' in html
39
+ assert 'data-t="9.00"' in html # 2つ目の発話の開始位置
40
+ assert "00:00:09" in html
41
+
42
+
43
+ def test_音声が無い録音には再生機を出さない():
44
+ html = web.render([build()])
45
+ assert "<audio" not in html
46
+ assert "SPEAKER_00" in html # 本文は出る
47
+
48
+
49
+ def test_本文のHTMLを素通しさせない():
50
+ """録音の中身が画面を壊さないように。"""
51
+ entry = build()
52
+ entry.utterances[0].text = '<script>alert("x")</script>'
53
+ html = web.render([entry])
54
+ assert "<script>alert" not in html
55
+ assert "&lt;script&gt;" in html
56
+
57
+
58
+ def test_除外した録音は理由が出る():
59
+ entry = pl.FileResult(original_name="短い.wav", duration=12.0,
60
+ status="skipped", reason="60秒未満")
61
+ html = web.render([entry])
62
+ assert "除外" in html and "60秒未満" in html
63
+
64
+
65
+ def test_集計が出る():
66
+ skipped = pl.FileResult(original_name="短い.wav", duration=12.0,
67
+ status="skipped", reason="60秒未満")
68
+ text = web.summarize([build(), skipped])
69
+ assert "書き起こし 1" in text
70
+ assert "長さ不足で除外 1" in text
71
+
72
+
73
+ def test_古い作業場は片付けられる(tmp_path, monkeypatch):
74
+ """商談の音声を必要以上に残さないため、時間で消す。"""
75
+ monkeypatch.setattr(web, "WORK_ROOT", tmp_path)
76
+
77
+ old = tmp_path / "job-old"
78
+ old.mkdir()
79
+ stale = time.time() - web.JOB_TTL_SECONDS - 60
80
+ import os
81
+ os.utime(old, (stale, stale))
82
+
83
+ fresh = tmp_path / "job-fresh"
84
+ fresh.mkdir()
85
+
86
+ web.sweep_old_jobs()
87
+
88
+ assert not old.exists()
89
+ assert fresh.exists()
90
+
91
+
92
+ def test_ファイル未選択なら止める():
93
+ with pytest.raises(Exception) as err:
94
+ web.process([], 60, "large-v3-turbo", 2, False, True, "", "")
95
+ assert "選んで" in str(err.value)
96
+
97
+
98
+ def test_トークン無しで話者分離はできない(monkeypatch, tmp_path):
99
+ monkeypatch.delenv("HF_TOKEN", raising=False)
100
+ with pytest.raises(Exception) as err:
101
+ web.process([str(tmp_path / "a.wav")], 60, "large-v3-turbo", 2, True, True, "", "")
102
+ assert "トークン" in str(err.value)
103
+
104
+
105
+ def test_GPUの飾りは環境で切り替わる():
106
+ """自分のPCでは素通し。ZeroGPU のときだけ GPU を割り当てる。"""
107
+ def plain():
108
+ return "ok"
109
+
110
+ if not web.ON_ZERO_GPU:
111
+ assert web.on_gpu(plain) is plain
tests/test_diarization.py CHANGED
@@ -68,7 +68,7 @@ def test_WAVを自前で読める(wav):
68
  ids=["pyannote3", "pyannote4"],
69
  )
70
  def test_どちらの戻り値でも同じ区間になる(monkeypatch, wav, result):
71
- monkeypatch.setattr(pl, "load_diarizer", lambda token: lambda audio, **kw: result)
72
  assert pl.diarize(wav, "token", None) == ROWS
73
 
74
 
@@ -81,7 +81,7 @@ def test_パスではなく波形を渡している(monkeypatch, wav):
81
  seen["kwargs"] = kwargs
82
  return FakeAnnotation(ROWS)
83
 
84
- monkeypatch.setattr(pl, "load_diarizer", lambda token: fake_pipe)
85
  pl.diarize(wav, "token", 2)
86
 
87
  assert set(seen["audio"]) == {"waveform", "sample_rate"}
@@ -92,7 +92,7 @@ def test_話者人数を指定しなければ自動判定に任せる(monkeypatc
92
  seen = {}
93
  monkeypatch.setattr(
94
  pl, "load_diarizer",
95
- lambda token: lambda audio, **kw: (seen.update(kw), FakeAnnotation(ROWS))[1],
96
  )
97
  pl.diarize(wav, "token", None)
98
  assert seen == {}
 
68
  ids=["pyannote3", "pyannote4"],
69
  )
70
  def test_どちらの戻り値でも同じ区間になる(monkeypatch, wav, result):
71
+ monkeypatch.setattr(pl, "load_diarizer", lambda token, device="": lambda audio, **kw: result)
72
  assert pl.diarize(wav, "token", None) == ROWS
73
 
74
 
 
81
  seen["kwargs"] = kwargs
82
  return FakeAnnotation(ROWS)
83
 
84
+ monkeypatch.setattr(pl, "load_diarizer", lambda token, device="": fake_pipe)
85
  pl.diarize(wav, "token", 2)
86
 
87
  assert set(seen["audio"]) == {"waveform", "sample_rate"}
 
92
  seen = {}
93
  monkeypatch.setattr(
94
  pl, "load_diarizer",
95
+ lambda token, device="": lambda audio, **kw: (seen.update(kw), FakeAnnotation(ROWS))[1],
96
  )
97
  pl.diarize(wav, "token", None)
98
  assert seen == {}
tests/test_web.py DELETED
@@ -1,80 +0,0 @@
1
- """Web側の窓口。実際の文字起こしは動かさず、結果が入った状態から確かめる。"""
2
-
3
- import sys
4
- from pathlib import Path
5
-
6
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
-
8
- from fastapi.testclient import TestClient # noqa: E402
9
-
10
- import app as web # noqa: E402
11
- import pipeline as pl # noqa: E402
12
-
13
- client = TestClient(web.app)
14
-
15
- SEGMENTS = [
16
- {"start": 0.0, "end": 8.0, "text": "本日はありがとうございます"},
17
- {"start": 9.0, "end": 14.0, "text": "よろしくお願いします"},
18
- ]
19
- TURNS = [(0.0, 8.5, "SPEAKER_00"), (8.5, 14.5, "SPEAKER_01")]
20
-
21
-
22
- def seed_job(job_id: str = "testjob") -> pl.FileResult:
23
- entry = pl.FileResult(original_name="A社_初回訪問.wav", duration=90.0, status="transcribed")
24
- entry.utterances = pl.merge(SEGMENTS, TURNS)
25
- entry.speakers = sorted({u.speaker for u in entry.utterances})
26
- web.JOBS[job_id] = {
27
- "state": "done",
28
- "message": "完了",
29
- "results": web.serialize([entry]),
30
- "outputs": None,
31
- }
32
- return entry
33
-
34
-
35
- def test_外から開いたら公開先として扱う():
36
- """HOSTED の書き忘れで警告が消えないよう、接続元でも判定していること。"""
37
- body = client.get("/environment").json()
38
- assert body["hosted"] is True
39
- assert body["passwordSet"] is False # 未設定なら画面に警告が出る
40
-
41
-
42
- def test_自分のPCから開いたらローカル扱い():
43
- local = TestClient(web.app, client=("127.0.0.1", 51000))
44
- assert local.get("/environment").json()["hosted"] is False
45
-
46
-
47
- def test_一覧に話者と発話量が入る():
48
- seed_job("job-list")
49
- record = client.get("/jobs/job-list").json()["results"][0]
50
- assert [u["speaker"] for u in record["utterances"]] == ["SPEAKER_00", "SPEAKER_01"]
51
- assert [t["name"] for t in record["talk"]] == ["SPEAKER_00", "SPEAKER_01"]
52
-
53
-
54
- def test_知らない処理番号は404():
55
- assert client.get("/jobs/nothing-here").status_code == 404
56
-
57
-
58
- def test_古い処理は音声ごと片付けられる(tmp_path, monkeypatch):
59
- """商談の音声を必要以上に残さないため、時間で消す。"""
60
- import time
61
-
62
- monkeypatch.setattr(web, "WORK_ROOT", tmp_path)
63
-
64
- old_dir = tmp_path / "old-job"
65
- (old_dir / "再生用").mkdir(parents=True)
66
- (old_dir / "再生用" / "000.mp3").write_bytes(b"x")
67
-
68
- web.JOBS["old-job"] = {
69
- "state": "done",
70
- "createdAt": time.time() - web.JOB_TTL_SECONDS - 60,
71
- "results": [],
72
- }
73
- seed_job("fresh-job")
74
- web.JOBS["fresh-job"]["createdAt"] = time.time()
75
-
76
- web.sweep_old_jobs()
77
-
78
- assert "old-job" not in web.JOBS
79
- assert not old_dir.exists()
80
- assert "fresh-job" in web.JOBS