Spaces:
Running on Zero
Running on Zero
ryota commited on
Commit ·
7bad3b7
1
Parent(s): 7ff69e0
話者に名前を付けられるようにし、既定のしきい値を60秒にする
Browse files- 録音ごとに SPEAKER_00 等へ名前を割り当てる欄を画面に追加
- 反映すると出力(テキスト・CSV・Excel・ZIP)を作り直す。文字起こしはやり直さない
- 元ラベルを Utterance に残し、何度でも付け直せるようにした
- 話者ごとの発話時間と回数を画面と書き起こしテキストに追加
- 短い録音を外すしきい値の既定を30秒から60秒に変更
- 話者まわりの自動テストを追加(11件)
- README.md +23 -3
- app.py +47 -2
- index.html +97 -6
- pipeline.py +53 -1
- requirements-dev.txt +1 -0
- tests/test_speakers.py +88 -0
- 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. しきい値(既定
|
| 104 |
3. 「文字起こしを始める」
|
| 105 |
|
| 106 |
処理が終わると、ファイルごとの書き起こしが画面に出て、CSVを保存できる。
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
### 出力
|
| 109 |
|
| 110 |
**録音1本ごとに分ける**のを基本にしている。「一式をZIPで保存」を押すと次の構成で落ちてくる。
|
|
@@ -130,7 +145,7 @@ Spaces の無料枠は 2 vCPU。**動くが、速くはない。**
|
|
| 130 |
| 列 | 内容 |
|
| 131 |
|---|---|
|
| 132 |
| 開始 / 終了 | 発話のタイムコード |
|
| 133 |
-
| 話者 | SPEAKER_00
|
| 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(
|
| 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 |
-
|
|
|
|
| 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="
|
| 268 |
-
<span class="hint">秒。短い録音は文字起こしせず
|
| 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 |
-
|
|
|
|
| 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[
|
| 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 =
|
| 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
|