deploy standalone ASR pipeline on tools-pve

This commit is contained in:
Codex
2026-09-18 02:32:14 -07:00
parent f30f72b295
commit b23f00f41a
38 changed files with 182773 additions and 0 deletions
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""S0 — deterministic pre-processing and chunking. No LLM, no cost.
Handles the three transcript shapes the pipeline actually produces:
qwen3cam `【谭凯】…` / `【speaker_3】…` turns, blank-line separated, plus a
`<!-- voiceprint_session: … -->` header line.
qwen3-plain `--- 第N段 [00:00 - 09:00] ---` segment banners then a wall of text
with no speaker information at all.
vibevoice `[mm:ss] S0: …` lines, with `[复读截断]` / `·` noise artifacts.
Chunking rule: never split inside a speaker turn if avoidable, because the
cleaning model needs the whole turn to tell a filler word from a real answer.
Turns longer than the budget are split on sentence boundaries. Each chunk carries
`overlap_prefix` — the tail of the previous chunk, given to the model as
read-only context so it can resolve a pronoun or a name that started earlier
without being asked to re-emit it.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
VOICEPRINT_HDR = re.compile(r"^<!--\s*voiceprint_session:")
SEGMENT_BANNER = re.compile(r"^---\s*第\d+段.*---\s*$")
TURN_TAG = re.compile(r"^【([^】]+)】\s*(.*)$", re.S)
VIBE_TAG = re.compile(r"^\[(\d{1,2}:\d{2})\]\s*(S\d+)\s*[::]\s*(.*)$", re.S)
# Must include ASCII terminators: the qwen3-plain backend emits English stretches
# ("...so that's why they wanted us to set up company.") with no CJK punctuation at
# all, and a CJK-only rule left single 15k-char "sentences" that blew the budget.
SENT_END = re.compile(r"(?<=[。!?;;?!])(?![”’\"'))])")
# Last-resort split points when a run has no terminator for thousands of chars.
SOFT_BREAK = re.compile(r"(?<=[,,、::])")
NOISE = re.compile(r"\[复读截断\]|^[·\s]+$")
# ASR decoders loop when fed non-speech audio (hold music, a phone beep, dead air).
# Measured on the fixture set: one 24k-char call transcript is 77% "beep beep beep…"
# (3,698 repetitions) and a 58k-char meeting has a 3.7k-char run of "呃,". Left in
# place this garbage consumed 11 of 17 chunks, i.e. ~65% of the LLM spend on that
# recording, and provoked the cleaning model into emitting 26k chars of noise per
# chunk. Collapsing it is free, deterministic, and strictly beneficial.
DEGENERATE = re.compile(
r"((?:[A-Za-z]{1,12}|[\u4e00-\u9fff]{1,4})[ ,,、。!?!?]{0,2})\1{7,}")
DEGEN_MIN_SPAN = 120 # only collapse runs long enough to be a real artifact
DEGEN_KEEP = 2 # keep a couple of instances so the context stays readable
def collapse_degenerate(text: str) -> tuple[str, list[dict]]:
"""Replace long ASR repetition loops with a short, explicit marker.
Legitimate speech repetition (对对对, 好的好的) is short and must survive, so the
rule requires both a high repeat count and a span over DEGEN_MIN_SPAN chars.
The marker stays in the text rather than deleting silently: a recording with
four minutes of hold music is a fact worth surfacing in the draft.
"""
events: list[dict] = []
def sub(m):
span, unit = m.group(0), m.group(1)
if len(span) < DEGEN_MIN_SPAN:
return span
n = len(span) // max(1, len(unit))
events.append({"unit": unit, "repeats": n, "chars": len(span)})
return unit * DEGEN_KEEP + f"[…重复噪声已折叠:{unit.strip()}×{n}…]"
return DEGENERATE.sub(sub, text), events
@dataclass
class Turn:
speaker: str # "" when the backend gives no speaker
text: str
idx: int
ts: str = ""
@dataclass
class Chunk:
idx: int
turns: list[Turn]
overlap_prefix: str = ""
meta: dict = field(default_factory=dict)
@property
def body(self) -> str:
out = []
for t in self.turns:
out.append(f"【{t.speaker}】{t.text}" if t.speaker else t.text)
return "\n\n".join(out)
@property
def chars(self) -> int:
return len(self.body)
def detect_backend(raw: str) -> str:
head = raw[:500]
if "CAM++" in head or TURN_TAG.match(raw.strip().splitlines()[-1] if raw.strip() else ""):
return "qwen3cam"
if re.search(r"^\[\d{1,2}:\d{2}\]\s*S\d+\s*[::]", raw, re.M):
return "vibevoice"
if SEGMENT_BANNER.search(raw):
return "qwen3-plain"
return "qwen3cam" if "【" in raw else "qwen3-plain"
def parse(raw: str) -> tuple[list[Turn], dict]:
"""Split a transcript into turns plus header metadata."""
meta: dict = {
"voiceprint_session": "", "voiceprint_named": {}, "banners": [],
"intake": {},
}
lines = raw.splitlines()
body_lines = []
for ln in lines:
if ln.startswith("<!-- intake:") and ln.endswith("-->"):
try:
meta["intake"] = json.loads(
ln[len("<!-- intake:"):-len("-->")].strip())
except (TypeError, ValueError):
pass
continue
if VOICEPRINT_HDR.match(ln):
m = re.search(r"voiceprint_session:\s*(\S+)", ln)
if m:
meta["voiceprint_session"] = m.group(1)
m = re.search(r"clusters:\s*(\d+)", ln)
if m:
meta["clusters"] = int(m.group(1))
continue
if ln.startswith("# 转写"):
for pair in re.findall(r"(speaker_\d+)=([^,\s]+)", ln):
meta["voiceprint_named"][pair[0]] = pair[1]
continue
if SEGMENT_BANNER.match(ln):
meta["banners"].append(ln.strip())
continue
body_lines.append(ln)
backend = detect_backend(raw)
meta["backend"] = backend
turns: list[Turn] = []
if backend == "vibevoice":
for ln in body_lines:
m = VIBE_TAG.match(ln.strip())
if m:
txt = NOISE.sub("", m.group(3)).strip()
if txt:
turns.append(Turn(m.group(2), txt, len(turns), m.group(1)))
return turns, meta
blocks = [b.strip() for b in re.split(r"\n\s*\n", "\n".join(body_lines)) if b.strip()]
for b in blocks:
m = TURN_TAG.match(b)
if m:
txt = NOISE.sub("", m.group(2)).strip()
if txt:
turns.append(Turn(m.group(1), txt, len(turns)))
else:
txt = NOISE.sub("", b).strip()
if txt:
turns.append(Turn("", txt, len(turns)))
return turns, meta
def _pack(pieces: list[str], budget: int) -> list[str]:
out, cur = [], ""
for s in pieces:
if len(cur) + len(s) > budget and cur:
out.append(cur)
cur = s
else:
cur += s
if cur:
out.append(cur)
return out
def _split_long(text: str, budget: int) -> list[str]:
"""Break an over-long single turn, escalating through split granularities.
A hard character cut is the final fallback. It is ugly, but an oversized chunk
is worse: it is exactly the condition that makes the cleaning model start
summarizing instead of rewriting, which is the failure mode this whole
pipeline exists to prevent.
"""
out = _pack([s for s in SENT_END.split(text) if s], budget)
if all(len(p) <= budget for p in out):
return out or [text]
stage2: list[str] = []
for p in out:
if len(p) <= budget:
stage2.append(p)
else:
stage2 += _pack([s for s in SOFT_BREAK.split(p) if s], budget)
final: list[str] = []
for p in stage2:
if len(p) <= budget:
final.append(p)
else:
final += [p[i:i + budget] for i in range(0, len(p), budget)]
return final or [text]
def chunk(raw: str, budget: int = 1800, overlap_chars: int = 200,
collapse_noise: bool = True) -> tuple[list[Chunk], dict]:
events: list[dict] = []
if collapse_noise:
raw, events = collapse_degenerate(raw)
turns, meta = parse(raw)
meta["degenerate_events"] = events
meta["degenerate_chars"] = sum(e["chars"] for e in events)
chunks: list[Chunk] = []
cur: list[Turn] = []
size = 0
def flush():
nonlocal cur, size
if cur:
chunks.append(Chunk(len(chunks), cur, meta={}))
cur, size = [], 0
for t in turns:
if len(t.text) > budget:
flush()
for i, piece in enumerate(_split_long(t.text, budget)):
chunks.append(Chunk(len(chunks), [Turn(t.speaker, piece, t.idx, t.ts)],
meta={"split_turn": True, "part": i}))
continue
if size + len(t.text) > budget and cur:
flush()
cur.append(t)
size += len(t.text)
flush()
if overlap_chars:
for i in range(1, len(chunks)):
prev = chunks[i - 1].body
chunks[i].overlap_prefix = prev[-overlap_chars:]
meta["n_turns"] = len(turns)
meta["n_chunks"] = len(chunks)
meta["speakers"] = sorted({t.speaker for t in turns if t.speaker})
return chunks, meta
if __name__ == "__main__":
import json
import sys
from pathlib import Path
for p in sys.argv[1:]:
raw = Path(p).read_text(errors="ignore")
for budget in (900, 1800, 3600):
cs, meta = chunk(raw, budget)
sizes = [c.chars for c in cs]
print(f"{Path(p).name[:40]:<42} budget={budget:<5} chunks={len(cs):<4} "
f"turns={meta['n_turns']:<5} spk={len(meta['speakers']):<3} "
f"backend={meta['backend']:<12} "
f"size min/avg/max={min(sizes)}/{sum(sizes)//len(sizes)}/{max(sizes)}")