ryota commited on
Commit
7bad3b7
·
1 Parent(s): 7ff69e0

話者に名前を付けられるようにし、既定のしきい値を60秒にする

Browse files

- 録音ごとに SPEAKER_00 等へ名前を割り当てる欄を画面に追加
- 反映すると出力(テキスト・CSV・Excel・ZIP)を作り直す。文字起こしはやり直さない
- 元ラベルを Utterance に残し、何度でも付け直せるようにした
- 話者ごとの発話時間と回数を画面と書き起こしテキストに追加
- 短い録音を外すしきい値の既定を30秒から60秒に変更
- 話者まわりの自動テストを追加(11件)

Files changed (7) hide show
  1. README.md +23 -3
  2. app.py +47 -2
  3. index.html +97 -6
  4. pipeline.py +53 -1
  5. requirements-dev.txt +1 -0
  6. tests/test_speakers.py +88 -0
  7. tests/test_web.py +71 -0
README.md CHANGED
@@ -99,12 +99,27 @@ Spaces の無料枠は 2 vCPU。**動くが、速くはない。**
99
 
100
  ## 3. 使い方
101
 
102
- 1. ZIPを選ぶ(ドラッグでも可)
103
- 2. しきい値(既定30秒)と話者人数を設定
104
  3. 「文字起こしを始める」
105
 
106
  処理が終わると、ファイルごとの書き起こしが画面に出て、CSVを保存できる。
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  ### 出力
109
 
110
  **録音1本ごとに分ける**のを基本にしている。「一式をZIPで保存」を押すと次の構成で落ちてくる。
@@ -130,7 +145,7 @@ Spaces の無料枠は 2 vCPU。**動くが、速くはない。**
130
  | 列 | 内容 |
131
  |---|---|
132
  | 開始 / 終了 | 発話のタイムコード |
133
- | 話者 | SPEAKER_00, SPEAKER_01 …(話者分離オフなら空) |
134
  | 発話内容 | 書き起こし |
135
  | 要確認 | 誤認識の疑いがある行に印 |
136
  | 備考 | 疑わしいと判定した理由 |
@@ -152,6 +167,7 @@ Excelで文字化けしないようBOM付きUTF-8で出力している。
152
  3. Space の Secret に `HF_TOKEN` として登録する(自分のPCで動かすなら `setx HF_TOKEN "hf_..."`)
153
 
154
  1対1の商談なら「話者の人数」に `2` を入れると精度が上がる。
 
155
  ただし**話者分離は文字起こしと同じかそれ以上の時間がかかる**ので、無料枠では現実的でないことが多い。
156
 
157
  ---
@@ -200,9 +216,13 @@ spinthoughts/
200
  ├── setup.ps1 自分のPCで動かすための初回セットアップ
201
  ├── start.cmd 自分のPCでの起動
202
  ├── requirements.txt
 
203
  └── README.md
