transcribe.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/env python3
  2. """
  3. Qwen3-ASR-1.7B 智能分段转录 v3
  4. - Silero VAD ONNX 在自然停顿处切分
  5. - 合并小段为 30~60s 大块,边界完整
  6. - 多线程并行转录
  7. - 输出 TXT / SRT / JSON
  8. """
  9. import argparse
  10. import json
  11. import os
  12. from pathlib import Path
  13. import re
  14. import shutil
  15. import subprocess
  16. import sys
  17. import threading
  18. import time
  19. from concurrent.futures import ThreadPoolExecutor, as_completed
  20. from dataclasses import dataclass
  21. from datetime import datetime
  22. from pathlib import Path
  23. sys.stdout = __import__("io").TextIOWrapper(
  24. sys.stdout.buffer, encoding="utf-8", errors="replace"
  25. )
  26. import numpy as np
  27. import soundfile as sf
  28. import torch
  29. import silero_vad
  30. import requests
  31. MODEL = "ggml-org/Qwen3-ASR-1.7B-GGUF:Q8_0"
  32. PORT = os.environ.get("QWEN3_ASR_PORT", "18003")
  33. API_URL = f"http://localhost:{PORT}/v1/audio/transcriptions"
  34. REPORT_DIR = Path(os.path.dirname(__file__)) / "reports"
  35. _http = requests.Session()
  36. _http.headers.update({"Accept": "application/json"})
  37. @dataclass
  38. class Segment:
  39. start: float
  40. end: float
  41. @dataclass
  42. class Chunk:
  43. segments: list[Segment]
  44. text: str = ""
  45. @property
  46. def start(self) -> float:
  47. return self.segments[0].start
  48. @property
  49. def end(self) -> float:
  50. return self.segments[-1].end
  51. @property
  52. def audio_duration(self) -> float:
  53. return sum(s.end - s.start for s in self.segments)
  54. @property
  55. def wall_duration(self) -> float:
  56. return self.end - self.start
  57. def ffmpeg_run(args: list[str], check=True):
  58. subprocess.run(
  59. ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args,
  60. capture_output=True, check=check
  61. )
  62. def extract_wav(src: str, dst: str, start: float, duration: float):
  63. ffmpeg_run([
  64. "-i", src, "-ss", str(start), "-t", str(duration),
  65. "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", dst
  66. ])
  67. def find_speech_segments(audio_path: str) -> list[Segment]:
  68. """用 Silero VAD 检出所有语音片段"""
  69. data, sr = sf.read(audio_path)
  70. audio_t = torch.from_numpy(data).float()
  71. model = silero_vad.load_silero_vad(onnx=True)
  72. raw = silero_vad.get_speech_timestamps(
  73. audio_t, model,
  74. threshold=0.5,
  75. min_speech_duration_ms=300,
  76. min_silence_duration_ms=400,
  77. return_seconds=True
  78. )
  79. return [Segment(s["start"], s["end"]) for s in raw]
  80. def group_chunks(segments: list[Segment],
  81. target: float = 45,
  82. min_size: float = 20,
  83. max_size: float = 75) -> list[Chunk]:
  84. """将语音片段合并为合适的转录块"""
  85. chunks = []
  86. cur: list[Segment] = []
  87. cur_audio = 0.0
  88. for seg in segments:
  89. dur = seg.end - seg.start
  90. if cur_audio + dur > max_size and cur_audio >= min_size:
  91. chunks.append(Chunk(cur))
  92. cur = []
  93. cur_audio = 0.0
  94. cur.append(seg)
  95. cur_audio += dur
  96. if cur:
  97. if cur_audio < min_size and chunks:
  98. chunks[-1].segments.extend(cur)
  99. else:
  100. chunks.append(Chunk(cur))
  101. return chunks
  102. def transcribe_chunk(audio_path: str, chunk: Chunk, tmp_dir: str) -> str:
  103. wav = os.path.join(tmp_dir, f"c{chunk.start:07.1f}.wav")
  104. extract_wav(audio_path, wav, chunk.start, chunk.wall_duration)
  105. with open(wav, "rb") as f:
  106. r = _http.post(API_URL, data={"model": MODEL}, files={"file": f}, timeout=300)
  107. text = r.text.strip()
  108. try:
  109. parsed = r.json()
  110. text = parsed.get("text", text)
  111. except Exception:
  112. pass
  113. return text
  114. def strip_prefix(text: str) -> str:
  115. idx = text.find("<asr_text>")
  116. return text[idx + 10:].strip() if idx >= 0 else text.strip()
  117. def to_srt(chunks: list[Chunk]) -> str:
  118. lines = []
  119. for i, c in enumerate(chunks, 1):
  120. text = strip_prefix(c.text)
  121. start_s = format_ts(c.start)
  122. end_s = format_ts(c.end)
  123. lines.append(f"{i}")
  124. lines.append(f"{start_s} --> {end_s}")
  125. lines.append(text)
  126. lines.append("")
  127. return "\n".join(lines)
  128. def format_ts(seconds: float) -> str:
  129. h = int(seconds // 3600)
  130. m = int((seconds % 3600) // 60)
  131. s = seconds % 60
  132. return f"{h:02d}:{m:02d}:{s:06.3f}".replace(".", ",")
  133. def transcribe_video(video_path: str,
  134. target: int = 45,
  135. workers: int = 4,
  136. output: str = "txt"):
  137. if not os.path.isfile(video_path):
  138. print(f"[ERROR] File not found: {video_path}")
  139. return
  140. t_start = time.time()
  141. video_name = Path(video_path).stem
  142. tmp = os.path.join(os.environ.get("TEMP", "C:\\Temp"), "qwen3_vad")
  143. os.makedirs(tmp, exist_ok=True)
  144. # ── 1. 提取完整 WAV ──
  145. full_wav = os.path.join(tmp, "full.wav")
  146. print("Extracting audio...", end=" ", flush=True)
  147. t0 = time.time()
  148. extract_wav(video_path, full_wav, 0, 999999)
  149. print(f"{time.time()-t0:.2f}s")
  150. # ── 2. VAD 检测 ──
  151. print("VAD detecting speech...", end=" ", flush=True)
  152. t0 = time.time()
  153. segs = find_speech_segments(full_wav)
  154. print(f"{time.time()-t0:.2f}s ({len(segs)} segments)")
  155. # ── 3. 分组 ──
  156. chunks = group_chunks(segs, target=target)
  157. print(f"Grouped into {len(chunks)} chunks")
  158. # ── 4. 并行转写 ──
  159. done = [0]
  160. lock = threading.Lock()
  161. errs = []
  162. def process(c: Chunk):
  163. try:
  164. text = transcribe_chunk(full_wav, c, tmp)
  165. with lock:
  166. c.text = text
  167. except Exception as e:
  168. with lock:
  169. errs.append((c.start, str(e)))
  170. with lock:
  171. done[0] += 1
  172. sys.stdout.write(f"\r [{done[0]}/{len(chunks)}]")
  173. sys.stdout.flush()
  174. t1 = time.time()
  175. with ThreadPoolExecutor(max_workers=workers) as pool:
  176. for f in as_completed([pool.submit(process, c) for c in chunks]):
  177. f.result()
  178. trans_time = time.time() - t1
  179. trans_audio = sum(c.audio_duration for c in chunks)
  180. # ── 5. 合并结果 ──
  181. full_text = "".join(strip_prefix(c.text) for c in chunks if c.text)
  182. # ── 6. 报告 ──
  183. total = time.time() - t_start
  184. rtf = round(total / trans_audio, 4)
  185. speed = round(trans_audio / trans_time, 2)
  186. print(f"\n\nAudio: {trans_audio:.0f}s ({trans_audio/60:.1f} min)")
  187. print(f"Transcribe: {trans_time:.1f}s Total: {total:.1f}s")
  188. print(f"RTF: {rtf} Speed: {speed}x")
  189. print(f"\n--- Result ({len(full_text)} chars) ---")
  190. print(full_text[:600] + "..." if len(full_text) > 600 else full_text)
  191. # ── 7. 输出文件 ──
  192. os.makedirs(REPORT_DIR, exist_ok=True)
  193. now = datetime.now().strftime("%Y%m%d-%H%M%S")
  194. base = os.path.join(REPORT_DIR, f"{video_name}-{now}")
  195. if output in ("txt", "all"):
  196. p = f"{base}.txt"
  197. with open(p, "w", encoding="utf-8") as f:
  198. f.write(full_text)
  199. print(f"TXT: {p}")
  200. if output in ("srt", "all"):
  201. p = f"{base}.srt"
  202. with open(p, "w", encoding="utf-8") as f:
  203. f.write(to_srt(chunks))
  204. print(f"SRT: {p}")
  205. if output in ("json", "all"):
  206. data = {
  207. "model": MODEL, "source": video_path,
  208. "duration": trans_audio,
  209. "rtf": rtf, "speed": speed,
  210. "chunks": [
  211. {"start": c.start, "end": c.end,
  212. "text": strip_prefix(c.text)}
  213. for c in chunks if c.text
  214. ],
  215. "text": full_text
  216. }
  217. p = f"{base}.json"
  218. with open(p, "w", encoding="utf-8") as f:
  219. json.dump(data, f, ensure_ascii=False, indent=2)
  220. print(f"JSON: {p}")
  221. # 清理
  222. try:
  223. shutil.rmtree(tmp)
  224. except Exception:
  225. pass
  226. return full_text
  227. def main():
  228. p = argparse.ArgumentParser(description="Qwen3-ASR 智能分段转录 v3")
  229. p.add_argument("--video", default=None)
  230. p.add_argument("--target", type=int, default=45,
  231. help="目标每段时长(秒), 默认45")
  232. p.add_argument("--workers", type=int, default=4)
  233. p.add_argument("--output", choices=["txt", "srt", "json", "all"],
  234. default="txt", help="输出格式")
  235. args = p.parse_args()
  236. video = args.video or (r"G:\Download\BaiduNetdiskDownload\个人成长--合集\打造AI时代的终身学习力:重构被异化的学习更新中\6-模型预测:模型预测未见-1080P 高清-AVC.mp4"
  237. )
  238. transcribe_video(video, args.target, args.workers, args.output)
  239. if __name__ == "__main__":
  240. main()