204
  ```
205
 
 
 
 
206
  処理の中身を変えたいときは `pipeline.py` を見る。
207
  `DEFAULT_PROMPT` に自社名・製品名・業界用語を足すと、固有名詞の精度が上がる。
208
  `HALLUCINATION_PATTERNS` には、自分の環境で出やすい誤認識を足せる。
 
99
 
100
  ## 3. 使い方
101
 
102
+ 1. ZIPを選ぶ(ドラッグでも可。中身はWAV・m4a・mp3など
103
+ 2. しきい値(**既定60**)と話者人数を設定
104
  3. 「文字起こしを始める」
105
 
106
  処理が終わると、ファイルごとの書き起こしが画面に出て、CSVを保存できる。
107
 
108
+ **短い録音は自動で外れる。** 既定では60秒未満のファイルは文字起こしせず、
109
+ 「60秒未満」という理由付きで一覧にだけ残す。言い間違いの録り直しや操作ミスの数秒ファイルを、
110
+ 処理時間とCSVから締め出すための仕組み。秒数は画面で変えられる。
111
+
112
+ ### 話者に名前を付ける
113
+
114
+ 話者分離を使うと、まず `SPEAKER_00` `SPEAKER_01` という仮のラベルが振られる。
115
+ 録音カードを開くと**名前の入力欄**が出るので、`弊社 田中` `A社 佐藤` のように入れて
116
+ 「名前を反映」を押す。**画面・テキスト・CSV・Excel のすべてに反映される。**
117
+
118
+ - 文字起こしはやり直さないので一瞬で終わる
119
+ - 何度でも付け直せる(元のラベルを内部に持っているため、前の名前に引きずられない)
120
+ - 空欄にすると元のラベルに戻る
121
+ - 名前の下に**話者ごとの発話時間と回数**が出る。商談でどちらが喋っていたかの目安になる
122
+
123
  ### 出力
124
 
125
  **録音1本ごとに分ける**のを基本にしている。「一式をZIPで保存」を押すと次の構成で落ちてくる。
 
145
  | 列 | 内容 |
146
  |---|---|
147
  | 開始 / 終了 | 発話のタイムコード |
148
+ | 話者 | 付けた名前。付けていなければ SPEAKER_00 (話者分離オフなら空) |
149
  | 発話内容 | 書き起こし |
150
  | 要確認 | 誤認識の疑いがある行に印 |
151
  | 備考 | 疑わしいと判定した理由 |
 
167
  3. Space の Secret に `HF_TOKEN` として登録する(自分のPCで動かすなら `setx HF_TOKEN "hf_..."`)
168
 
169
  1対1の商談なら「話者の人数」に `2` を入れると精度が上がる。
170
+ 振られたラベルには、あとから画面で名前を付けられる(上の「話者に名前を付ける」)。
171
  ただし**話者分離は文字起こしと同じかそれ以上の時間がかかる**ので、無料枠では現実的でないことが多い。
172
 
173
  ---
 
216
  ├── setup.ps1 自分のPCで動かすための初回セットアップ
217
  ├── start.cmd 自分のPCでの起動
218
  ├── requirements.txt
219
+ ├── tests/ 話者まわりの自動テスト(pytest)
220
  └── README.md
221
  ```
222
 
223
+ テストは `.venv\Scripts\python.exe -m pytest tests -q` で走る(`pip install -r requirements-dev.txt` が必要)。
224
+ 文字起こし本体は重いので動かさず、話者の割り当て・名前の付け替え・出力の中身だけを確かめている。
225
+
226
  処理の中身を変えたいときは `pipeline.py` を見る。
227
  `DEFAULT_PROMPT` に自社名・製品名・業界用語を足すと、固有名詞の精度が上がる。
228
  `HALLUCINATION_PATTERNS` には、自分の環境で出やすい誤認識を足せる。
app.py CHANGED
@@ -80,11 +80,17 @@ def serialize(results: list[pl.FileResult]) -> list[dict]:
80
  "status": r.status,
81
  "reason": r.reason,
82
  "speakers": r.speakers,
 
 
 
 
 
83
  "utterances": [
84
  {
85
  "start": pl.hhmmss(u.start),
86
  "end": pl.hhmmss(u.end),
87
  "speaker": u.speaker,
 
88
  "text": u.text,
89
  "needsReview": u.needs_review,
90
  "reviewNote": u.review_note,
@@ -115,6 +121,7 @@ def worker(job_id: str, zip_path: Path, options: dict) -> None:
115
  job_id,
116
  state="done",
117
  message="完了",
 
118
  results=serialize(results),
119
  summary={
120
  "total": len(results),
@@ -160,7 +167,7 @@ def environment() -> JSONResponse:
160
  @app.post("/jobs")
161
  async def create_job(
162
  file: UploadFile = File(...),
163
- minSeconds: float = Form(30.0),
164
  modelSize: str = Form(""),
165
  numSpeakers: int = Form(0),
166
  diarization: bool = Form(True),
@@ -216,7 +223,8 @@ def job_status(job_id: str) -> JSONResponse:
216
  job = JOBS.get(job_id)
217
  if not job:
218
  raise HTTPException(404, "処理が見つかりません。")
219
- return JSONResponse({k: v for k, v in job.items() if k != "outputs"})
 
220
 
221
 
222
  def _outputs(job_id: str) -> dict:
@@ -227,6 +235,43 @@ def _outputs(job_id: str) -> dict:
227
  return job["outputs"]
228
 
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  @app.get("/jobs/{job_id}/bundle")
231
  def download_bundle(job_id: str) -> FileResponse:
232
  out = _outputs(job_id)
 
80
  "status": r.status,
81
  "reason": r.reason,
82
  "speakers": r.speakers,
83
+ "rawSpeakers": pl.speaker_labels(r),
84
+ "talk": [
85
+ {"name": name, "seconds": round(secs, 1), "count": count}
86
+ for name, secs, count in pl.speaking_time(r)
87
+ ],
88
  "utterances": [
89
  {
90
  "start": pl.hhmmss(u.start),
91
  "end": pl.hhmmss(u.end),
92
  "speaker": u.speaker,
93
+ "rawSpeaker": u.raw_speaker,
94
  "text": u.text,
95
  "needsReview": u.needs_review,
96
  "reviewNote": u.review_note,
 
121
  job_id,
122
  state="done",
123
  message="完了",
124
+ entries=results, # 話者名の付け替えに使う(JSONには出さない)
125
  results=serialize(results),
126
  summary={
127
  "total": len(results),
 
167
  @app.post("/jobs")
168
  async def create_job(
169
  file: UploadFile = File(...),
170
+ minSeconds: float = Form(60.0),
171
  modelSize: str = Form(""),
172
  numSpeakers: int = Form(0),
173
  diarization: bool = Form(True),
 
223
  job = JOBS.get(job_id)
224
  if not job:
225
  raise HTTPException(404, "処理が見つかりません。")
226
+ hidden = {"outputs", "entries"}
227
+ return JSONResponse({k: v for k, v in job.items() if k not in hidden})
228
 
229
 
230
  def _outputs(job_id: str) -> dict:
 
235
  return job["outputs"]
236
 
237
 
238
+ @app.post("/jobs/{job_id}/speakers")
239
+ async def rename_speakers(job_id: str, request: Request) -> JSONResponse:
240
+ """SPEAKER_00 などに名前を付け直し、出力ファイルを作り直す。
241
+
242
+ 文字起こしはやり直さないので一瞬で終わる。
243
+ """
244
+ with LOCK:
245
+ job = JOBS.get(job_id)
246
+ if not job or not job.get("entries"):
247
+ raise HTTPException(404, "処理が見つかりません。")
248
+
249
+ body = await request.json()
250
+ index = int(body.get("index", 0))
251
+ names = body.get("names") or {}
252
+ if not isinstance(names, dict):
253
+ raise HTTPException(400, "名前の指定が正しくありません。")
254
+
255
+ entries = job["entries"]
256
+ if not 1 <= index <= len(entries):
257
+ raise HTTPException(404, "その録音が見つかりません。")
258
+
259
+ pl.apply_speaker_names(
260
+ entries[index - 1],
261
+ {str(k): str(v)[:40] for k, v in names.items()},
262
+ )
263
+
264
+ workdir = WORK_ROOT / job_id
265
+ outputs = pl.build_outputs(entries, workdir / "出力", job_id)
266
+
267
+ with LOCK:
268
+ job["outputs"] = outputs
269
+ job["results"] = serialize(entries)
270
+ results = job["results"]
271
+
272
+ return JSONResponse({"results": results})
273
+
274
+
275
  @app.get("/jobs/{job_id}/bundle")
276
  def download_bundle(job_id: str) -> FileResponse:
277
  out = _outputs(job_id)
index.html CHANGED
@@ -230,6 +230,36 @@
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
 
@@ -264,8 +294,8 @@
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">
@@ -455,9 +485,11 @@ async function check(jobId) {
455
 
456
  /* ---------- render ---------- */
457
  let currentJob = null;
 
458
 
459
  function render(job, jobId) {
460
  currentJob = job;
 
461
  const s = job.summary || {};
462
  $("tally").innerHTML = [
463
  ["書き起こし", s.transcribed || 0],
@@ -520,14 +552,15 @@ function card(r, jobId, gate, longest, openByDefault) {
520
  if (done) {
521
  score = r.utterances.length
522
  ? `<div class="score">${r.utterances.map(u => {
523
- if (!(u.speaker in palette)) palette[u.speaker] = next++ % 5;
 
524
  const flag = u.needsReview
525
  ? `<span class="flag" title="${esc(u.reviewNote)}">要確認</span>` : "";
526
  return `<div class="line">
527
  <span class="time">${u.start}</span>
528
- <span class="spine sp${palette[u.speaker]}"></span>
529
  <span class="said">
530
- <span class="who">${esc(u.speaker)}</span>
531
  <span class="what">${esc(u.text)}${flag}</span>
532
  </span>
533
  </div>`;
@@ -537,6 +570,26 @@ function card(r, jobId, gate, longest, openByDefault) {
537
  score = `<p class="empty">${esc(r.reason || "書き起こしはありません。")}</p>`;
538
  }
539
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  const tools = `<div class="file-tools">
541
  <button type="button" onclick="copyOne(${r.index}, this)">本文をコピー</button>
542
  <a href="/jobs/${jobId}/files/${r.index}.txt" download>テキスト</a>
@@ -557,10 +610,48 @@ function card(r, jobId, gate, longest, openByDefault) {
557
  </div>
558
  ${preview}
559
  </summary>
560
- <div class="file-body">${tools}${score}</div>
561
  </details>`;
562
  }
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  function copyOne(index, btn) {
565
  const r = (currentJob?.results || []).find(x => x.index === index);
566
  if (!r) return;
 
230
  .sp2 { background: var(--sp-2); } .sp3 { background: var(--sp-3); }
231
  .sp4 { background: var(--sp-4); }
232
 
233
+ /* 話者に名前を付ける欄 */
234
+ .speakers {
235
+ display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
236
+ margin: 12px 0 2px; padding: 12px 14px;
237
+ background: var(--accent-soft); border-radius: 8px;
238
+ }
239
+ .speakers-label { font-size: 12px; color: var(--accent); font-weight: 600; }
240
+ .speaker-set { display: flex; align-items: center; gap: 6px; }
241
+ .speaker-set .raw {
242
+ font-family: var(--mono); font-size: 10.5px; color: #fff;
243
+ padding: 2px 7px; border-radius: 3px; white-space: nowrap;
244
+ }
245
+ .speaker-set input {
246
+ width: 150px; padding: 5px 8px; font-size: 13px; font-family: var(--sans);
247
+ border: 1px solid var(--rule); border-radius: 5px; background: var(--card); color: var(--ink);
248
+ }
249
+ .speaker-set input:focus { outline: none; border-color: var(--accent); }
250
+ .speakers button {
251
+ padding: 6px 14px; font-size: 12.5px; font-family: var(--sans);
252
+ border: 1px solid var(--accent); border-radius: 5px;
253
+ background: var(--accent); color: #fff; cursor: pointer;
254
+ }
255
+ .speakers button:disabled { opacity: .5; cursor: default; }
256
+
257
+ /* 誰がどれだけ喋ったか */
258
+ .talk { display: flex; flex-wrap: wrap; gap: 14px; margin: 10px 2px 0; font-size: 12px; color: var(--ink-soft); }
259
+ .talk b { color: var(--ink); font-weight: 600; }
260
+
261
+ .who.named { font-family: var(--sans); font-size: 12px; letter-spacing: 0; text-transform: none; color: var(--ink); }
262
+
263
  .empty { color: var(--ink-faint); font-size: 13px; }
264
  [hidden] { display: none !important; }
265
 
 
294
  <div class="grid">
295
  <div class="field">
296
  <label for="minSeconds">この長さ以上だけ処理する</label>
297
+ <input type="number" id="minSeconds" value="60" min="0" step="1">
298
+ <span class="hint">秒。これより短い録音は文字起こしせず一覧だけ残します。</span>
299
  </div>
300
 
301
  <div class="field">
 
485
 
486
  /* ---------- render ---------- */
487
  let currentJob = null;
488
+ let currentJobId = null;
489
 
490
  function render(job, jobId) {
491
  currentJob = job;
492
+ currentJobId = jobId;
493
  const s = job.summary || {};
494
  $("tally").innerHTML = [
495
  ["書き起こし", s.transcribed || 0],
 
552
  if (done) {
553
  score = r.utterances.length
554
  ? `<div class="score">${r.utterances.map(u => {
555
+ const key = u.rawSpeaker || u.speaker;
556
+ if (!(key in palette)) palette[key] = next++ % 5;
557
  const flag = u.needsReview
558
  ? `<span class="flag" title="${esc(u.reviewNote)}">要確認</span>` : "";
559
  return `<div class="line">
560
  <span class="time">${u.start}</span>
561
+ <span class="spine sp${palette[key]}"></span>
562
  <span class="said">
563
+ <span class="who ${u.rawSpeaker && u.speaker !== u.rawSpeaker ? "named" : ""}">${esc(u.speaker)}</span>
564
  <span class="what">${esc(u.text)}${flag}</span>
565
  </span>
566
  </div>`;
 
570
  score = `<p class="empty">${esc(r.reason || "書き起こしはありません。")}</p>`;
571
  }
572
 
573
+ /* 話者に名前を付ける欄。話者分離を使ったときだけ出す。 */
574
+ const speakerBox = (done && (r.rawSpeakers || []).length)
575
+ ? `<div class="speakers">
576
+ <span class="speakers-label">話者の名前</span>
577
+ ${r.rawSpeakers.map(raw => `<label class="speaker-set">
578
+ <span class="raw sp${raw in palette ? palette[raw] : 0}">${esc(raw)}</span>
579
+ <input type="text" data-raw="${esc(raw)}" maxlength="40"
580
+ value="${esc(nameOf(r, raw))}" placeholder="例: 弊社 田中">
581
+ </label>`).join("")}
582
+ <button type="button" onclick="renameSpeakers(${r.index}, this)">名前を反映</button>
583
+ </div>`
584
+ : "";
585
+
586
+ /* 誰がどれだけ喋ったか。商談だと相手の話量が分かると役に立つ。 */
587
+ const talkBox = (done && (r.talk || []).length > 1)
588
+ ? `<div class="talk">${r.talk
589
+ .map(t => `<span><b>${esc(t.name)}</b> ${mmss(t.seconds)}・${t.count}回</span>`)
590
+ .join("")}</div>`
591
+ : "";
592
+
593
  const tools = `<div class="file-tools">
594
  <button type="button" onclick="copyOne(${r.index}, this)">本文をコピー</button>
595
  <a href="/jobs/${jobId}/files/${r.index}.txt" download>テキスト</a>
 
610
  </div>
611
  ${preview}
612
  </summary>
613
+ <div class="file-body">${tools}${speakerBox}${talkBox}${score}</div>
614
  </details>`;
615
  }
616
 
617
+ function mmss(seconds) {
618
+ const s = Math.round(seconds || 0);
619
+ return `${Math.floor(s / 60)}分${String(s % 60).padStart(2, "0")}秒`;
620
+ }
621
+
622
+ /* その話者に人が付けた名前。まだ付けていなければ空(入力欄は例示だけ出す)。 */
623
+ function nameOf(r, raw) {
624
+ const u = r.utterances.find(x => x.rawSpeaker === raw);
625
+ return u && u.speaker !== raw ? u.speaker : "";
626
+ }
627
+
628
+ async function renameSpeakers(index, btn) {
629
+ const box = btn.closest(".speakers");
630
+ const names = {};
631
+ box.querySelectorAll("input[data-raw]").forEach(input => {
632
+ names[input.dataset.raw] = input.value.trim();
633
+ });
634
+
635
+ btn.disabled = true;
636
+ const original = btn.textContent;
637
+ btn.textContent = "反映しています";
638
+ try {
639
+ const res = await fetch(`/jobs/${currentJobId}/speakers`, {
640
+ method: "POST",
641
+ headers: { "Content-Type": "application/json" },
642
+ body: JSON.stringify({ index, names }),
643
+ });
644
+ if (!res.ok) throw new Error("反映できませんでした。");
645
+ const data = await res.json();
646
+ /* 出力ファイルもサーバー側で作り直されているので、画面ごと描き直す。 */
647
+ render({ ...currentJob, results: data.results }, currentJobId);
648
+ } catch (err) {
649
+ btn.textContent = err.message;
650
+ setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000);
651
+ return;
652
+ }
653
+ }
654
+
655
  function copyOne(index, btn) {
656
  const r = (currentJob?.results || []).find(x => x.index === index);
657
  if (!r) return;
pipeline.py CHANGED
@@ -172,6 +172,9 @@ class Utterance:
172
  text: str
173
  needs_review: bool = False
174
  review_note: str = ""
 
 
 
175
 
176
 
177
  @dataclass
@@ -410,19 +413,58 @@ def merge(
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 = "",
@@ -567,6 +609,16 @@ def transcript_text(entry: FileResult) -> str:
567
 
568
  lines.append(rule)
569
  lines.append(f"全 {len(entry.utterances)} 発話 / 要確認 {flagged} 件")
 
 
 
 
 
 
 
 
 
 
570
  lines.append("※ 話者名は自動判定の仮ラベルです。担当者名への置き換えと内容の確認をしてください。")
571
  return "\n".join(lines) + "\n"
572
 
 
172
  text: str
173
  needs_review: bool = False
174
  review_note: str = ""
175
+ # 話者分離が付けた元のラベル(SPEAKER_00 など)。
176
+ # speaker は人が付けた名前で上書きされるので、やり直せるよう別に残す。
177
+ raw_speaker: str = ""
178
 
179
 
180
  @dataclass
 
413
  text=seg["text"],
414
  needs_review=bool(note),
415
  review_note=" / ".join(note),
416
+ raw_speaker=best_label if diarized else "",
417
  )
418
  )
419
 
420
  return utterances
421
 
422
 
423
+ def speaker_labels(entry: "FileResult") -> list[str]:
424
+ """この録音で話者分離が付けたラベルを、出てきた順に返す。"""
425
+ seen: list[str] = []
426
+ for u in entry.utterances:
427
+ if u.raw_speaker and u.raw_speaker not in seen:
428
+ seen.append(u.raw_speaker)
429
+ return seen
430
+
431
+
432
+ def apply_speaker_names(entry: "FileResult", names: dict[str, str]) -> None:
433
+ """SPEAKER_00 などのラベルに、人が付けた名前を割り当てる。
434
+
435
+ 元ラベルからの変換なので、何度やり直しても結果は同じになる。
436
+ 空文字を渡した話者は元のラベルに戻る。
437
+ """
438
+ for u in entry.utterances:
439
+ if not u.raw_speaker:
440
+ continue
441
+ u.speaker = (names.get(u.raw_speaker) or "").strip() or u.raw_speaker
442
+ entry.speakers = sorted({u.speaker for u in entry.utterances if u.speaker})
443
+
444
+
445
+ def speaking_time(entry: "FileResult") -> list[tuple[str, float, int]]:
446
+ """話者ごとの(名前, 発話秒数, 発話回数)。誰がどれだけ喋ったかの目安。"""
447
+ totals: dict[str, list[float]] = {}
448
+ for u in entry.utterances:
449
+ if not u.speaker:
450
+ continue
451
+ row = totals.setdefault(u.speaker, [0.0, 0])
452
+ row[0] += max(0.0, u.end - u.start)
453
+ row[1] += 1
454
+ return sorted(
455
+ ((name, secs, int(count)) for name, (secs, count) in totals.items()),
456
+ key=lambda item: item[1],
457
+ reverse=True,
458
+ )
459
+
460
+
461
  # ---------------------------------------------------------------- orchestrator
462
 
463
 
464
  def run(
465
  zip_path: Path,
466
  workdir: Path,
467
+ min_seconds: float = 60.0,
468
  backend: str = "auto",
469
  model_size: str = "",
470
  hf_token: str = "",
 
609
 
610
  lines.append(rule)
611
  lines.append(f"全 {len(entry.utterances)} 発話 / 要確認 {flagged} 件")
612
+
613
+ talk = speaking_time(entry)
614
+ if talk:
615
+ total = sum(secs for _, secs, _ in talk) or 1.0
616
+ lines.append("")
617
+ lines.append("話者ごとの発話")
618
+ for name, secs, count in talk:
619
+ lines.append(
620
+ f" {name} {hhmmss(secs)}({secs / total * 100:.0f}%) / {count}回"
621
+ )
622
  lines.append("※ 話者名は自動判定の仮ラベルです。担当者名への置き換えと内容の確認をしてください。")
623
  return "\n".join(lines) + "\n"
624
 
requirements-dev.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ pytest>=8.0
tests/test_speakers.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """話者の割り当てと名前の付け替え。
2
+
3
+ 文字起こし本体(重い)は動かさず、話者まわりの筋道だけを確かめる。
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+
13
+ import pipeline as pl # noqa: E402
14
+
15
+
16
+ SEGMENTS = [
17
+ {"start": 0.0, "end": 8.0, "text": "本日はお時間をいただきありがとうございます"},
18
+ {"start": 9.0, "end": 14.0, "text": "こちらこそ、よろしくお願いします"},
19
+ {"start": 15.0, "end": 30.0, "text": "早速ですがお見積りの件です"},
20
+ ]
21
+ TURNS = [(0.0, 8.5, "SPEAKER_00"), (8.5, 14.5, "SPEAKER_01"), (14.5, 31.0, "SPEAKER_00")]
22
+
23
+
24
+ def build() -> pl.FileResult:
25
+ entry = pl.FileResult(original_name="A社_初回訪問.wav", duration=90.0)
26
+ entry.utterances = pl.merge(SEGMENTS, TURNS)
27
+ entry.speakers = sorted({u.speaker for u in entry.utterances if u.speaker})
28
+ entry.status = "transcribed"
29
+ return entry
30
+
31
+
32
+ def test_話者が重なりの長いラベルに割り当てられる():
33
+ entry = build()
34
+ assert [u.speaker for u in entry.utterances] == ["SPEAKER_00", "SPEAKER_01", "SPEAKER_00"]
35
+ assert pl.speaker_labels(entry) == ["SPEAKER_00", "SPEAKER_01"]
36
+
37
+
38
+ def test_名前を付けると発話も一覧も置き換わる():
39
+ entry = build()
40
+ pl.apply_speaker_names(entry, {"SPEAKER_00": "弊社 田中", "SPEAKER_01": "A社 佐藤"})
41
+ assert [u.speaker for u in entry.utterances] == ["弊社 田中", "A社 佐藤", "弊社 田中"]
42
+ assert entry.speakers == ["A社 佐藤", "弊社 田中"]
43
+
44
+
45
+ def test_名前は何度でも付け直せる():
46
+ """元ラベルを残しているので、2回目は1回目の結果に引きずられない。"""
47
+ entry = build()
48
+ pl.apply_speaker_names(entry, {"SPEAKER_00": "弊社 田中"})
49
+ pl.apply_speaker_names(entry, {"SPEAKER_00": "弊社 鈴木", "SPEAKER_01": "A社 佐藤"})
50
+ assert [u.speaker for u in entry.utterances] == ["弊社 鈴木", "A社 佐藤", "弊社 鈴木"]
51
+
52
+
53
+ def test_空欄にすると元のラベルに戻る():
54
+ entry = build()
55
+ pl.apply_speaker_names(entry, {"SPEAKER_00": "弊社 田中"})
56
+ pl.apply_speaker_names(entry, {"SPEAKER_00": " "})
57
+ assert entry.utterances[0].speaker == "SPEAKER_00"
58
+
59
+
60
+ def test_話者分離をしていない録音は要確認が付かない():
61
+ """区間が無いことを誤認識と数えると、全行に印が付いて意味が無くなる。"""
62
+ utterances = pl.merge(SEGMENTS, [], diarized=False)
63
+ assert all(u.speaker == "" for u in utterances)
64
+ assert all(not u.needs_review for u in utterances)
65
+
66
+
67
+ def test_発話量は多い順に出る():
68
+ entry = build()
69
+ pl.apply_speaker_names(entry, {"SPEAKER_00": "弊社 田中", "SPEAKER_01": "A社 佐藤"})
70
+ talk = pl.speaking_time(entry)
71
+ assert [name for name, _, _ in talk] == ["弊社 田中", "A社 佐藤"]
72
+ assert talk[0][1] == pytest.approx(23.0) # 8秒 + 15秒
73
+ assert talk[0][2] == 2
74
+
75
+
76
+ def test_出力ファイルに付けた名前が入る(tmp_path):
77
+ entry = build()
78
+ pl.apply_speaker_names(entry, {"SPEAKER_00": "弊社 田中", "SPEAKER_01": "A社 佐藤"})
79
+ outputs = pl.build_outputs([entry], tmp_path / "出力", "test")
80
+
81
+ csv_text = Path(outputs["per_file"][0]["csv"]).read_text(encoding="utf-8-sig")
82
+ assert "弊社 田中" in csv_text and "A社 佐藤" in csv_text
83
+
84
+ txt = Path(outputs["per_file"][0]["txt"]).read_text(encoding="utf-8")
85
+ assert "話者ごとの発話" in txt
86
+ assert "弊社 田中" in txt
87
+
88
+ assert Path(outputs["bundle"]).exists()
tests/test_web.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "entries": [entry],
30
+ "results": web.serialize([entry]),
31
+ "outputs": None,
32
+ }
33
+ return entry
34
+
35
+
36
+ def test_一覧に元ラベルと発話量が入る():
37
+ seed_job("job-list")
38
+ body = client.get("/jobs/job-list").json()
39
+ record = body["results"][0]
40
+ assert record["rawSpeakers"] == ["SPEAKER_00", "SPEAKER_01"]
41
+ assert record["utterances"][0]["rawSpeaker"] == "SPEAKER_00"
42
+ assert [t["name"] for t in record["talk"]] == ["SPEAKER_00", "SPEAKER_01"]
43
+
44
+
45
+ def test_生の結果はJSONに出さない():
46
+ """FileResult はそのままではJSONにできず、外に出す必要も無い。"""
47
+ seed_job("job-hidden")
48
+ assert "entries" not in client.get("/jobs/job-hidden").json()
49
+
50
+
51
+ def test_名前を送ると結果が置き換わる(tmp_path, monkeypatch):
52
+ monkeypatch.setattr(web, "WORK_ROOT", tmp_path)
53
+ seed_job("job-rename")
54
+
55
+ res = client.post(
56
+ "/jobs/job-rename/speakers",
57
+ json={"index": 1, "names": {"SPEAKER_00": "弊社 田中", "SPEAKER_01": "A社 佐藤"}},
58
+ )
59
+ assert res.status_code == 200
60
+
61
+ record = res.json()["results"][0]
62
+ assert [u["speaker"] for u in record["utterances"]] == ["弊社 田中", "A社 佐藤"]
63
+ # 出力ファイルも作り直されている
64
+ assert Path(web.JOBS["job-rename"]["outputs"]["bundle"]).exists()
65
+
66
+
67
+ def test_知らない録音番号は404(tmp_path, monkeypatch):
68
+ monkeypatch.setattr(web, "WORK_ROOT", tmp_path)
69
+ seed_job("job-404")
70
+ res = client.post("/jobs/job-404/speakers", json={"index": 9, "names": {}})
71
+ assert res.status_code == 404